create-acrylblends 0.1.3 → 0.2.1

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.
package/dist/index.js CHANGED
@@ -14,6 +14,37 @@ const PACKAGE = "acrylblends";
14
14
  const CLASS = "Acryl";
15
15
  const CREATE = "create-acrylblends";
16
16
  //#endregion
17
+ //#region src/spawn.ts
18
+ /**
19
+ * How to start a package manager without the shell option (Node warns about passing args to a shell, DEP0190, and a `.cmd` shim cannot be spawned directly).
20
+ * Off Windows the command runs as it is. On Windows npm runs as `node npm-cli.js` (the one npm is running from, else the one beside `node`); any other
21
+ * manager goes through `cmd.exe /d /s /c <name>.cmd`, whose arguments stay an array.
22
+ */
23
+ function packageManagerInvocation(command, args, platform = process.platform, env = process.env, exists = existsSync, execPath = process.execPath) {
24
+ if (platform !== "win32") return {
25
+ file: command,
26
+ args: [...args]
27
+ };
28
+ if (command === "npm") {
29
+ const running = env.npm_execpath;
30
+ const cli = running !== void 0 && /npm-cli\.c?js$/.test(running) ? running : join(dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
31
+ if (exists(cli)) return {
32
+ file: execPath,
33
+ args: [cli, ...args]
34
+ };
35
+ }
36
+ return {
37
+ file: env.ComSpec ?? "cmd.exe",
38
+ args: [
39
+ "/d",
40
+ "/s",
41
+ "/c",
42
+ `${command}.cmd`,
43
+ ...args
44
+ ]
45
+ };
46
+ }
47
+ //#endregion
17
48
  //#region src/index.ts
18
49
  const TEMPLATES = [
19
50
  "nodejs",
@@ -64,10 +95,10 @@ function processIo() {
64
95
  }
65
96
  } : void 0,
66
97
  run: (command, args, cwd) => new Promise((done) => {
67
- const child = spawn(command, args, {
98
+ const invocation = packageManagerInvocation(command, args);
99
+ const child = spawn(invocation.file, invocation.args, {
68
100
  cwd,
69
- stdio: "inherit",
70
- shell: process.platform === "win32"
101
+ stdio: "inherit"
71
102
  });
72
103
  child.on("error", () => done(1));
73
104
  child.on("exit", (code) => done(code ?? 1));
@@ -146,6 +177,7 @@ function agentsText(template, name) {
146
177
  nodejs: "npm run dev",
147
178
  bun: "bun run dev"
148
179
  }[template];
180
+ const desktopNote = template === "nodejs" ? `\nTo run it as a desktop window: \`npx ${PACKAGE} add desktop\`, \`npm install\`, then \`npm run desktop\` (see \`docs/framework/getting-started/desktop.md\`).` : "";
149
181
  const test = {
150
182
  nodejs: "npm test` and `npm run check",
151
183
  bun: "bun test` and `bun run check"
@@ -162,14 +194,15 @@ subpath (\`'${PACKAGE}/cors'\`, \`'${PACKAGE}/validator'\`, ...). Parts marked "
162
194
  ## This app
163
195
 
164
196
  Run it with \`${run}\`. The routes are in \`${template === "bun" ? "src/index.ts" : "src/app.ts"}\`; an app is a function from \`Request\` to \`Response\`,
165
- so test a route with \`await app.request('/path')\` and no server.
197
+ so test a route with \`await app.request('/path')\` and no server.${desktopNote}
166
198
 
167
199
  ## Things that surprise people
168
200
 
169
201
  - A page mounted at \`/x\` answers \`/x\`, not \`/x/\` (trailing slashes are strict); \`trimTrailingSlash()\` redirects.
170
202
  - A \`json\` validator target reads the body only with a JSON \`Content-Type\`; a failure answers 400 with \`{ error, issues: [{ message, path }] }\`.
171
203
  - \`throw new HTTPException(404, { message })\` answers an error response (plain text).
172
- - **JSX syntax does not run in this app.** ${template === "nodejs" ? "Node.js strips TypeScript types and does not compile JSX, so a `.tsx` file will not load. Write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'`, with the children after the props as further arguments (`jsx(tag, props, ...children)`); render a node with `.toString()` and return it with `c.html(...)`. See `docs/framework/guides/jsx.md`." : "Write the call JSX compiles to rather than JSX syntax, so the same file runs on Node.js too: `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'`, with the children after the props as further arguments (`jsx(tag, props, ...children)`); render a node with `.toString()` and return it with `c.html(...)`. See `docs/framework/guides/jsx.md`."}
204
+ - \`testClient(app)\` from \`'${PACKAGE}/testing'\` types only routes chained on the \`new ${CLASS}()\` expression (\`new ${CLASS}().get(...).post(...)\`); headers such as a cookie go in the second argument of each call.
205
+ - **JSX:** ${template === "nodejs" ? "Write pages in `.tsx` (see `src/page.tsx`): the scripts in `package.json` run Node.js with `--import acrylblends/node-server/register`, which compiles `.tsx` with this app's own `typescript`, and `tsconfig.json` sets `jsx: react-jsx` with `jsxImportSource: acrylblends/jsx`. Render a component with `.toString()` and return it with `c.html(...)`. A command that starts the app without those scripts (a bare `node src/index.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'`, children after the props. See `docs/framework/guides/jsx.md`." : "Bun compiles `.tsx` itself: write pages in `.tsx`, render a component with `.toString()` and return it with `c.html(...)`. `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'` (children after the props) is the same thing without JSX syntax. See `docs/framework/guides/jsx.md`."}
173
206
 
174
207
  ## Before you say you are done
175
208
 
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/names.ts","../src/index.ts"],"sourcesContent":["/**\n * The one place this package names the framework. The templates are written with `__PACKAGE__` and `__RANGE__` (the range is stamped at create time from the framework version this package carries, see `frameworkRange`), and the text of `AGENTS.md` with\n * `__PACKAGE__`, so a rename (for example to `acryl`) is a change here and in `scripts/lib/names.mjs`, which a test keeps equal.\n */\nexport const PACKAGE = 'acrylblends'\nexport const CLASS = 'Acryl'\nexport const CREATE = 'create-acrylblends'\n","import { spawn } from 'node:child_process'\nimport { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { createRequire } from 'node:module'\nimport { basename, dirname, join, resolve } from 'node:path'\nimport { createInterface } from 'node:readline/promises'\nimport { fileURLToPath } from 'node:url'\nimport { parseArgs } from 'node:util'\nimport { CLASS, CREATE, PACKAGE } from './names'\n\nexport { CLASS, CREATE, PACKAGE }\n\nexport const TEMPLATES = ['nodejs', 'bun', 'acryl-app'] as const\nexport type Template = (typeof TEMPLATES)[number]\nexport const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const\nexport type PackageManager = (typeof PACKAGE_MANAGERS)[number]\n\n/** The version of this package. */\nexport const VERSION: string = (\n createRequire(import.meta.url)('../package.json') as { version: string }\n).version\n\nexport const HELP = `${CREATE} ${VERSION} - create an ${CLASS} app\n\nUsage:\n npm create ${PACKAGE}@latest [directory] [-- options]\n\nOptions:\n --template <name> nodejs, bun (experimental), acryl-app (asked when not given)\n --install install the dependencies after creating the app\n --pm <manager> ${PACKAGE_MANAGERS.join(', ')} (default: the one that ran this command, else npm)\n -y, --yes ask nothing: template nodejs unless --template is given, directory ${PACKAGE}-app\n -h, --help show this help\n -v, --version show the version\n`\n\n/** A command line mistake. The command prints it with the help text and exits 2. */\nexport class UsageError extends Error {\n override name = 'UsageError'\n}\n\nexport interface Io {\n stdout: { write(text: string): unknown }\n stderr: { write(text: string): unknown }\n cwd: string\n env: Record<string, string | undefined>\n /** Ask one question; `undefined` when there is no terminal to ask on. */\n ask: ((question: string) => Promise<string>) | undefined\n /** Run a program, showing its output; resolves to the exit code. */\n run(command: string, args: string[], cwd: string): Promise<number>\n}\n\nexport function processIo(): Io {\n const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)\n return {\n stdout: process.stdout,\n stderr: process.stderr,\n cwd: process.cwd(),\n env: process.env,\n ask: interactive\n ? async (question) => {\n const rl = createInterface({ input: process.stdin, output: process.stdout })\n try {\n return await rl.question(question)\n } finally {\n rl.close()\n }\n }\n : undefined,\n run: (command, args, cwd) =>\n new Promise((done) => {\n const child = spawn(command, args, {\n cwd,\n stdio: 'inherit',\n shell: process.platform === 'win32',\n })\n child.on('error', () => done(1))\n child.on('exit', (code) => done(code ?? 1))\n }),\n }\n}\n\nexport interface CreateOptions {\n directory: string | undefined\n template: Template | undefined\n install: boolean\n pm: PackageManager | undefined\n yes: boolean\n}\n\nexport function isTemplate(value: string): value is Template {\n return (TEMPLATES as readonly string[]).includes(value)\n}\n\nexport function parseCreateOptions(argv: readonly string[]): CreateOptions {\n let parsed: ReturnType<typeof parse>\n try {\n parsed = parse(argv)\n } catch (error) {\n throw new UsageError((error as Error).message)\n }\n const { values, positionals } = parsed\n if (positionals.length > 1) throw new UsageError('takes at most one directory')\n if (values.template !== undefined && !isTemplate(values.template))\n throw new UsageError(`unknown template \"${values.template}\" (${TEMPLATES.join(', ')})`)\n if (values.pm !== undefined && !(PACKAGE_MANAGERS as readonly string[]).includes(values.pm))\n throw new UsageError(`unknown package manager \"${values.pm}\" (${PACKAGE_MANAGERS.join(', ')})`)\n return {\n directory: positionals[0],\n template: values.template as Template | undefined,\n install: values.install === true,\n pm: values.pm as PackageManager | undefined,\n yes: values.yes === true,\n }\n}\n\nconst parse = (argv: readonly string[]) =>\n parseArgs({\n args: [...argv],\n allowPositionals: true,\n strict: true,\n options: {\n template: { type: 'string' },\n install: { type: 'boolean' },\n pm: { type: 'string' },\n yes: { type: 'boolean', short: 'y' },\n },\n })\n\n/** A valid package name from a directory name: lower case letters, digits and dashes. */\nexport function nameFrom(directory: string): string {\n const name = basename(resolve(directory))\n .toLowerCase()\n .replace(/[^a-z0-9-]+/g, '-')\n .replace(/^[^a-z]+/, '')\n .replace(/-+$/g, '')\n return name === '' ? 'acryl-app' : name\n}\n\nfunction walkUp(from: string, test: (dir: string) => string | undefined): string {\n let dir = from\n for (;;) {\n const found = test(dir)\n if (found) return found\n const parent = dirname(dir)\n if (parent === dir) throw new Error(`${CREATE} cannot find its files`)\n dir = parent\n }\n}\nconst here = dirname(fileURLToPath(import.meta.url))\nconst templatesRoot = () =>\n walkUp(here, (d) =>\n existsSync(join(d, 'templates', 'nodejs')) ? join(d, 'templates') : undefined,\n )\n/** The framework docs: the copy `prepack` puts in the package, or `docs/framework` of the repository. */\nconst docsRoot = () =>\n walkUp(here, (d) =>\n existsSync(join(d, 'docs', 'framework', 'README.md'))\n ? join(d, 'docs', 'framework')\n : undefined,\n )\n\n/**\n * The range a created app asks for: the framework version this package was released with, so a new app depends on the release that made it (and gets what that release's\n * commands can do), never an older one. A caret range, so it also takes the newer patch releases.\n */\nexport function frameworkRange(): string {\n const file = createRequire(import.meta.url).resolve(`${PACKAGE}/package.json`)\n return `^${(JSON.parse(readFileSync(file, 'utf8')) as { version: string }).version}`\n}\n\nexport function replacePlaceholders(text: string, name: string): string {\n return text\n .replaceAll('__APP_NAME__', name)\n .replaceAll('__PACKAGE__', PACKAGE)\n .replaceAll('__CLASS__', CLASS)\n .replaceAll('__RANGE__', frameworkRange())\n}\n\n/** The guide for a coding agent in a created app: where the API is, how to run it, what must pass. */\nexport function agentsText(template: Exclude<Template, 'acryl-app'>, name: string): string {\n const run = { nodejs: 'npm run dev', bun: 'bun run dev' }[template]\n const test = {\n nodejs: 'npm test` and `npm run check',\n bun: 'bun test` and `bun run check',\n }[template]\n const jsxNote =\n template === 'nodejs'\n ? \"Node.js strips TypeScript types and does not compile JSX, so a `.tsx` file will not load. Write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'`, with the children after the props as further arguments (`jsx(tag, props, ...children)`); render a node with `.toString()` and return it with `c.html(...)`. See `docs/framework/guides/jsx.md`.\"\n : \"Write the call JSX compiles to rather than JSX syntax, so the same file runs on Node.js too: `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'`, with the children after the props as further arguments (`jsx(tag, props, ...children)`); render a node with `.toString()` and return it with `c.html(...)`. See `docs/framework/guides/jsx.md`.\"\n return `# ${name}\n\nAn ${CLASS} app (${template}${template === 'bun' ? ', experimental' : ''}): handlers over the Web Standard \\`Request\\` and \\`Response\\`, from the \\`${PACKAGE}\\` package.\n\n## The framework's API is in \\`docs/framework/\\`\n\nStart at \\`docs/framework/README.md\\`; \\`api/summary.md\\` is the one-page summary. Import the class from \\`'${PACKAGE}'\\` and each middleware or helper from its own\nsubpath (\\`'${PACKAGE}/cors'\\`, \\`'${PACKAGE}/validator'\\`, ...). Parts marked \"not yet available\" in the docs are not in the package yet.\n\n## This app\n\nRun it with \\`${run}\\`. The routes are in \\`${template === 'bun' ? 'src/index.ts' : 'src/app.ts'}\\`; an app is a function from \\`Request\\` to \\`Response\\`,\nso test a route with \\`await app.request('/path')\\` and no server.\n\n## Things that surprise people\n\n- A page mounted at \\`/x\\` answers \\`/x\\`, not \\`/x/\\` (trailing slashes are strict); \\`trimTrailingSlash()\\` redirects.\n- A \\`json\\` validator target reads the body only with a JSON \\`Content-Type\\`; a failure answers 400 with \\`{ error, issues: [{ message, path }] }\\`.\n- \\`throw new HTTPException(404, { message })\\` answers an error response (plain text).\n- **JSX syntax does not run in this app.** ${jsxNote}\n\n## Before you say you are done\n\n\\`${test}\\` pass.\n`\n}\n\nfunction copyDocs(target: string): void {\n const filter = (path: string) => !/(upstream\\.json|UPSTREAM\\.md)$/.test(path)\n mkdirSync(join(target, 'docs'), { recursive: true })\n cpSync(docsRoot(), join(target, 'docs', 'framework'), { recursive: true, filter })\n}\n\nfunction copyTemplate(\n template: Exclude<Template, 'acryl-app'>,\n target: string,\n name: string,\n): string[] {\n const written: string[] = []\n const walk = (from: string, to: string, prefix: string) => {\n mkdirSync(to, { recursive: true })\n for (const entry of readdirSync(from, { withFileTypes: true })) {\n const targetName = prefix === '' && entry.name === 'gitignore' ? '.gitignore' : entry.name\n if (entry.isDirectory())\n walk(join(from, entry.name), join(to, targetName), `${prefix}${entry.name}/`)\n else {\n writeFileSync(\n join(to, targetName),\n replacePlaceholders(readFileSync(join(from, entry.name), 'utf8'), name),\n )\n written.push(`${prefix}${targetName}`)\n }\n }\n }\n walk(join(templatesRoot(), template), target, '')\n writeFileSync(join(target, 'AGENTS.md'), agentsText(template, name))\n writeFileSync(join(target, 'CLAUDE.md'), '@AGENTS.md\\n')\n copyDocs(target)\n return written\n}\n\nconst isEmpty = (directory: string) => !existsSync(directory) || readdirSync(directory).length === 0\n\n/** The `acrylblends` command of the installed framework package, which creates the `acryl-app` template. */\nfunction acrylblendsBin(): string {\n const pkgFile = createRequire(import.meta.url).resolve(`${PACKAGE}/package.json`)\n const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')) as { bin?: string | Record<string, string> }\n const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[PACKAGE]\n if (!bin) throw new Error(`${PACKAGE} has no command to create an app with`)\n return join(dirname(pkgFile), bin)\n}\n\n/** Create the app. Returns the exit code: 0 done, 1 refused or failed, 2 a usage error. Never calls `process.exit`. */\nexport async function runCreate(argv: readonly string[], io: Io = processIo()): Promise<number> {\n try {\n if (argv.includes('-h') || argv.includes('--help')) {\n io.stdout.write(HELP)\n return 0\n }\n if (argv.includes('-v') || argv.includes('--version')) {\n io.stdout.write(`${VERSION}\\n`)\n return 0\n }\n const options = parseCreateOptions(argv)\n const ask = options.yes ? undefined : io.ask\n const directory =\n options.directory ??\n (ask\n ? (await ask(`Project directory (${PACKAGE}-app): `)).trim() || `${PACKAGE}-app`\n : `${PACKAGE}-app`)\n const target = resolve(io.cwd, directory)\n const template: Template =\n options.template ??\n (ask\n ? await (async () => {\n const answer =\n (\n await ask(\n `Which template do you want to use? (nodejs, bun (experimental), acryl-app) [nodejs]: `,\n )\n ).trim() || 'nodejs'\n if (!isTemplate(answer))\n throw new UsageError(`unknown template \"${answer}\" (${TEMPLATES.join(', ')})`)\n return answer\n })()\n : 'nodejs')\n if (!isEmpty(target)) {\n io.stderr.write(`error: ${target} is not empty; nothing was changed\\n`)\n return 1\n }\n const name = nameFrom(target)\n if (template === 'acryl-app') {\n const code = await io.run(\n process.execPath,\n [acrylblendsBin(), 'init', target, '--template', 'agent', '--name', name, '--yes'],\n io.cwd,\n )\n if (code !== 0) return 1\n } else {\n const files = copyTemplate(template, target, name)\n io.stdout.write(\n `created ${name} (${template}) in ${target}: ${files.length} files, ${CLASS} docs in docs/framework\\n`,\n )\n }\n const pm: PackageManager =\n options.pm ??\n ((PACKAGE_MANAGERS as readonly string[]).find((m) =>\n (io.env.npm_config_user_agent ?? '').startsWith(`${m}/`),\n ) as PackageManager | undefined) ??\n 'npm'\n if (options.install) {\n if ((await io.run(pm, ['install'], target)) !== 0) {\n io.stderr.write(`error: ${pm} install failed; run it yourself in ${directory}\\n`)\n return 1\n }\n }\n const start = {\n nodejs: `${pm} run dev`,\n bun: 'bun run dev',\n 'acryl-app': `${pm} run dev`,\n }[template]\n io.stdout.write(\n `\\nNext:\\n cd ${directory}\\n${options.install ? '' : ` ${pm} install\\n`} ${start}\\n`,\n )\n return 0\n } catch (error) {\n if (error instanceof UsageError) {\n io.stderr.write(`error: ${error.message}\\n\\n${HELP}`)\n return 2\n }\n io.stderr.write(`error: ${(error as Error).message}\\n`)\n return 1\n }\n}\n"],"mappings":";;;;;;;;;;;;AAIA,MAAa,UAAU;AACvB,MAAa,QAAQ;AACrB,MAAa,SAAS;;;ACKtB,MAAa,YAAY;CAAC;CAAU;CAAO;AAAW;AAEtD,MAAa,mBAAmB;CAAC;CAAO;CAAQ;CAAQ;AAAK;;AAI7D,MAAa,UACX,cAAc,YAAY,GAAG,CAAC,CAAC,iBAAiB,CAAC,CACjD;AAEF,MAAa,OAAO,GAAG,OAAO,GAAG,QAAQ,eAAe,MAAM;;;eAG/C,QAAQ;;;;;wBAKC,iBAAiB,KAAK,IAAI,EAAE;2FACuC,QAAQ;;;;;AAMnG,IAAa,aAAb,cAAgC,MAAM;CACpC,OAAgB;AAClB;AAaA,SAAgB,YAAgB;CAC9B,MAAM,cAAc,QAAQ,QAAQ,MAAM,SAAS,QAAQ,OAAO,KAAK;CACvE,OAAO;EACL,QAAQ,QAAQ;EAChB,QAAQ,QAAQ;EAChB,KAAK,QAAQ,IAAI;EACjB,KAAK,QAAQ;EACb,KAAK,cACD,OAAO,aAAa;GAClB,MAAM,KAAK,gBAAgB;IAAE,OAAO,QAAQ;IAAO,QAAQ,QAAQ;GAAO,CAAC;GAC3E,IAAI;IACF,OAAO,MAAM,GAAG,SAAS,QAAQ;GACnC,UAAU;IACR,GAAG,MAAM;GACX;EACF,IACA,KAAA;EACJ,MAAM,SAAS,MAAM,QACnB,IAAI,SAAS,SAAS;GACpB,MAAM,QAAQ,MAAM,SAAS,MAAM;IACjC;IACA,OAAO;IACP,OAAO,QAAQ,aAAa;GAC9B,CAAC;GACD,MAAM,GAAG,eAAe,KAAK,CAAC,CAAC;GAC/B,MAAM,GAAG,SAAS,SAAS,KAAK,QAAQ,CAAC,CAAC;EAC5C,CAAC;CACL;AACF;AAUA,SAAgB,WAAW,OAAkC;CAC3D,OAAQ,UAAgC,SAAS,KAAK;AACxD;AAEA,SAAgB,mBAAmB,MAAwC;CACzE,IAAI;CACJ,IAAI;EACF,SAAS,MAAM,IAAI;CACrB,SAAS,OAAO;EACd,MAAM,IAAI,WAAY,MAAgB,OAAO;CAC/C;CACA,MAAM,EAAE,QAAQ,gBAAgB;CAChC,IAAI,YAAY,SAAS,GAAG,MAAM,IAAI,WAAW,6BAA6B;CAC9E,IAAI,OAAO,aAAa,KAAA,KAAa,CAAC,WAAW,OAAO,QAAQ,GAC9D,MAAM,IAAI,WAAW,qBAAqB,OAAO,SAAS,KAAK,UAAU,KAAK,IAAI,EAAE,EAAE;CACxF,IAAI,OAAO,OAAO,KAAA,KAAa,CAAE,iBAAuC,SAAS,OAAO,EAAE,GACxF,MAAM,IAAI,WAAW,4BAA4B,OAAO,GAAG,KAAK,iBAAiB,KAAK,IAAI,EAAE,EAAE;CAChG,OAAO;EACL,WAAW,YAAY;EACvB,UAAU,OAAO;EACjB,SAAS,OAAO,YAAY;EAC5B,IAAI,OAAO;EACX,KAAK,OAAO,QAAQ;CACtB;AACF;AAEA,MAAM,SAAS,SACb,UAAU;CACR,MAAM,CAAC,GAAG,IAAI;CACd,kBAAkB;CAClB,QAAQ;CACR,SAAS;EACP,UAAU,EAAE,MAAM,SAAS;EAC3B,SAAS,EAAE,MAAM,UAAU;EAC3B,IAAI,EAAE,MAAM,SAAS;EACrB,KAAK;GAAE,MAAM;GAAW,OAAO;EAAI;CACrC;AACF,CAAC;;AAGH,SAAgB,SAAS,WAA2B;CAClD,MAAM,OAAO,SAAS,QAAQ,SAAS,CAAC,CAAC,CACtC,YAAY,CAAC,CACb,QAAQ,gBAAgB,GAAG,CAAC,CAC5B,QAAQ,YAAY,EAAE,CAAC,CACvB,QAAQ,QAAQ,EAAE;CACrB,OAAO,SAAS,KAAK,cAAc;AACrC;AAEA,SAAS,OAAO,MAAc,MAAmD;CAC/E,IAAI,MAAM;CACV,SAAS;EACP,MAAM,QAAQ,KAAK,GAAG;EACtB,IAAI,OAAO,OAAO;EAClB,MAAM,SAAS,QAAQ,GAAG;EAC1B,IAAI,WAAW,KAAK,MAAM,IAAI,MAAM,GAAG,OAAO,uBAAuB;EACrE,MAAM;CACR;AACF;AACA,MAAM,OAAO,QAAQ,cAAc,YAAY,GAAG,CAAC;AACnD,MAAM,sBACJ,OAAO,OAAO,MACZ,WAAW,KAAK,GAAG,aAAa,QAAQ,CAAC,IAAI,KAAK,GAAG,WAAW,IAAI,KAAA,CACtE;;AAEF,MAAM,iBACJ,OAAO,OAAO,MACZ,WAAW,KAAK,GAAG,QAAQ,aAAa,WAAW,CAAC,IAChD,KAAK,GAAG,QAAQ,WAAW,IAC3B,KAAA,CACN;;;;;AAMF,SAAgB,iBAAyB;CACvC,MAAM,OAAO,cAAc,YAAY,GAAG,CAAC,CAAC,QAAQ,GAAG,QAAQ,cAAc;CAC7E,OAAO,IAAK,KAAK,MAAM,aAAa,MAAM,MAAM,CAAC,CAAC,CAAyB;AAC7E;AAEA,SAAgB,oBAAoB,MAAc,MAAsB;CACtE,OAAO,KACJ,WAAW,gBAAgB,IAAI,CAAC,CAChC,WAAW,eAAe,OAAO,CAAC,CAClC,WAAW,aAAa,KAAK,CAAC,CAC9B,WAAW,aAAa,eAAe,CAAC;AAC7C;;AAGA,SAAgB,WAAW,UAA0C,MAAsB;CACzF,MAAM,MAAM;EAAE,QAAQ;EAAe,KAAK;CAAc,EAAE;CAC1D,MAAM,OAAO;EACX,QAAQ;EACR,KAAK;CACP,EAAE;CAKF,OAAO,KAAK,KAAK;;KAEd,MAAM,QAAQ,WAAW,aAAa,QAAQ,mBAAmB,GAAG,6EAA6E,QAAQ;;;;8GAIhD,QAAQ;cACxG,QAAQ,eAAe,QAAQ;;;;gBAI7B,IAAI,0BAA0B,aAAa,QAAQ,iBAAiB,aAAa;;;;;;;;6CAd7F,aAAa,WACT,yYACA,kWAoB6C;;;;IAIjD,KAAK;;AAET;AAEA,SAAS,SAAS,QAAsB;CACtC,MAAM,UAAU,SAAiB,CAAC,iCAAiC,KAAK,IAAI;CAC5E,UAAU,KAAK,QAAQ,MAAM,GAAG,EAAE,WAAW,KAAK,CAAC;CACnD,OAAO,SAAS,GAAG,KAAK,QAAQ,QAAQ,WAAW,GAAG;EAAE,WAAW;EAAM;CAAO,CAAC;AACnF;AAEA,SAAS,aACP,UACA,QACA,MACU;CACV,MAAM,UAAoB,CAAC;CAC3B,MAAM,QAAQ,MAAc,IAAY,WAAmB;EACzD,UAAU,IAAI,EAAE,WAAW,KAAK,CAAC;EACjC,KAAK,MAAM,SAAS,YAAY,MAAM,EAAE,eAAe,KAAK,CAAC,GAAG;GAC9D,MAAM,aAAa,WAAW,MAAM,MAAM,SAAS,cAAc,eAAe,MAAM;GACtF,IAAI,MAAM,YAAY,GACpB,KAAK,KAAK,MAAM,MAAM,IAAI,GAAG,KAAK,IAAI,UAAU,GAAG,GAAG,SAAS,MAAM,KAAK,EAAE;QACzE;IACH,cACE,KAAK,IAAI,UAAU,GACnB,oBAAoB,aAAa,KAAK,MAAM,MAAM,IAAI,GAAG,MAAM,GAAG,IAAI,CACxE;IACA,QAAQ,KAAK,GAAG,SAAS,YAAY;GACvC;EACF;CACF;CACA,KAAK,KAAK,cAAc,GAAG,QAAQ,GAAG,QAAQ,EAAE;CAChD,cAAc,KAAK,QAAQ,WAAW,GAAG,WAAW,UAAU,IAAI,CAAC;CACnE,cAAc,KAAK,QAAQ,WAAW,GAAG,cAAc;CACvD,SAAS,MAAM;CACf,OAAO;AACT;AAEA,MAAM,WAAW,cAAsB,CAAC,WAAW,SAAS,KAAK,YAAY,SAAS,CAAC,CAAC,WAAW;;AAGnG,SAAS,iBAAyB;CAChC,MAAM,UAAU,cAAc,YAAY,GAAG,CAAC,CAAC,QAAQ,GAAG,QAAQ,cAAc;CAChF,MAAM,MAAM,KAAK,MAAM,aAAa,SAAS,MAAM,CAAC;CACpD,MAAM,MAAM,OAAO,IAAI,QAAQ,WAAW,IAAI,MAAM,IAAI,MAAM;CAC9D,IAAI,CAAC,KAAK,MAAM,IAAI,MAAM,GAAG,QAAQ,sCAAsC;CAC3E,OAAO,KAAK,QAAQ,OAAO,GAAG,GAAG;AACnC;;AAGA,eAAsB,UAAU,MAAyB,KAAS,UAAU,GAAoB;CAC9F,IAAI;EACF,IAAI,KAAK,SAAS,IAAI,KAAK,KAAK,SAAS,QAAQ,GAAG;GAClD,GAAG,OAAO,MAAM,IAAI;GACpB,OAAO;EACT;EACA,IAAI,KAAK,SAAS,IAAI,KAAK,KAAK,SAAS,WAAW,GAAG;GACrD,GAAG,OAAO,MAAM,GAAG,QAAQ,GAAG;GAC9B,OAAO;EACT;EACA,MAAM,UAAU,mBAAmB,IAAI;EACvC,MAAM,MAAM,QAAQ,MAAM,KAAA,IAAY,GAAG;EACzC,MAAM,YACJ,QAAQ,cACP,OACI,MAAM,IAAI,uCAAsC,EAAA,CAAG,KAAK,KAAK,oBAC9D;EACN,MAAM,SAAS,QAAQ,GAAG,KAAK,SAAS;EACxC,MAAM,WACJ,QAAQ,aACP,MACG,OAAO,YAAY;GACjB,MAAM,UAEF,MAAM,IACJ,uFACF,EAAA,CACA,KAAK,KAAK;GACd,IAAI,CAAC,WAAW,MAAM,GACpB,MAAM,IAAI,WAAW,qBAAqB,OAAO,KAAK,UAAU,KAAK,IAAI,EAAE,EAAE;GAC/E,OAAO;EACT,EAAA,CAAG,IACH;EACN,IAAI,CAAC,QAAQ,MAAM,GAAG;GACpB,GAAG,OAAO,MAAM,UAAU,OAAO,qCAAqC;GACtE,OAAO;EACT;EACA,MAAM,OAAO,SAAS,MAAM;EAC5B,IAAI,aAAa,aAMX;OAAA,MALe,GAAG,IACpB,QAAQ,UACR;IAAC,eAAe;IAAG;IAAQ;IAAQ;IAAc;IAAS;IAAU;IAAM;GAAO,GACjF,GAAG,GACL,MACa,GAAG,OAAO;EAAA,OAClB;GACL,MAAM,QAAQ,aAAa,UAAU,QAAQ,IAAI;GACjD,GAAG,OAAO,MACR,WAAW,KAAK,IAAI,SAAS,OAAO,OAAO,IAAI,MAAM,OAAO,UAAU,MAAM,0BAC9E;EACF;EACA,MAAM,KACJ,QAAQ,MACN,iBAAuC,MAAM,OAC5C,GAAG,IAAI,yBAAyB,GAAA,CAAI,WAAW,GAAG,EAAE,EAAE,CACzD,KACA;EACF,IAAI,QAAQ,SACL;OAAA,MAAM,GAAG,IAAI,IAAI,CAAC,SAAS,GAAG,MAAM,MAAO,GAAG;IACjD,GAAG,OAAO,MAAM,UAAU,GAAG,sCAAsC,UAAU,GAAG;IAChF,OAAO;GACT;;EAEF,MAAM,QAAQ;GACZ,QAAQ,GAAG,GAAG;GACd,KAAK;GACL,aAAa,GAAG,GAAG;EACrB,EAAE;EACF,GAAG,OAAO,MACR,iBAAiB,UAAU,IAAI,QAAQ,UAAU,KAAK,KAAK,GAAG,YAAY,IAAI,MAAM,GACtF;EACA,OAAO;CACT,SAAS,OAAO;EACd,IAAI,iBAAiB,YAAY;GAC/B,GAAG,OAAO,MAAM,UAAU,MAAM,QAAQ,MAAM,MAAM;GACpD,OAAO;EACT;EACA,GAAG,OAAO,MAAM,UAAW,MAAgB,QAAQ,GAAG;EACtD,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/names.ts","../src/spawn.ts","../src/index.ts"],"sourcesContent":["/**\n * The one place this package names the framework. The templates are written with `__PACKAGE__` and `__RANGE__` (the range is stamped at create time from the framework version this package carries, see `frameworkRange`), and the text of `AGENTS.md` with\n * `__PACKAGE__`, so a rename (for example to `acryl`) is a change here and in `scripts/lib/names.mjs`, which a test keeps equal.\n */\nexport const PACKAGE = 'acrylblends'\nexport const CLASS = 'Acryl'\nexport const CREATE = 'create-acrylblends'\n","import { existsSync } from 'node:fs'\nimport { dirname, join } from 'node:path'\n\nexport interface Invocation {\n file: string\n args: string[]\n}\n\n/**\n * How to start a package manager without the shell option (Node warns about passing args to a shell, DEP0190, and a `.cmd` shim cannot be spawned directly).\n * Off Windows the command runs as it is. On Windows npm runs as `node npm-cli.js` (the one npm is running from, else the one beside `node`); any other\n * manager goes through `cmd.exe /d /s /c <name>.cmd`, whose arguments stay an array.\n */\nexport function packageManagerInvocation(\n command: string,\n args: readonly string[],\n platform: NodeJS.Platform = process.platform,\n env: NodeJS.ProcessEnv = process.env,\n exists: (path: string) => boolean = existsSync,\n execPath: string = process.execPath,\n): Invocation {\n if (platform !== 'win32') return { file: command, args: [...args] }\n if (command === 'npm') {\n const running = env.npm_execpath\n const cli =\n running !== undefined && /npm-cli\\.c?js$/.test(running)\n ? running\n : join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')\n if (exists(cli)) return { file: execPath, args: [cli, ...args] }\n }\n return { file: env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', `${command}.cmd`, ...args] }\n}\n","import { spawn } from 'node:child_process'\nimport { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'\nimport { createRequire } from 'node:module'\nimport { basename, dirname, join, resolve } from 'node:path'\nimport { createInterface } from 'node:readline/promises'\nimport { fileURLToPath } from 'node:url'\nimport { parseArgs } from 'node:util'\nimport { CLASS, CREATE, PACKAGE } from './names'\nimport { packageManagerInvocation } from './spawn'\n\nexport { CLASS, CREATE, PACKAGE }\n\nexport const TEMPLATES = ['nodejs', 'bun', 'acryl-app'] as const\nexport type Template = (typeof TEMPLATES)[number]\nexport const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const\nexport type PackageManager = (typeof PACKAGE_MANAGERS)[number]\n\n/** The version of this package. */\nexport const VERSION: string = (\n createRequire(import.meta.url)('../package.json') as { version: string }\n).version\n\nexport const HELP = `${CREATE} ${VERSION} - create an ${CLASS} app\n\nUsage:\n npm create ${PACKAGE}@latest [directory] [-- options]\n\nOptions:\n --template <name> nodejs, bun (experimental), acryl-app (asked when not given)\n --install install the dependencies after creating the app\n --pm <manager> ${PACKAGE_MANAGERS.join(', ')} (default: the one that ran this command, else npm)\n -y, --yes ask nothing: template nodejs unless --template is given, directory ${PACKAGE}-app\n -h, --help show this help\n -v, --version show the version\n`\n\n/** A command line mistake. The command prints it with the help text and exits 2. */\nexport class UsageError extends Error {\n override name = 'UsageError'\n}\n\nexport interface Io {\n stdout: { write(text: string): unknown }\n stderr: { write(text: string): unknown }\n cwd: string\n env: Record<string, string | undefined>\n /** Ask one question; `undefined` when there is no terminal to ask on. */\n ask: ((question: string) => Promise<string>) | undefined\n /** Run a program, showing its output; resolves to the exit code. */\n run(command: string, args: string[], cwd: string): Promise<number>\n}\n\nexport function processIo(): Io {\n const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)\n return {\n stdout: process.stdout,\n stderr: process.stderr,\n cwd: process.cwd(),\n env: process.env,\n ask: interactive\n ? async (question) => {\n const rl = createInterface({ input: process.stdin, output: process.stdout })\n try {\n return await rl.question(question)\n } finally {\n rl.close()\n }\n }\n : undefined,\n run: (command, args, cwd) =>\n new Promise((done) => {\n const invocation = packageManagerInvocation(command, args)\n const child = spawn(invocation.file, invocation.args, { cwd, stdio: 'inherit' })\n child.on('error', () => done(1))\n child.on('exit', (code) => done(code ?? 1))\n }),\n }\n}\n\nexport interface CreateOptions {\n directory: string | undefined\n template: Template | undefined\n install: boolean\n pm: PackageManager | undefined\n yes: boolean\n}\n\nexport function isTemplate(value: string): value is Template {\n return (TEMPLATES as readonly string[]).includes(value)\n}\n\nexport function parseCreateOptions(argv: readonly string[]): CreateOptions {\n let parsed: ReturnType<typeof parse>\n try {\n parsed = parse(argv)\n } catch (error) {\n throw new UsageError((error as Error).message)\n }\n const { values, positionals } = parsed\n if (positionals.length > 1) throw new UsageError('takes at most one directory')\n if (values.template !== undefined && !isTemplate(values.template))\n throw new UsageError(`unknown template \"${values.template}\" (${TEMPLATES.join(', ')})`)\n if (values.pm !== undefined && !(PACKAGE_MANAGERS as readonly string[]).includes(values.pm))\n throw new UsageError(`unknown package manager \"${values.pm}\" (${PACKAGE_MANAGERS.join(', ')})`)\n return {\n directory: positionals[0],\n template: values.template as Template | undefined,\n install: values.install === true,\n pm: values.pm as PackageManager | undefined,\n yes: values.yes === true,\n }\n}\n\nconst parse = (argv: readonly string[]) =>\n parseArgs({\n args: [...argv],\n allowPositionals: true,\n strict: true,\n options: {\n template: { type: 'string' },\n install: { type: 'boolean' },\n pm: { type: 'string' },\n yes: { type: 'boolean', short: 'y' },\n },\n })\n\n/** A valid package name from a directory name: lower case letters, digits and dashes. */\nexport function nameFrom(directory: string): string {\n const name = basename(resolve(directory))\n .toLowerCase()\n .replace(/[^a-z0-9-]+/g, '-')\n .replace(/^[^a-z]+/, '')\n .replace(/-+$/g, '')\n return name === '' ? 'acryl-app' : name\n}\n\nfunction walkUp(from: string, test: (dir: string) => string | undefined): string {\n let dir = from\n for (;;) {\n const found = test(dir)\n if (found) return found\n const parent = dirname(dir)\n if (parent === dir) throw new Error(`${CREATE} cannot find its files`)\n dir = parent\n }\n}\nconst here = dirname(fileURLToPath(import.meta.url))\nconst templatesRoot = () =>\n walkUp(here, (d) =>\n existsSync(join(d, 'templates', 'nodejs')) ? join(d, 'templates') : undefined,\n )\n/** The framework docs: the copy `prepack` puts in the package, or `docs/framework` of the repository. */\nconst docsRoot = () =>\n walkUp(here, (d) =>\n existsSync(join(d, 'docs', 'framework', 'README.md'))\n ? join(d, 'docs', 'framework')\n : undefined,\n )\n\n/**\n * The range a created app asks for: the framework version this package was released with, so a new app depends on the release that made it (and gets what that release's\n * commands can do), never an older one. A caret range, so it also takes the newer patch releases.\n */\nexport function frameworkRange(): string {\n const file = createRequire(import.meta.url).resolve(`${PACKAGE}/package.json`)\n return `^${(JSON.parse(readFileSync(file, 'utf8')) as { version: string }).version}`\n}\n\nexport function replacePlaceholders(text: string, name: string): string {\n return text\n .replaceAll('__APP_NAME__', name)\n .replaceAll('__PACKAGE__', PACKAGE)\n .replaceAll('__CLASS__', CLASS)\n .replaceAll('__RANGE__', frameworkRange())\n}\n\n/** The guide for a coding agent in a created app: where the API is, how to run it, what must pass. */\nexport function agentsText(template: Exclude<Template, 'acryl-app'>, name: string): string {\n const run = { nodejs: 'npm run dev', bun: 'bun run dev' }[template]\n const desktopNote =\n template === 'nodejs'\n ? `\\nTo run it as a desktop window: \\`npx ${PACKAGE} add desktop\\`, \\`npm install\\`, then \\`npm run desktop\\` (see \\`docs/framework/getting-started/desktop.md\\`).`\n : ''\n const test = {\n nodejs: 'npm test` and `npm run check',\n bun: 'bun test` and `bun run check',\n }[template]\n const jsxNote =\n template === 'nodejs'\n ? \"Write pages in `.tsx` (see `src/page.tsx`): the scripts in `package.json` run Node.js with `--import acrylblends/node-server/register`, which compiles `.tsx` with this app's own `typescript`, and `tsconfig.json` sets `jsx: react-jsx` with `jsxImportSource: acrylblends/jsx`. Render a component with `.toString()` and return it with `c.html(...)`. A command that starts the app without those scripts (a bare `node src/index.ts`) cannot load a `.tsx` file. Without a build step you can write the call JSX compiles to instead: `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'`, children after the props. See `docs/framework/guides/jsx.md`.\"\n : \"Bun compiles `.tsx` itself: write pages in `.tsx`, render a component with `.toString()` and return it with `c.html(...)`. `jsx('h1', { class: 'title' }, 'Hello')` from `'acrylblends/jsx'` (children after the props) is the same thing without JSX syntax. See `docs/framework/guides/jsx.md`.\"\n return `# ${name}\n\nAn ${CLASS} app (${template}${template === 'bun' ? ', experimental' : ''}): handlers over the Web Standard \\`Request\\` and \\`Response\\`, from the \\`${PACKAGE}\\` package.\n\n## The framework's API is in \\`docs/framework/\\`\n\nStart at \\`docs/framework/README.md\\`; \\`api/summary.md\\` is the one-page summary. Import the class from \\`'${PACKAGE}'\\` and each middleware or helper from its own\nsubpath (\\`'${PACKAGE}/cors'\\`, \\`'${PACKAGE}/validator'\\`, ...). Parts marked \"not yet available\" in the docs are not in the package yet.\n\n## This app\n\nRun it with \\`${run}\\`. The routes are in \\`${template === 'bun' ? 'src/index.ts' : 'src/app.ts'}\\`; an app is a function from \\`Request\\` to \\`Response\\`,\nso test a route with \\`await app.request('/path')\\` and no server.${desktopNote}\n\n## Things that surprise people\n\n- A page mounted at \\`/x\\` answers \\`/x\\`, not \\`/x/\\` (trailing slashes are strict); \\`trimTrailingSlash()\\` redirects.\n- A \\`json\\` validator target reads the body only with a JSON \\`Content-Type\\`; a failure answers 400 with \\`{ error, issues: [{ message, path }] }\\`.\n- \\`throw new HTTPException(404, { message })\\` answers an error response (plain text).\n- \\`testClient(app)\\` from \\`'${PACKAGE}/testing'\\` types only routes chained on the \\`new ${CLASS}()\\` expression (\\`new ${CLASS}().get(...).post(...)\\`); headers such as a cookie go in the second argument of each call.\n- **JSX:** ${jsxNote}\n\n## Before you say you are done\n\n\\`${test}\\` pass.\n`\n}\n\nfunction copyDocs(target: string): void {\n const filter = (path: string) => !/(upstream\\.json|UPSTREAM\\.md)$/.test(path)\n mkdirSync(join(target, 'docs'), { recursive: true })\n cpSync(docsRoot(), join(target, 'docs', 'framework'), { recursive: true, filter })\n}\n\nfunction copyTemplate(\n template: Exclude<Template, 'acryl-app'>,\n target: string,\n name: string,\n): string[] {\n const written: string[] = []\n const walk = (from: string, to: string, prefix: string) => {\n mkdirSync(to, { recursive: true })\n for (const entry of readdirSync(from, { withFileTypes: true })) {\n const targetName = prefix === '' && entry.name === 'gitignore' ? '.gitignore' : entry.name\n if (entry.isDirectory())\n walk(join(from, entry.name), join(to, targetName), `${prefix}${entry.name}/`)\n else {\n writeFileSync(\n join(to, targetName),\n replacePlaceholders(readFileSync(join(from, entry.name), 'utf8'), name),\n )\n written.push(`${prefix}${targetName}`)\n }\n }\n }\n walk(join(templatesRoot(), template), target, '')\n writeFileSync(join(target, 'AGENTS.md'), agentsText(template, name))\n writeFileSync(join(target, 'CLAUDE.md'), '@AGENTS.md\\n')\n copyDocs(target)\n return written\n}\n\nconst isEmpty = (directory: string) => !existsSync(directory) || readdirSync(directory).length === 0\n\n/** The `acrylblends` command of the installed framework package, which creates the `acryl-app` template. */\nfunction acrylblendsBin(): string {\n const pkgFile = createRequire(import.meta.url).resolve(`${PACKAGE}/package.json`)\n const pkg = JSON.parse(readFileSync(pkgFile, 'utf8')) as { bin?: string | Record<string, string> }\n const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[PACKAGE]\n if (!bin) throw new Error(`${PACKAGE} has no command to create an app with`)\n return join(dirname(pkgFile), bin)\n}\n\n/** Create the app. Returns the exit code: 0 done, 1 refused or failed, 2 a usage error. Never calls `process.exit`. */\nexport async function runCreate(argv: readonly string[], io: Io = processIo()): Promise<number> {\n try {\n if (argv.includes('-h') || argv.includes('--help')) {\n io.stdout.write(HELP)\n return 0\n }\n if (argv.includes('-v') || argv.includes('--version')) {\n io.stdout.write(`${VERSION}\\n`)\n return 0\n }\n const options = parseCreateOptions(argv)\n const ask = options.yes ? undefined : io.ask\n const directory =\n options.directory ??\n (ask\n ? (await ask(`Project directory (${PACKAGE}-app): `)).trim() || `${PACKAGE}-app`\n : `${PACKAGE}-app`)\n const target = resolve(io.cwd, directory)\n const template: Template =\n options.template ??\n (ask\n ? await (async () => {\n const answer =\n (\n await ask(\n `Which template do you want to use? (nodejs, bun (experimental), acryl-app) [nodejs]: `,\n )\n ).trim() || 'nodejs'\n if (!isTemplate(answer))\n throw new UsageError(`unknown template \"${answer}\" (${TEMPLATES.join(', ')})`)\n return answer\n })()\n : 'nodejs')\n if (!isEmpty(target)) {\n io.stderr.write(`error: ${target} is not empty; nothing was changed\\n`)\n return 1\n }\n const name = nameFrom(target)\n if (template === 'acryl-app') {\n const code = await io.run(\n process.execPath,\n [acrylblendsBin(), 'init', target, '--template', 'agent', '--name', name, '--yes'],\n io.cwd,\n )\n if (code !== 0) return 1\n } else {\n const files = copyTemplate(template, target, name)\n io.stdout.write(\n `created ${name} (${template}) in ${target}: ${files.length} files, ${CLASS} docs in docs/framework\\n`,\n )\n }\n const pm: PackageManager =\n options.pm ??\n ((PACKAGE_MANAGERS as readonly string[]).find((m) =>\n (io.env.npm_config_user_agent ?? '').startsWith(`${m}/`),\n ) as PackageManager | undefined) ??\n 'npm'\n if (options.install) {\n if ((await io.run(pm, ['install'], target)) !== 0) {\n io.stderr.write(`error: ${pm} install failed; run it yourself in ${directory}\\n`)\n return 1\n }\n }\n const start = {\n nodejs: `${pm} run dev`,\n bun: 'bun run dev',\n 'acryl-app': `${pm} run dev`,\n }[template]\n io.stdout.write(\n `\\nNext:\\n cd ${directory}\\n${options.install ? '' : ` ${pm} install\\n`} ${start}\\n`,\n )\n return 0\n } catch (error) {\n if (error instanceof UsageError) {\n io.stderr.write(`error: ${error.message}\\n\\n${HELP}`)\n return 2\n }\n io.stderr.write(`error: ${(error as Error).message}\\n`)\n return 1\n }\n}\n"],"mappings":"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"}
@@ -50,7 +50,7 @@ They are enabled as they are ported.
50
50
  | Run the app as a desktop app | [Desktop](getting-started/desktop.md) |
51
51
  | Look up a call | [API](api/index.md), [Routing](api/routing.md), [Context](api/context.md), [Request](api/request.md), [API summary](api/summary.md) |
52
52
  | Understand the design | [Concepts](concepts/motivation.md) |
53
- | Copy a pattern | [Guides](guides/best-practices.md), [Validation](guides/validation.md), [Testing](guides/testing.md) |
53
+ | Copy a pattern | [Guides](guides/best-practices.md), [Validation](guides/validation.md), [Testing](guides/testing.md), [Forms, sessions and a database](guides/forms-and-sessions.md) |
54
54
  | Use Acryl inside a Cordis app | [Acryl in a Cordis app](cordis/acryl-in-a-cordis-app.md), [Plugin lifecycle](cordis/plugin-lifecycle.md), [Mounting](cordis/mounting.md) |
55
55
  | Move from the 0.3 router calls | [Migration from 0.3](cordis/migration-from-0-3.md) |
56
56
  | Move from the 0.4 names (`App`) | [Migration from 0.4](cordis/migration-from-0-4.md) |
@@ -1,3 +1,3 @@
1
1
  # Generated copy
2
2
 
3
- This folder is a copy of `docs/framework` of acryldev/acryl at commit `fd1f7dcaaff8b757c0562294002c0d32d6e0a893`, made by `scripts/sync-framework.mjs`. Do not edit it here: change the text in ACRYL and sync again. `pnpm run verify:framework` fails when a page differs from the recorded digest.
3
+ This folder is a copy of `docs/framework` of acryldev/acryl at commit `524406d2033cdc5ca5e01207f9ce29543bbf221b`, made by `scripts/sync-framework.mjs`. Do not edit it here: change the text in ACRYL and sync again. `pnpm run verify:framework` fails when a page differs from the recorded digest.
@@ -1,6 +1,6 @@
1
1
  # API summary
2
2
 
3
- One page with the calls an ordinary app uses, each linked to the page that explains it. Imports are from `acryl-router` unless noted.
3
+ One page with the calls an ordinary app uses, each linked to the page that explains it. Imports are from `acrylblends` (and its subpaths such as `acrylblends/cookie`) unless noted. The first example is a Cordis plugin; a plain app (`npm create acrylblends@latest`) uses the `Acryl` class below with no plugin around it.
4
4
 
5
5
  ## A plugin that serves an app
6
6
 
@@ -21,7 +21,7 @@ In a Blend app, the same two commands (`npx acrylblends add desktop`, `npm insta
21
21
  ## What it adds
22
22
 
23
23
  - `src/plugins/desktop-web/` (a plain app) or `src/plugins/desktop/` (a Blend app): the window code. Only this folder imports `electron`; the app's own code never does.
24
- - `electron` as a development dependency (pinned to 44.0.0) and the script `npm run desktop`.
24
+ - `electron` as a development dependency (pinned to 44.9.3) and the script `npm run desktop`.
25
25
  - `.acrylblends/` in `.gitignore`: Electron's user data (window state, cache, logs) lives in `.acrylblends/electron` inside the app's own folder, never in the user's application-data folder.
26
26
 
27
27
  ## What `npm run desktop` does
@@ -0,0 +1,231 @@
1
+ # Forms, sessions and a database
2
+
3
+ This page shows the parts a small multi-user app needs, in the order an app uses them: a database, passwords, a cookie session, HTML forms with server-side validation, and a JSON API that shares the same rules.
4
+ It is the shape of a "Notes" app built from these docs alone (sign up, log in, create, edit, delete and search notes, a typed test client for the API).
5
+
6
+ ## A database with `node:sqlite`
7
+
8
+ Node.js 22.19 and later include SQLite, so there is nothing to install. `DatabaseSync` is synchronous, which suits a small app.
9
+
10
+ ```ts
11
+ import { DatabaseSync } from 'node:sqlite'
12
+
13
+ export function openDb(path = process.env.NOTES_DB ?? 'notes.db'): DatabaseSync {
14
+ const db = new DatabaseSync(path)
15
+ db.exec(`
16
+ CREATE TABLE IF NOT EXISTS users (
17
+ id INTEGER PRIMARY KEY,
18
+ email TEXT NOT NULL UNIQUE,
19
+ password_hash TEXT NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS sessions (
22
+ token TEXT PRIMARY KEY,
23
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
24
+ );
25
+ `)
26
+ return db
27
+ }
28
+ ```
29
+
30
+ Take the database as an argument (`createApp(db)`) instead of opening it at import time. A test then passes `openDb(':memory:')`, and importing the app module does not create a file.
31
+
32
+ ## Passwords with `node:crypto`
33
+
34
+ Never store a password. Store a salted `scrypt` hash and compare in constant time.
35
+
36
+ ```ts
37
+ import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
38
+ import { promisify } from 'node:util'
39
+
40
+ const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, length: number) => Promise<Buffer>
41
+
42
+ export async function hashPassword(password: string): Promise<string> {
43
+ const salt = randomBytes(16)
44
+ const key = await scryptAsync(password, salt, 64)
45
+ return `${salt.toString('hex')}:${key.toString('hex')}`
46
+ }
47
+
48
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
49
+ const [salt, key] = stored.split(':')
50
+ if (!salt || !key) return false
51
+ const expected = Buffer.from(key, 'hex')
52
+ return timingSafeEqual(await scryptAsync(password, Buffer.from(salt, 'hex'), expected.length), expected)
53
+ }
54
+ ```
55
+
56
+ ## A cookie session
57
+
58
+ The simplest session is a random token in an `httpOnly` cookie and a `sessions` row that maps it to a user. Log out deletes the row, so a stolen cookie stops working.
59
+ `getCookie`, `setCookie` and `deleteCookie` are in [the Cookie helper](../helpers/cookie.md).
60
+
61
+ ```ts
62
+ import { Acryl } from 'acrylblends'
63
+ import { createMiddleware } from 'acrylblends/factory'
64
+ import { deleteCookie, getCookie, setCookie } from 'acrylblends/cookie'
65
+ import { randomBytes } from 'node:crypto'
66
+ import type { DatabaseSync } from 'node:sqlite'
67
+
68
+ type Env = { Variables: { user: { id: number; email: string } } }
69
+
70
+ export function createApp(db: DatabaseSync) {
71
+ const userFor = (token: string | undefined) =>
72
+ token
73
+ ? (db
74
+ .prepare('SELECT users.id AS id, users.email AS email FROM sessions JOIN users ON users.id = sessions.user_id WHERE token = ?')
75
+ .get(token) as { id: number; email: string } | undefined)
76
+ : undefined
77
+
78
+ const requireUser = createMiddleware<Env>(async (c, next) => {
79
+ const user = userFor(getCookie(c, 'sid'))
80
+ if (!user) return c.redirect('/login')
81
+ c.set('user', user)
82
+ await next()
83
+ })
84
+
85
+ const app = new Acryl<Env>()
86
+ app.use('/notes', requireUser)
87
+ app.use('/notes/*', requireUser)
88
+ app.get('/notes', (c) => c.text(`Hello ${c.get('user').email}`))
89
+
90
+ app.post('/login-example', (c) => {
91
+ const token = randomBytes(32).toString('hex')
92
+ db.prepare('INSERT INTO sessions (token, user_id) VALUES (?, ?)').run(token, 1)
93
+ setCookie(c, 'sid', token, { path: '/', httpOnly: true, sameSite: 'Lax', maxAge: 60 * 60 * 24 * 30 })
94
+ return c.redirect('/notes', 303)
95
+ })
96
+
97
+ app.post('/logout', (c) => {
98
+ const token = getCookie(c, 'sid')
99
+ if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
100
+ deleteCookie(c, 'sid', { path: '/' })
101
+ return c.redirect('/login', 303)
102
+ })
103
+ return app
104
+ }
105
+ ```
106
+
107
+ A middleware registered with `use('/notes', ...)` does not cover `/notes/…`: register both `'/notes'` and `'/notes/*'`.
108
+ After a form POST redirect with status **303** so the browser follows with a GET.
109
+
110
+ ## Forms and server-side validation
111
+
112
+ `await c.req.parseBody()` reads an HTML form (`application/x-www-form-urlencoded` or multipart). Each value is a `string` or a `File`, so convert with `String(...)` or check `typeof`.
113
+ Keep the rules in one function that both the form routes and the JSON API call, and answer an invalid form with **status 400 and the same page**, with the messages and the typed values filled in:
114
+
115
+ ```ts
116
+ export interface NoteInput {
117
+ title: string
118
+ body: string
119
+ }
120
+
121
+ export function checkNote(value: unknown): { value: NoteInput } | { issues: { message: string; path: string[] }[] } {
122
+ const input = (value ?? {}) as Record<string, unknown>
123
+ const title = typeof input.title === 'string' ? input.title.trim() : ''
124
+ const body = typeof input.body === 'string' ? input.body : ''
125
+ const issues: { message: string; path: string[] }[] = []
126
+ if (title === '') issues.push({ message: 'Give the note a title.', path: ['title'] })
127
+ if (body.length > 2000) issues.push({ message: 'The note is at most 2000 characters.', path: ['body'] })
128
+ return issues.length > 0 ? { issues } : { value: { title, body } }
129
+ }
130
+ ```
131
+
132
+ ```tsx
133
+ import { Acryl } from 'acrylblends'
134
+
135
+ const app = new Acryl()
136
+
137
+ app.post('/notes', async (c) => {
138
+ const form = await c.req.parseBody()
139
+ const checked = checkNote(form)
140
+ if ('issues' in checked) {
141
+ const messages = checked.issues.map((issue) => issue.message)
142
+ return c.html(
143
+ <form method="post" action="/notes">
144
+ <ul role="alert">
145
+ {messages.map((message) => (
146
+ <li>{message}</li>
147
+ ))}
148
+ </ul>
149
+ <input name="title" value={String(form.title ?? '')} />
150
+ <textarea name="body">{String(form.body ?? '')}</textarea>
151
+ <button type="submit">Save</button>
152
+ </form>,
153
+ 400,
154
+ )
155
+ }
156
+ return c.redirect('/notes', 303)
157
+ })
158
+ ```
159
+
160
+ Escaping is automatic in JSX: `{String(form.title)}` is safe to put in a page. Use `dangerouslySetInnerHTML` only for text you wrote yourself, such as a `<style>` or `<script>` block.
161
+
162
+ ## A JSON API that shares the rules
163
+
164
+ Wrap `checkNote` as a Standard Schema (an object with a `~standard.validate` function) and give it to `schemaValidator`. A failure answers 400 with `{ error, issues: [{ message, path }] }`.
165
+ Build the API app by **chaining** the routes on `new Acryl()`, because the typed client and `testClient` only know chained routes:
166
+
167
+ ```ts
168
+ import { Acryl } from 'acrylblends'
169
+ import { schemaValidator, type StandardSchema } from 'acrylblends/schema-validator'
170
+
171
+ const NoteSchema: StandardSchema<NoteInput> = {
172
+ '~standard': { validate: (value) => checkNote(value) },
173
+ }
174
+
175
+ export const api = new Acryl()
176
+ .get('/', (c) => c.json({ notes: [] as NoteInput[] }, 200))
177
+ .post('/', schemaValidator('json', NoteSchema), (c) => c.json({ note: c.req.valid('json') }, 201))
178
+ .get('/:id', (c) => (c.req.param('id') === '1' ? c.json({ note: { title: 't', body: '' } }, 200) : c.json({ error: 'No such note.' }, 404)))
179
+ ```
180
+
181
+ Mount it below the pages with `app.route('/api/notes', api)`.
182
+
183
+ ## Testing every route
184
+
185
+ Call the app with `app.request`. A form POST needs a body and the cookie of a signed-in user, which a test reads from the sign-up response's `set-cookie` header:
186
+
187
+ ```ts
188
+ const response = await app.request('/signup', {
189
+ method: 'POST',
190
+ body: new URLSearchParams({ email: 'a@example.com', password: 'correct horse' }),
191
+ })
192
+ const cookie = (response.headers.get('set-cookie') ?? '').split(';')[0] ?? ''
193
+ await app.request('/notes', { headers: { cookie } })
194
+ ```
195
+
196
+ For the typed API use `testClient` ([Testing helper](../helpers/testing.md)); the cookie goes in the second argument of each call:
197
+
198
+ ```ts
199
+ import { testClient } from 'acrylblends/testing'
200
+
201
+ const client = testClient(api)
202
+ const created = await client.index.$post({ json: { title: 'First', body: 'hello' } }, { headers: { cookie } })
203
+ const { note } = await created.json()
204
+ const one = await client[':id'].$get({ param: { id: '1' } }, { headers: { cookie } })
205
+ ```
206
+
207
+ A route mounted at `'/'` is `client.index` and `'/:id'` is `client[':id']`.
208
+
209
+ ## A part of the page that reacts in the browser
210
+
211
+ A live character counter or an instant filter needs a little JavaScript in the page. The simplest way, with no build step, is a `<script>` in the server-rendered page:
212
+
213
+ ```tsx
214
+ const counter = `
215
+ const area = document.getElementById('body')
216
+ const out = document.getElementById('count')
217
+ const show = () => { out.textContent = area.value.length + ' / ' + area.maxLength }
218
+ area.addEventListener('input', show)
219
+ show()
220
+ `
221
+
222
+ export const Field = () => (
223
+ <>
224
+ <textarea id="body" name="body" maxlength={2000} />
225
+ <small id="count" aria-live="polite" />
226
+ <script dangerouslySetInnerHTML={{ __html: counter }} />
227
+ </>
228
+ )
229
+ ```
230
+
231
+ [Client Components](jsx-dom.md) (`acrylblends/jsx/dom`) are for pages that need components and state in the browser; they have to be bundled for the browser first (with Vite or esbuild).
@@ -105,11 +105,11 @@ export default function App() {
105
105
  </button>
106
106
  <div>
107
107
  {!showLargeImage ? (
108
- <img src=' />
108
+ <img src="/photo.jpg" alt="" />
109
109
  ) : (
110
110
  <div
111
111
  class={css`
112
- background: url(');
112
+ background: url('/photo.jpg');
113
113
  background-size: contain;
114
114
  background-repeat: no-repeat;
115
115
  background-position: center;
@@ -170,12 +170,12 @@ export default function App() {
170
170
  </button>
171
171
  <div>
172
172
  {!showLargeImage ? (
173
- <img src=' />
173
+ <img src="/photo.jpg" alt="" />
174
174
  ) : (
175
175
  <div
176
176
  class={css`
177
177
  ${transitionNameClass}
178
- background: url(');
178
+ background: url('/photo.jpg');
179
179
  background-size: contain;
180
180
  background-repeat: no-repeat;
181
181
  background-position: center;
@@ -240,12 +240,12 @@ export default function App() {
240
240
  </button>
241
241
  <div>
242
242
  {!showLargeImage ? (
243
- <img src=' />
243
+ <img src="/photo.jpg" alt="" />
244
244
  ) : (
245
245
  <div
246
246
  class={css`
247
247
  ${transitionNameClass}
248
- background: url(');
248
+ background: url('/photo.jpg');
249
249
  background-size: contain;
250
250
  background-repeat: no-repeat;
251
251
  background-position: center;
@@ -4,30 +4,6 @@ You can write HTML with JSX syntax with `acrylblends/jsx`.
4
4
 
5
5
  Although `acrylblends/jsx` works on the client, you will probably use it most often when rendering content on the server side. Here are some things related to JSX that are common to both server and client.
6
6
 
7
- ## Node.js runs `jsx()`, not JSX syntax
8
-
9
- The apps `npm create acrylblends@latest` makes run their TypeScript on Node.js by stripping the types (no build step). That does **not** compile JSX: a `.tsx` file does not load. Either compile the file first (`tsc`
10
- or a bundler, with the settings below), or write the call that JSX compiles to. `jsx(tag, props, ...children)` takes the children after the props, and a node renders with `toString()`:
11
-
12
- ```ts
13
- import { Acryl } from 'acrylblends'
14
- import { jsx } from 'acrylblends/jsx'
15
- import type { PropsWithChildren } from 'acrylblends/jsx'
16
-
17
- const Layout = (props: PropsWithChildren<{ title: string }>) =>
18
- jsx('html', { lang: 'en' }, jsx('head', null, jsx('title', null, props.title)), jsx('body', null, props.children))
19
-
20
- const About = () =>
21
- jsx(Layout, { title: 'About' }, jsx('h1', null, 'About'), jsx('p', { class: 'lead' }, 'Rendered on the server.'))
22
-
23
- const app = new Acryl()
24
- app.get('/about', (c) => c.html(jsx(About, null).toString()))
25
-
26
- export default app
27
- ```
28
-
29
- Pass the children as arguments after the props. A `children` entry inside the props object is not rendered by this form.
30
-
31
7
  ## Settings
32
8
 
33
9
  To use JSX, modify the `tsconfig.json`:
@@ -50,9 +26,34 @@ Alternatively, use the pragma directives:
50
26
  /** @jsxImportSource acrylblends/jsx */
51
27
  ```
52
28
 
29
+ ## Run `.tsx` on Node.js
30
+
31
+ Node.js strips TypeScript's types but does not compile JSX, so a `.tsx` file needs one flag: `--import acrylblends/node-server/register`.
32
+ It compiles each `.tsx` (and `.jsx`) module as Node.js loads it, with your app's own `typescript` (a development dependency) and the
33
+ `jsxImportSource` of your `tsconfig.json` (`acrylblends/jsx` without one); every other module runs exactly as before. Stack traces point at
34
+ the `.tsx` lines.
35
+
36
+ `package.json`:
37
+
38
+ ```json
39
+ {
40
+ "scripts": {
41
+ "dev": "node --watch --import acrylblends/node-server/register src/index.tsx",
42
+ "start": "node --import acrylblends/node-server/register src/index.tsx",
43
+ "test": "node --import acrylblends/node-server/register --test \"src/**/*.test.{ts,tsx}\""
44
+ },
45
+ "devDependencies": {
46
+ "typescript": "^6.0.3"
47
+ }
48
+ }
49
+ ```
50
+
51
+ `node --test` looks only for `.js` and `.ts` test files on its own, so the `test` script names the `.tsx` ones with a pattern. Without `typescript` in the app, the first `.tsx` module stops with a message that says to install it. Bun compiles JSX itself and needs
52
+ no flag.
53
+
53
54
  ## Usage
54
55
 
55
- > **Note.** If you are coming straight from the [Quick Start](../README.md#quick-start), the main file has a `.ts` extension - you need to change it to `.tsx` - otherwise you will not be able to run the application at all. You should additionally modify the `package.json` (or `deno.json` if you are using Deno) to reflect that change (e.g. instead of having `bun run --hot src/index.ts` in dev script, you should have `bun run --hot src/index.tsx`).
56
+ > **Note.** If you are coming straight from the [Quick Start](../README.md#quick-start), the main file has a `.ts` extension - you need to change it to `.tsx` - otherwise you will not be able to run the application at all. Change the scripts in `package.json` to match: on Node.js, as [above](#run-tsx-on-nodejs); on Bun, `bun run --hot src/index.tsx` instead of `bun run --hot src/index.ts`.
56
57
 
57
58
  `index.tsx`:
58
59
 
@@ -93,6 +94,31 @@ app.get('/', (c) => {
93
94
  export default app
94
95
  ```
95
96
 
97
+ ## Without compiling: `jsx()`
98
+
99
+ A `.ts` file can build the same nodes without JSX syntax and without the register flag: write the call that JSX compiles to.
100
+ `jsx(tag, props, ...children)` takes the children after the props, and a node renders with `toString()`. The `` html`...` `` template
101
+ from `acrylblends/html` is the other build-free choice.
102
+
103
+ ```ts
104
+ import { Acryl } from 'acrylblends'
105
+ import { jsx } from 'acrylblends/jsx'
106
+ import type { PropsWithChildren } from 'acrylblends/jsx'
107
+
108
+ const Layout = (props: PropsWithChildren<{ title: string }>) =>
109
+ jsx('html', { lang: 'en' }, jsx('head', null, jsx('title', null, props.title)), jsx('body', null, props.children))
110
+
111
+ const About = () =>
112
+ jsx(Layout, { title: 'About' }, jsx('h1', null, 'About'), jsx('p', { class: 'lead' }, 'Rendered on the server.'))
113
+
114
+ const app = new Acryl()
115
+ app.get('/about', (c) => c.html(jsx(About, null).toString()))
116
+
117
+ export default app
118
+ ```
119
+
120
+ Pass the children as arguments after the props. A `children` entry inside the props object is not rendered by this form.
121
+
96
122
  ## Metadata hoisting
97
123
 
98
124
  You can write document metadata tags such as `<title>`, `<link>`, and `<meta>` directly inside your components. These tags will be automatically hoisted to the `<head>` section of the document. This is especially useful when the `<head>` element is rendered far from the component that determines the appropriate metadata.
@@ -213,7 +213,7 @@ const route = app.post(
213
213
  validator('form', (value, c) => {
214
214
  const parsed = schema.safeParse(value)
215
215
  if (!parsed.success) {
216
- return c.text('Invalid!', 401)
216
+ return c.text('Invalid!', 400)
217
217
  }
218
218
  return parsed.data
219
219
  }),
@@ -36,7 +36,7 @@ app.get('/cookie', (c) => {
36
36
  **NOTE**: Setting and retrieving signed cookies returns a Promise due to the async nature of the WebCrypto API, which is used to create HMAC SHA-256 signatures.
37
37
 
38
38
  ```ts
39
- app.get('/signed-cookie', (c) => {
39
+ app.get('/signed-cookie', async (c) => {
40
40
  const secret = process.env.COOKIE_SECRET! // a long random string, kept out of the source
41
41
 
42
42
  await setSignedCookie(c, 'cookie_name0', 'cookie_value', secret)
@@ -2,8 +2,8 @@
2
2
  "source": {
3
3
  "repo": "acryldev/acryl",
4
4
  "path": "docs/framework",
5
- "commit": "fd1f7dcaaff8b757c0562294002c0d32d6e0a893"
5
+ "commit": "524406d2033cdc5ca5e01207f9ce29543bbf221b"
6
6
  },
7
- "files": 88,
8
- "digest": "sha256:5e6989c7fe1b3006f8917926d8cc3e84e14c2af03440682c18d22bec0045c3d2"
7
+ "files": 89,
8
+ "digest": "sha256:fe2da945c3286e316a0412b2d3cfccc4dbf8d1c082e283b77606b45e27b778aa"
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-acrylblends",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Create an Acryl app: npm create acrylblends@latest",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "scaffold"
38
38
  ],
39
39
  "dependencies": {
40
- "acrylblends": "^0.6.4"
40
+ "acrylblends": "^0.7.1"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"
@@ -7,9 +7,9 @@
7
7
  "node": ">=22.19.0"
8
8
  },
9
9
  "scripts": {
10
- "dev": "node --watch src/index.ts",
11
- "start": "node src/index.ts",
12
- "test": "node --test",
10
+ "dev": "node --watch --import acrylblends/node-server/register src/index.ts",
11
+ "start": "node --import acrylblends/node-server/register src/index.ts",
12
+ "test": "node --import acrylblends/node-server/register --test \"test/**/*.test.{ts,tsx}\"",
13
13
  "check": "tsc --noEmit"
14
14
  },
15
15
  "dependencies": {
@@ -1,7 +1,10 @@
1
1
  import { __CLASS__ } from '__PACKAGE__'
2
+ import { aboutPage } from './page.tsx'
2
3
 
3
4
  const app = new __CLASS__()
4
5
 
5
6
  app.get('/', (c) => c.text('__CLASS__!'))
6
7
 
8
+ app.get('/about', (c) => c.html(aboutPage()))
9
+
7
10
  export default app
@@ -0,0 +1,14 @@
1
+ /** A page written in JSX: Node.js runs it through `acrylblends/node-server/register` (see the scripts in package.json), which compiles .tsx with this app's own typescript. */
2
+ const About = () => (
3
+ <main>
4
+ <h1>About</h1>
5
+ <p>Rendered on the server with __PACKAGE__/jsx.</p>
6
+ </main>
7
+ )
8
+
9
+ export const aboutPage = (): string => (<About />).toString()
10
+
11
+ /** Throws on purpose: the template's test checks that the stack names this file, so a failure in your own pages points at the .tsx line. */
12
+ export const failingPage = (): string => {
13
+ throw new Error('a page failed')
14
+ }
@@ -7,3 +7,24 @@ test('GET / answers __CLASS__!', async () => {
7
7
  assert.equal(response.status, 200)
8
8
  assert.equal(await response.text(), '__CLASS__!')
9
9
  })
10
+
11
+ test('GET /about renders the JSX page', async () => {
12
+ const response = await app.request('/about')
13
+ assert.equal(response.status, 200)
14
+ assert.match(response.headers.get('content-type') ?? '', /html/)
15
+ assert.match(
16
+ await response.text(),
17
+ /<main><h1>About<\/h1><p>Rendered on the server with __PACKAGE__\/jsx\.<\/p><\/main>/,
18
+ )
19
+ })
20
+
21
+ test('an error thrown in a .tsx file names the .tsx line in its stack', async () => {
22
+ const { failingPage } = await import('../src/page.tsx')
23
+ let stack = ''
24
+ try {
25
+ failingPage()
26
+ } catch (error) {
27
+ stack = String((error as Error).stack)
28
+ }
29
+ assert.match(stack, /page\.tsx:\d+/)
30
+ })
@@ -10,7 +10,9 @@
10
10
  "allowImportingTsExtensions": true,
11
11
  "noEmit": true,
12
12
  "skipLibCheck": true,
13
- "types": ["node"]
13
+ "types": ["node"],
14
+ "jsx": "react-jsx",
15
+ "jsxImportSource": "__PACKAGE__/jsx"
14
16
  },
15
17
  "include": ["src", "test"]
16
18
  }