openvibers/openvibe.shared Commits
Add a starter site scaffold and create-site command (#6)
Changes
@@@ -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@@@ -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",nnew file mode 100644@@@ -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; }nnew file mode 100644@@@ -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`.nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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}`));nnew file mode 100644@@@ -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; });nnew file mode 100644@@@ -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 });++}
@@ -32,6 +32,15 @@ subpath exports rather than a set of `@openvibe/*` packages:
@@ -32,6 +32,15 @@ subpath exports rather than a set of `@openvibe/*` packages:
## Consuming it
## 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
### Server side: pin a release tarball
```json
```json
@@ -78,10 +78,12 @@
@@ -78,10 +78,12 @@
"analytics/",
"analytics/",
"docs/schemas/",
"docs/schemas/",
"scripts/",
"scripts/",
+ "templates/",
"CHANGELOG.md"
"CHANGELOG.md"
],
],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
+ "create-site": "node scripts/create-site.js",
"size": "node scripts/size-report.js",
"size": "node scripts/size-report.js",
"build:icons": "python3 scripts/build-nav-icons.py",
"build:icons": "python3 scripts/build-nav-icons.py",
"build:themes": "node scripts/build-theme-loader.js",
"build:themes": "node scripts/build-theme-loader.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -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; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -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}`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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 });
+}