openvibers/openvibe.shared Commits

Add a starter site scaffold and create-site command (#6)

OP OpenVibers committed 2 Oct 2026 · c76302dc26 · 9 files, +234 −0

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by Claude opus (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Shared/pull/6

Changes

ddiff --git a/README.md b/README.md
iindex 7b0bea2..af51b07 100644
---- a/README.md
++++ b/README.md
@@@ -32,6 +32,15 @@ subpath exports rather than a set of `@openvibe/*` packages:
## Consuming it
++### Start a new site
++
++```sh
++npm run create-site -- my-site
++```
++
++The command creates `./my-site` with a pinned Shared dependency, a small Express server,
++discovery routes, and a smoke test. Set `SITE_URL` to the site's public origin before serving it.
++
### Server side: pin a release tarball
```json
ddiff --git a/package.json b/package.json
iindex de0f395..998a32d 100644
---- a/package.json
++++ b/package.json
@@@ -78,10 +78,12 @@
"analytics/",
"docs/schemas/",
"scripts/",
++ "templates/",
"CHANGELOG.md"
],
"scripts": {
"test": "node test/run.js",
++ "create-site": "node scripts/create-site.js",
"size": "node scripts/size-report.js",
"build:icons": "python3 scripts/build-nav-icons.py",
"build:themes": "node scripts/build-theme-loader.js",
ddiff --git a/scripts/create-site.js b/scripts/create-site.js
nnew file mode 100644
iindex 0000000..c9578d4
---- /dev/null
++++ b/scripts/create-site.js
@@@ -0,0 +1,35 @@
++#!/usr/bin/env node
++'use strict';
++
++const fs = require('node:fs');
++const path = require('node:path');
++
++function main(args) {
++ const [name, flag, dir] = args;
++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(name || '') ||
++ (flag !== undefined && flag !== '--dir') ||
++ (flag === '--dir' && (!dir || args.length !== 3)) ||
++ (flag === undefined && args.length !== 1)) {
++ throw new Error('usage: npm run create-site -- <name> [--dir <path>]; name must match ^[a-z][a-z0-9-]{1,39}$');
++ }
++
++ const target = path.resolve(dir || name);
++ if (fs.existsSync(target) && fs.readdirSync(target).length) {
++ throw new Error(`target is not empty: ${target}`);
++ }
++
++ const source = path.join(__dirname, '..', 'templates', 'site');
++ fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: true });
++ function replace(dirPath) {
++ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
++ const file = path.join(dirPath, entry.name);
++ if (entry.isDirectory()) replace(file);
++ else fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replaceAll('__NAME__', name));
++ }
++ }
++ replace(target);
++ console.log(`Created ${target}`);
++}
++
++try { main(process.argv.slice(2)); }
++catch (err) { console.error(err.message); process.exitCode = 1; }
ddiff --git a/templates/site/README.md b/templates/site/README.md
nnew file mode 100644
iindex 0000000..1a6019a
---- /dev/null
++++ b/templates/site/README.md
@@@ -0,0 +1,6 @@
++# __NAME__
++
++Minimal OpenVibe site using a pinned `openvibe-shared` release.
++
++Set `SITE_URL` to the public origin for canonical URLs and discovery files.
++Run `npm install` to create a lockfile, then `npm start` or `npm test`.
ddiff --git a/templates/site/package.json b/templates/site/package.json
nnew file mode 100644
iindex 0000000..bc0480d
---- /dev/null
++++ b/templates/site/package.json
@@@ -0,0 +1,14 @@
++{
++ "name": "__NAME__",
++ "version": "0.1.0",
++ "private": true,
++ "type": "commonjs",
++ "scripts": {
++ "start": "node server/index.js",
++ "test": "node test/smoke.test.js"
++ },
++ "dependencies": {
++ "express": "^4.22.3",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.4.0"
++ }
++}
ddiff --git a/templates/site/server/app.js b/templates/site/server/app.js
nnew file mode 100644
iindex 0000000..ea68863
---- /dev/null
++++ b/templates/site/server/app.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++const express = require('express');
++const serve = require('openvibe-shared/serve');
++const frame = require('openvibe-shared/frame');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++
++const name = '__NAME__';
++const siteUrl = new URL(process.env.SITE_URL || 'http://localhost:3000/');
++const home = new URL('/', siteUrl).href;
++const sitemap = new URL('/sitemap.xml', siteUrl).href;
++const app = express();
++
++app.use('/shared', serve.handler());
++
++app.get('/robots.txt', (req, res) => {
++ res.set('Cache-Control', cache.assetHeaders(req.path));
++ res.type('text/plain').send(seo.robotsTxt({ sitemaps: [sitemap] }));
++});
++
++app.get('/sitemap.xml', (req, res) => {
++ res.set('Cache-Control', cache.assetHeaders(req.path));
++ res.type('application/xml').send(seo.sitemapXml([{ loc: home }]));
++});
++
++app.get('/llms.txt', (req, res) => {
++ res.set('Cache-Control', cache.assetHeaders(req.path));
++ res.type('text/plain').send(seo.llmsTxt({ name, summary: name, sections: [{ title: 'Pages', links: [{ title: 'Home', url: home }] }] }));
++});
++
++app.get('/', cache.applyHtml(), (req, res) => {
++ const head = seo.headTags({ title: name, siteName: name, description: name, canonical: home });
++ res.type('html').send(`<!doctype html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++${head}
++<script src="${serve.url('web-runtime.js')}" defer></script>
++<script src="${serve.url('navbar.js')}" defer></script>
++<script src="${serve.url('footer.js')}" defer></script>
++<script>window.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: '${name}' }); OVWebRuntime.create({ features: {}, routes: [] }).boot(); });</script>
++</head>
++<body>
++${frame.noscriptNav({ name })}
++<main><h1>${seo.esc(name)}</h1></main>
++${frame.footer({ service: name, variant: 'compact' })}
++</body>
++</html>`);
++});
++
++module.exports = app;
ddiff --git a/templates/site/server/index.js b/templates/site/server/index.js
nnew file mode 100644
iindex 0000000..39dca10
---- /dev/null
++++ b/templates/site/server/index.js
@@@ -0,0 +1,5 @@
++'use strict';
++
++const app = require('./app');
++const port = Number(process.env.PORT || 3000);
++app.listen(port, () => console.log(`Listening on ${port}`));
ddiff --git a/templates/site/test/smoke.test.js b/templates/site/test/smoke.test.js
nnew file mode 100644
iindex 0000000..b0e07dd
---- /dev/null
++++ b/templates/site/test/smoke.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const http = require('node:http');
++const app = require('../server/app');
++
++function request(server, path) {
++ return new Promise((resolve, reject) => {
++ const req = http.get({ hostname: '127.0.0.1', port: server.address().port, path }, (res) => {
++ let body = '';
++ res.setEncoding('utf8');
++ res.on('data', (chunk) => { body += chunk; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
++ });
++ req.on('error', reject);
++ });
++}
++
++(async () => {
++ const homeUrl = new URL('/', process.env.SITE_URL || 'http://localhost:3000/').href;
++ const sitemapUrl = new URL('/sitemap.xml', homeUrl).href;
++ const server = http.createServer(app);
++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++ try {
++ const home = await request(server, '/');
++ assert.equal(home.status, 200);
++ assert.ok(home.body.includes(`<link rel="canonical" href="${homeUrl}">`));
++ assert.ok(home.body.includes(`<meta property="og:url" content="${homeUrl}">`));
++ assert.match(home.body, /OpenVibeNavbar\.init\(/);
++ assert.match(home.body, /OVWebRuntime\.create\(\{ features: \{\}, routes: \[\] \}\)\.boot\(\)/);
++ assert.equal(home.headers['cache-control'], 'public, max-age=120, stale-while-revalidate=3600');
++
++ const robots = await request(server, '/robots.txt');
++ assert.equal(robots.status, 200);
++ assert.match(robots.headers['content-type'], /^text\/plain/);
++ assert.ok(robots.body.includes(`Sitemap: ${sitemapUrl}`));
++
++ const sitemap = await request(server, '/sitemap.xml');
++ assert.equal(sitemap.status, 200);
++ assert.match(sitemap.headers['content-type'], /xml/);
++ assert.ok(sitemap.body.includes(`<loc>${homeUrl}</loc>`));
++
++ const llms = await request(server, '/llms.txt');
++ assert.equal(llms.status, 200);
++ assert.match(llms.headers['content-type'], /^text\/plain/);
++
++ const script = await request(server, '/shared/web-runtime.js');
++ assert.equal(script.status, 200);
++ assert.equal(script.headers['cache-control'], 'public, max-age=300, stale-while-revalidate=60');
++ console.log('site smoke: all checks passed');
++ } finally {
++ await new Promise((resolve) => server.close(resolve));
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/create-site.test.js b/test/create-site.test.js
nnew file mode 100644
iindex 0000000..144e0a4
---- /dev/null
++++ b/test/create-site.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { spawnSync } = require('node:child_process');
++
++const root = path.resolve(__dirname, '..');
++const script = path.join(root, 'scripts', 'create-site.js');
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-create-site-'));
++const target = path.join(tmp, 'example-site');
++
++function run(args) {
++ return spawnSync(process.execPath, [script, ...args], { cwd: tmp, encoding: 'utf8' });
++}
++
++try {
++ let result = run(['example-site', '--dir', target]);
++ assert.equal(result.status, 0, result.stderr);
++ for (const file of ['package.json', 'README.md', 'server/app.js', 'server/index.js', 'test/smoke.test.js']) {
++ const content = fs.readFileSync(path.join(target, file), 'utf8');
++ assert.ok(!content.includes('__NAME__'), `${file} has no placeholder`);
++ }
++ const pkg = require(path.join(target, 'package.json'));
++ assert.equal(pkg.name, 'example-site');
++ assert.equal(pkg.dependencies['openvibe-shared'], `https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v${require('../package.json').version}`);
++
++ const marker = path.join(target, 'keep.txt');
++ fs.writeFileSync(marker, 'unchanged');
++ result = run(['example-site', '--dir', target]);
++ assert.equal(result.status, 1);
++ assert.equal(fs.readFileSync(marker, 'utf8'), 'unchanged');
++ assert.match(result.stderr, /target is not empty/);
++
++ for (const bad of ['X', 'a', '1site', 'site_name', `a${'b'.repeat(40)}`]) {
++ result = run([bad, '--dir', path.join(tmp, `invalid-${bad}`)]);
++ assert.equal(result.status, 1, `${bad} must be refused`);
++ assert.ok(!fs.existsSync(path.join(tmp, `invalid-${bad}`)), 'invalid name wrote nothing');
++ }
++
++ fs.mkdirSync(path.join(target, 'node_modules'));
++ fs.symlinkSync(root, path.join(target, 'node_modules', 'openvibe-shared'), 'dir');
++ result = spawnSync(process.execPath, [path.join(target, 'test', 'smoke.test.js')], {
++ cwd: target,
++ encoding: 'utf8',
++ env: { ...process.env, NODE_PATH: [path.join(root, 'node_modules'), process.env.NODE_PATH].filter(Boolean).join(path.delimiter) },
++ timeout: 30000,
++ });
++ assert.equal(result.status, 0, result.stderr || result.stdout);
++ assert.match(result.stdout, /site smoke: all checks passed/);
++
++ console.log('create-site: all checks passed');
++} finally {
++ fs.rmSync(tmp, { recursive: true, force: true });
++}