redweb 0.16.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ import { action, component, defineApp, page, state } from 'redweb';
2
+
3
+ @component()
4
+ class Counter {
5
+ @state() count = 0;
6
+
7
+ @action()
8
+ increment() { this.count += 1; }
9
+
10
+ render() {
11
+ return <button rw-click="increment">Count {this.count}</button>;
12
+ }
13
+ }
14
+
15
+ @page('/')
16
+ class CountersPage {
17
+ first = new Counter();
18
+ second = new Counter();
19
+
20
+ render() { return <main>{this.first}{this.second}</main>; }
21
+ }
22
+
23
+ const app = defineApp({ pages: [CountersPage] });
24
+ app.run();
@@ -0,0 +1,16 @@
1
+ import { action, defineApp, page, state } from 'redweb';
2
+
3
+ @page('/', { shared: true })
4
+ class CounterPage {
5
+ @state() count = 0;
6
+
7
+ @action()
8
+ increment() { this.count += 1; }
9
+
10
+ render() {
11
+ return <button rw-click="increment">Count {this.count}</button>;
12
+ }
13
+ }
14
+
15
+ const app = defineApp({ pages: [CounterPage] });
16
+ app.run();
@@ -1,8 +1,8 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { page, start, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';
2
+ import { page, defineApp, BaseHandler, SocketRoute, RedWebSocket, RedWebRequest, LivePageRequestContext } from 'redweb';
3
3
 
4
4
  // A runnable local demonstration, not a production credential store.
5
- export function createApp(port = 8181) {
5
+ export async function createApp(port = 8181) {
6
6
  const token = randomBytes(32).toString('base64url');
7
7
  let enabled = true;
8
8
  const authenticate = (request: Pick<RedWebRequest, 'headers'>) =>
@@ -28,9 +28,9 @@ export function createApp(port = 8181) {
28
28
  }
29
29
  }
30
30
 
31
- const app = start(Home, { listen: false, authenticate, logger: null });
32
- const team = app.sockets!.addRoute(Team);
33
- app.server.listen(port, '127.0.0.1');
31
+ const app = defineApp({ pages: [Home], sockets: [Team], authenticate, port, bind: '127.0.0.1', logger: null });
32
+ await app.run();
33
+ const team = app.sockets!.routes.find(route => route instanceof Team)!;
34
34
  return {
35
35
  app, team, token,
36
36
  async revoke() {
@@ -42,10 +42,9 @@ export function createApp(port = 8181) {
42
42
  };
43
43
  }
44
44
 
45
- if (require.main === module) {
46
- const demo = createApp();
47
- console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');
48
- console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.
49
- process.once('SIGTERM', () => void demo.shutdown().catch(console.error));
50
- process.once('SIGINT', () => void demo.shutdown().catch(console.error));
51
- }
45
+ if (require.main === module) {
46
+ createApp().then(demo => {
47
+ console.log('Local demo: http://127.0.0.1:8181/ and ws://127.0.0.1:8181/team');
48
+ console.log(`Authorization: Bearer ${demo.token}`); // One fresh local-demo credential per run.
49
+ });
50
+ }
@@ -0,0 +1,2 @@
1
+ body { max-width: 50rem; margin: 3rem auto; padding: 0 1rem; font-family: system-ui, sans-serif; }
2
+ nav { display: flex; gap: 1rem; margin-bottom: 2rem; }
@@ -0,0 +1,22 @@
1
+ import { defineApp, defineSite } from 'redweb';
2
+
3
+ const site = defineSite({
4
+ css: 'site.css',
5
+ layout: content => <body>
6
+ <nav><a href="/">Home</a> · <a href="/about">About</a></nav>
7
+ <main>{content}</main>
8
+ </body>,
9
+ });
10
+
11
+ @site.page('/', { head: { title: 'Home' } })
12
+ class HomePage {
13
+ render() { return <h1>Welcome to Redweb</h1>; }
14
+ }
15
+
16
+ @site.page('/about', { head: { title: 'About' } })
17
+ class AboutPage {
18
+ render() { return <p>Two pages, one layout, no browser framework.</p>; }
19
+ }
20
+
21
+ const app = defineApp({ pages: [HomePage, AboutPage] });
22
+ app.run();
package/docs/topics.json CHANGED
@@ -2,7 +2,7 @@
2
2
  { "id": "getting-started", "title": "Choose a starter and build a working app", "summary": "Requirements, fit, development, tests, and production boundaries.", "source": "docs/GETTING_STARTED.md" },
3
3
  { "id": "application", "title": "One application, one listener", "summary": "Define pages, socket routes and application services together; run and shut down one owned HTTP/WebSocket listener.", "source": "docs/APPLICATION.md" },
4
4
  { "id": "guides/realtime-dashboard", "title": "Build a private realtime dashboard", "summary": "Persistent SQLite cards, account-private updates and sign-out across tabs, with explicit single-process limits.", "source": "docs/guides/realtime-dashboard.md", "recipe": { "template": "dashboard", "file": "src/cards.tsx" } },
5
- { "id": "guides/jsx-without-react", "title": "Render JSX without React", "summary": "TypeScript pages, a shared layout and external CSS, rendered on the server without browser framework code.", "source": "docs/guides/jsx-without-react.md", "recipe": { "template": "site", "file": "src/app.tsx" } },
5
+ { "id": "guides/jsx-without-react", "title": "Render JSX without React", "summary": "TypeScript pages, a shared layout and external CSS, rendered on the server without browser framework code.", "source": "docs/guides/jsx-without-react.md", "codeSource": "docs/snippets/site.tsx", "recipe": { "template": "site", "file": "src/app.tsx" } },
6
6
  { "id": "guides/chatroom", "title": "Build a chatroom with live presence", "summary": "Reusable server-side components, validated forms and disconnect-aware presence, without custom browser socket glue.", "source": "docs/guides/chatroom.md", "recipe": { "template": "chat", "file": "src/chatroom.tsx" } },
7
7
  { "id": "guides/typed-websockets", "title": "Share typed WebSocket contracts", "summary": "One match route, separate join/move/resume handlers and validated client/server payloads from the same schema.", "source": "docs/guides/typed-websockets.md", "recipe": { "template": "socket", "file": "src/handlers.ts" } },
8
8
  { "id": "guides/http-websocket", "title": "Serve HTTP and WebSockets on one port", "summary": "An Express endpoint and raw socket route share one listener with one explicit shutdown owner.", "source": "docs/guides/http-websocket.md", "recipe": { "template": "http-ws", "file": "src/app.tsx" } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redweb",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "A small Node.js foundation for HTTP, WebSockets, multiplayer services, and server-rendered HTML",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -0,0 +1,7 @@
1
+ # Neutral application foundation
2
+
3
+ The default initializer creates a working TypeScript/TSX application without choosing a product domain. Replace `HomePage` with your pages, then add socket routes and application services as needed.
4
+
5
+ Use `--with auth,multiplayer` when the project needs those dependency sets. `auth` adds Express, Zod, their TypeScript declarations, and the Node version required by Redweb's native-SQLite authentication path. `multiplayer` adds Redweb Client and Zod. Capabilities adjust the manifest without copying dashboard, chat, counter, or match-example source. Use an explicit `--template` only when you want a complete example walkthrough.
6
+
7
+ Run `npm test` for the real HTTP and lifecycle checks. Pass `--bare` only when you intentionally do not want the test directory, test scripts, or test-only coverage dependency; the runnable source, assets, build scripts, and development setup stay the same.
@@ -0,0 +1,15 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { listen } = require('./network.cjs');
4
+
5
+ test('neutral application foundation serves real HTML and CSS', { timeout: 10000 }, async t => {
6
+ const origin = await listen(t);
7
+ const response = await fetch(origin);
8
+ assert.equal(response.status, 200);
9
+ const document = await response.text();
10
+ assert.match(document, /<h1>Redweb is ready\.<\/h1>/);
11
+ const css = document.match(/<link rel="stylesheet" href="([^"]+)"/)[1];
12
+ const stylesheet = await fetch(`${origin}${css}`);
13
+ assert.equal(stylesheet.status, 200);
14
+ assert.match(await stylesheet.text(), /\.home/);
15
+ });
@@ -0,0 +1,12 @@
1
+ import { defineApp, page } from 'redweb';
2
+
3
+ @page('/', { live: false, css: 'app.css' })
4
+ export class HomePage {
5
+ render() {
6
+ return <main class="home"><h1>Redweb is ready.</h1><p>Replace this page with your application.</p></main>;
7
+ }
8
+ }
9
+
10
+ export const app = defineApp({ pages: [HomePage], port: Number(process.env.PORT ?? 8181), templateRoot: __dirname });
11
+
12
+ if (require.main === module) app.run();
@@ -11,7 +11,7 @@ class ProjectInitializer {
11
11
 
12
12
  initialize(target, options = {}) {
13
13
  const root = path.resolve(target);
14
- const templateFiles = projectFiles(this.version, options.template);
14
+ const templateFiles = projectFiles(this.version, options.template ?? null, undefined, options);
15
15
  const files = options.existing ? templateFiles.filter(file => file.path === 'tsconfig.json') : templateFiles;
16
16
  return new FilePlan(root, files).write({ dryRun: options.dryRun });
17
17
  }
@@ -1,15 +1,17 @@
1
1
  'use strict';
2
2
 
3
- const { TEMPLATES } = require('./templates');
3
+ const { CAPABILITIES, TEMPLATES } = require('./templates');
4
4
  const { KINDS } = require('./ProjectAddition');
5
5
 
6
6
  const USAGE = [
7
- `Usage: redweb init [directory] [--template ${TEMPLATES.join('|')}] [--existing] [--dry-run] [--json]`,
7
+ `Usage: redweb init [directory] [--with ${CAPABILITIES.join(',')}] [--template ${TEMPLATES.join('|')}] [--bare] [--existing] [--dry-run] [--json]`,
8
8
  ' redweb doctor [directory] [--port number] [--json]',
9
9
  ` redweb add <${KINDS.join('|')}> <name> [directory] [--config file] [--source-dir dir] [--test-dir dir] [--dry-run] [--json]`,
10
10
  ' redweb --help | --version',
11
11
  '',
12
- '--existing creates only a missing tsconfig.json; no starter or package changes.',
12
+ '--existing creates only a missing tsconfig.json; no starter or package changes.',
13
+ '--with adds neutral capability dependencies without generating example-domain code.',
14
+ '--bare omits generated tests; templates remain explicit examples.',
13
15
  '--dry-run reports planned files without writing anything.',
14
16
  'doctor inspects configuration without executing application code or repairing files.',
15
17
  ].join('\n') + '\n';
@@ -18,7 +20,7 @@ function parseArguments(args) {
18
20
  const [command = '--help', ...rest] = args;
19
21
  if (['--help', '-h', '--version'].includes(command) && !rest.length) return { command };
20
22
  if (!['init', 'doctor', 'add'].includes(command)) throw new Error('Unknown command. Run redweb --help.');
21
- const result = { command, target: '.', existing: false, dryRun: false, json: false, port: null };
23
+ const result = { command, target: '.', existing: false, dryRun: false, json: false, port: null };
22
24
  if (command === 'add') {
23
25
  result.kind = rest.shift();
24
26
  result.name = rest.shift();
@@ -37,25 +39,35 @@ function parseArguments(args) {
37
39
  if (seen.has(value)) throw new Error(`Duplicate option: ${value}`);
38
40
  seen.add(value);
39
41
  if (value === '--json') result.json = true;
40
- else if (value === '--existing' && command === 'init') result.existing = true;
42
+ else if (value === '--existing' && command === 'init') result.existing = true;
43
+ else if (value === '--bare' && command === 'init') result.bare = true;
41
44
  else if (value === '--dry-run' && ['init', 'add'].includes(command)) result.dryRun = true;
42
45
  else if (command === 'add' && ['--config', '--source-dir', '--test-dir'].includes(value)) {
43
46
  const argument = rest[++i];
44
47
  if (!argument || argument.startsWith('-')) throw new Error(`${value} requires a path.`);
45
48
  result[{ '--config': 'configFile', '--source-dir': 'sourceDir', '--test-dir': 'testDir' }[value]] = argument;
46
49
  }
47
- else if (value === '--template' && command === 'init') {
50
+ else if (value === '--template' && command === 'init') {
48
51
  const template = rest[++i];
49
52
  if (!TEMPLATES.includes(template)) throw new Error(`--template must be one of: ${TEMPLATES.join(', ')}.`);
50
- result.template = template;
51
- }
53
+ result.template = template;
54
+ }
55
+ else if (value === '--with' && command === 'init') {
56
+ const raw = rest[++i];
57
+ if (!raw || raw.startsWith('-')) throw new Error('--with requires a comma-separated capability list.');
58
+ const capabilities = raw.split(',');
59
+ if (capabilities.some(capability => !CAPABILITIES.includes(capability)) || new Set(capabilities).size !== capabilities.length) {
60
+ throw new Error(`--with must contain unique capabilities from: ${CAPABILITIES.join(', ')}.`);
61
+ }
62
+ result.with = capabilities;
63
+ }
52
64
  else if (value === '--port' && command === 'doctor') {
53
65
  const port = rest[++i];
54
66
  if (!/^\d+$/.test(port) || Number(port) > 65535) throw new Error('--port must be an integer from 0 through 65535.');
55
67
  result.port = Number(port);
56
68
  } else throw new Error(`Unknown option for ${command}: ${value}`);
57
69
  }
58
- if (result.existing && result.template) throw new Error('--existing and --template cannot be combined.');
70
+ if (result.existing && (result.template || result.with || result.bare)) throw new Error('--existing cannot be combined with --template, --with, or --bare.');
59
71
  return result;
60
72
  }
61
73
 
package/src/cli/run.js CHANGED
@@ -36,9 +36,18 @@ async function run(args, cwd, version) {
36
36
  return { exitCode: 0, stdout: `${output}\n`, stderr: '' };
37
37
  }
38
38
  const result = new ProjectInitializer(version).initialize(root, options);
39
- const report = { schemaVersion: 1, operation: 'init', dryRun: options.dryRun, ...result };
40
- const output = options.json ? JSON.stringify(report) : [
41
- `${options.dryRun ? 'Planned initialization' : 'Initialization complete'} in ${result.root}`,
39
+ const report = {
40
+ schemaVersion: 1,
41
+ operation: 'init',
42
+ dryRun: options.dryRun,
43
+ foundation: options.template ?? 'default',
44
+ capabilities: options.with ?? [],
45
+ tests: !options.bare,
46
+ ...result,
47
+ };
48
+ const output = options.json ? JSON.stringify(report) : [
49
+ `${options.dryRun ? 'Planned initialization' : 'Initialization complete'} in ${result.root}`,
50
+ `Foundation: ${options.template ? `example template "${options.template}"` : 'neutral default'}; capabilities: ${options.with?.join(', ') || 'base'}; tests: ${options.bare ? 'omitted' : 'included'}.`,
42
51
  `Created: ${result.created.join(', ')}`,
43
52
  `Kept existing: ${result.skipped.join(', ')}`,
44
53
  `Planned: ${result.planned.join(', ')}`,
@@ -3,11 +3,18 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const json = value => `${JSON.stringify(value, null, 2)}\n`;
7
- const TEMPLATES = Object.freeze(['realtime', 'chat', 'site', 'socket', 'dashboard', 'http-ws']);
8
-
9
- function projectFiles(version, template = 'realtime', root = path.resolve(__dirname, '../..')) {
10
- if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
6
+ const json = value => `${JSON.stringify(value, null, 2)}\n`;
7
+ const TEMPLATES = Object.freeze(['realtime', 'chat', 'site', 'socket', 'dashboard', 'http-ws']);
8
+ const CAPABILITIES = Object.freeze(['auth', 'multiplayer']);
9
+
10
+ function projectFiles(version, template = null, root = path.resolve(__dirname, '../..'), options = {}) {
11
+ if (template !== null && !TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
12
+ if (options.with !== undefined && !Array.isArray(options.with)) throw new TypeError('Initializer capabilities must be an array.');
13
+ const capabilities = new Set(options.with || []);
14
+ if ([...capabilities].some(capability => !CAPABILITIES.includes(capability))) throw new Error('Unknown initializer capability.');
15
+ const selected = template ?? 'foundation';
16
+ const authenticated = template === 'dashboard' || capabilities.has('auth');
17
+ const multiplayer = capabilities.has('multiplayer');
11
18
  const { devDependencies, dependencies, overrides } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
12
19
  const read = relative => fs.readFileSync(path.join(root, 'recipes', relative), 'utf8');
13
20
  const manifest = {
@@ -16,18 +23,22 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
16
23
  build: 'tsc && node scripts/copy-assets.cjs',
17
24
  start: 'node dist/app.js',
18
25
  dev: 'nodemon',
19
- test: 'npm run build && node --test test/app.test.cjs test/lifecycle.test.cjs',
20
- 'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/lifecycle.test.cjs',
26
+ ...(!options.bare ? {
27
+ test: 'npm run build && node --test test/app.test.cjs test/lifecycle.test.cjs',
28
+ 'test:coverage': 'npm run build && c8 --all --src=dist --include=dist/** --reporter=text --reporter=json node --test test/app.test.cjs test/lifecycle.test.cjs',
29
+ } : {}),
21
30
  },
22
31
  dependencies: {
23
32
  redweb: `^${version}`,
24
- ...(['chat', 'socket', 'dashboard'].includes(template) ? { zod: devDependencies.zod } : {}),
25
- ...(template === 'dashboard' ? { express: dependencies.express } : {}),
33
+ ...(['chat', 'socket', 'dashboard'].includes(template) || authenticated || multiplayer ? { zod: devDependencies.zod } : {}),
34
+ ...(authenticated ? { express: dependencies.express } : {}),
35
+ ...(multiplayer ? { 'redweb-client': dependencies['redweb-client'] } : {}),
26
36
  },
27
37
  overrides,
28
38
  devDependencies: {
29
- typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws, c8: devDependencies.c8,
30
- ...(template === 'dashboard' ? {
39
+ typescript: devDependencies.typescript, nodemon: devDependencies.nodemon, ws: dependencies.ws,
40
+ ...(!options.bare ? { c8: devDependencies.c8 } : {}),
41
+ ...(authenticated ? {
31
42
  '@types/node': devDependencies['redweb-dashboard-types'].replace('npm:@types/node@', ''),
32
43
  '@types/express': dependencies['@types/express'],
33
44
  } : {}),
@@ -40,10 +51,10 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
40
51
  delay: 200,
41
52
  },
42
53
  };
43
- if (template === 'dashboard') {
44
- manifest.engines = { node: '>=22.13.0' };
45
- manifest.scripts['add-user'] = 'npm run build && node dist/admin.js';
46
- manifest.scripts['test:coverage'] += ' test/rate-window.test.cjs';
54
+ if (authenticated) manifest.engines = { node: '>=22.13.0' };
55
+ if (template === 'dashboard') {
56
+ manifest.scripts['add-user'] = 'npm run build && node dist/admin.js';
57
+ if (!options.bare) manifest.scripts['test:coverage'] += ' test/rate-window.test.cjs';
47
58
  }
48
59
  const files = [
49
60
  { path: 'package.json', content: json(manifest) },
@@ -52,13 +63,15 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
52
63
  compilerOptions: { rootDir: 'src', outDir: 'dist', sourceMap: true },
53
64
  include: ['src/**/*.ts', 'src/**/*.tsx'],
54
65
  }) },
55
- { path: 'src/app.tsx', content: read(`${template}/app.tsx`) },
56
- { path: 'src/app.css', content: read(`${template === 'dashboard' ? template : 'shared'}/app.css`) },
57
- { path: 'scripts/copy-assets.cjs', content: read('shared/copy-assets.cjs') },
58
- { path: 'test/network.cjs', content: read('shared/network.cjs') },
59
- { path: 'test/app.test.cjs', content: read(`${template}/app.test.cjs`) },
60
- { path: 'test/lifecycle.test.cjs', content: read('shared/lifecycle.test.cjs') },
61
- { path: 'README.md', content: `${read('shared/README.md')}\n${read(`${template}/README.md`)}` },
66
+ { path: 'src/app.tsx', content: read(`${selected}/app.tsx`) },
67
+ { path: 'src/app.css', content: read(`${selected === 'dashboard' ? selected : 'shared'}/app.css`) },
68
+ { path: 'scripts/copy-assets.cjs', content: read('shared/copy-assets.cjs') },
69
+ ...(!options.bare ? [
70
+ { path: 'test/network.cjs', content: read('shared/network.cjs') },
71
+ { path: 'test/app.test.cjs', content: read(`${selected}/app.test.cjs`) },
72
+ { path: 'test/lifecycle.test.cjs', content: read('shared/lifecycle.test.cjs') },
73
+ ] : []),
74
+ { path: 'README.md', content: `${read('shared/README.md')}\n${read(`${selected}/README.md`)}` },
62
75
  { path: '.gitignore', content: 'node_modules/\ndist/\ncoverage/\n.env\ndata/\n*.sqlite\n*.sqlite-wal\n*.sqlite-shm\n' },
63
76
  ];
64
77
  if (template === 'chat') {
@@ -75,7 +88,7 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
75
88
  }
76
89
  if (template === 'dashboard') {
77
90
  files.push({ path: '.npmrc', content: 'engine-strict=true\n' });
78
- files.push({ path: 'test/rate-window.test.cjs', content: read('dashboard/rate-window.test.cjs') });
91
+ if (!options.bare) files.push({ path: 'test/rate-window.test.cjs', content: read('dashboard/rate-window.test.cjs') });
79
92
  for (const name of ['store.ts', 'auth.ts', 'cards.tsx', 'admin.ts']) {
80
93
  files.push({ path: `src/${name}`, content: read(`dashboard/${name}`) });
81
94
  }
@@ -83,4 +96,4 @@ function projectFiles(version, template = 'realtime', root = path.resolve(__dirn
83
96
  return Object.freeze(files.map(Object.freeze));
84
97
  }
85
98
 
86
- module.exports = { projectFiles, TEMPLATES };
99
+ module.exports = { projectFiles, CAPABILITIES, TEMPLATES };
@@ -77,8 +77,8 @@ class Documentation {
77
77
  : `> Documentation for Redweb ${this.channel}. Install that exact version when following these examples.`;
78
78
  }
79
79
 
80
- setup(template) {
81
- if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
80
+ setup(template) {
81
+ if (!TEMPLATES.includes(template)) throw new Error('Unknown starter template.');
82
82
  const acceptance = `${template === 'dashboard' ? 'npm run add-user -- alice\n' : ''}npm test\nnpm run dev`;
83
83
  return this.channel === 'unreleased'
84
84
  ? [
@@ -86,8 +86,18 @@ class Documentation {
86
86
  fence(`npx --yes --package TARBALL redweb init my-${template} --template ${template}\ncd my-${template}\nnpm install --save-exact TARBALL\n${acceptance}`, 'sh'),
87
87
  'This prerelease Redweb artifact is development-only until its release checks finish. For released applications, use an available versioned release guide.',
88
88
  ].join('\n\n')
89
- : fence(`npx --yes redweb@${this.channel} init my-${template} --template ${template}\ncd my-${template}\nnpm install --save-exact redweb@${this.channel}\n${acceptance}`, 'sh');
90
- }
89
+ : fence(`npx --yes redweb@${this.channel} init my-${template} --template ${template}\ncd my-${template}\nnpm install --save-exact redweb@${this.channel}\n${acceptance}`, 'sh');
90
+ }
91
+
92
+ foundationSetup() {
93
+ return this.channel === 'unreleased'
94
+ ? [
95
+ 'Replace `TARBALL` with the absolute path to the matching Redweb tarball produced by `npm pack` (quoted if it contains spaces):',
96
+ fence('npx --yes --package TARBALL redweb init my-app\ncd my-app\nnpm install --save-exact TARBALL\nnpm test\nnpm run dev', 'sh'),
97
+ 'This prerelease Redweb artifact is development-only until its release checks finish.',
98
+ ].join('\n\n')
99
+ : fence(`npx --yes redweb@${this.channel} init my-app\ncd my-app\nnpm install --save-exact redweb@${this.channel}\nnpm test\nnpm run dev`, 'sh');
100
+ }
91
101
 
92
102
  recipe(template) {
93
103
  const files = projectFiles(this.manifest.version, template, this.root).map(file => ({ ...file, content: normalize(file.content) }));
@@ -104,21 +114,28 @@ class Documentation {
104
114
  return { id: `recipes/${template}`, title: `${template[0].toUpperCase()}${template.slice(1)} starter`, summary: explanation.split('\n').find(line => line && !line.startsWith('#')), source, markdown, files };
105
115
  }
106
116
 
107
- recipeCode(entry) {
117
+ recipeCode(entry) {
108
118
  const files = projectFiles(this.manifest.version, entry.template, this.root);
109
119
  const file = files.find(file => file.path === entry.file);
110
120
  if (!file) throw new Error(`Unknown documentation recipe file: ${entry.template}/${entry.file}`);
111
- return normalize(file.content);
112
- }
121
+ return normalize(file.content);
122
+ }
123
+
124
+ code(entry, field) {
125
+ return entry.recipe ? this.recipeCode(entry.recipe) : entry.codeSource ? this.read(entry.codeSource) : entry[field];
126
+ }
113
127
 
114
128
  topic(topic) {
115
- let markdown = `${this.notice()}\n\n${this.links(this.read(topic.source), topic.source)}`;
129
+ let markdown = `${this.notice()}\n\n${this.links(this.read(topic.source), topic.source)}`;
130
+ markdown = markdown.replace(/<!-- source: ([\w./-]+) -->/g,
131
+ (_match, file) => fence(this.read(file), language(file)));
116
132
  if (topic.recipe) {
117
133
  const { template, file } = topic.recipe;
118
134
  markdown += [
119
135
  '\n## Build and run the complete application', this.setup(template),
120
- `The [complete ${template} recipe](${this.basePath}/recipes/${template}.md) contains every generated file, its real acceptance tests, and deployment instructions. The source below is one of those files, not a standalone program; initialize the whole project before modifying it.`,
121
- `## Source walkthrough: ${file}`, fence(this.recipeCode(topic.recipe), language(file)),
136
+ `The [complete ${template} recipe](${this.basePath}/recipes/${template}.md) contains every generated file, its real acceptance tests, and deployment instructions.`,
137
+ ...(topic.codeSource ? [] : [`The source below is one of those files, not a standalone program; initialize the whole project before modifying it.`,
138
+ `## Source walkthrough: ${file}`, fence(this.recipeCode(topic.recipe), language(file))]),
122
139
  ].join('\n\n') + '\n';
123
140
  }
124
141
  return { ...topic, markdown };
@@ -128,8 +145,8 @@ class Documentation {
128
145
  const pages = this.topics.map(topic => this.topic(topic));
129
146
  pages.push(...TEMPLATES.map(template => this.recipe(template)));
130
147
  const reference = this.reference;
131
- const api = reference.api.map(section => ({ ...section, usage: section.recipe ? this.recipeCode(section.recipe) : section.usage }));
132
- const examples = reference.examples.map(example => ({ ...example, code: example.recipe ? this.recipeCode(example.recipe) : example.codeSource ? this.read(example.codeSource) : example.code }));
148
+ const api = reference.api.map(section => ({ ...section, usage: this.code(section, 'usage') }));
149
+ const examples = reference.examples.map(example => ({ ...example, code: this.code(example, 'code') }));
133
150
  for (const example of examples) {
134
151
  pages.push({ id: `examples/${example.id}`, title: example.title, summary: example.summary, source: 'docs/reference.json', markdown: [
135
152
  `# ${example.title}`, this.notice(), example.summary,