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 +38 -5
- package/dist/index.js.map +1 -1
- package/docs/framework/README.md +1 -1
- package/docs/framework/UPSTREAM.md +1 -1
- package/docs/framework/api/summary.md +1 -1
- package/docs/framework/getting-started/desktop.md +1 -1
- package/docs/framework/guides/forms-and-sessions.md +231 -0
- package/docs/framework/guides/jsx-dom.md +6 -6
- package/docs/framework/guides/jsx.md +51 -25
- package/docs/framework/guides/validation.md +1 -1
- package/docs/framework/helpers/cookie.md +1 -1
- package/docs/framework/upstream.json +3 -3
- package/package.json +2 -2
- package/templates/nodejs/package.json +3 -3
- package/templates/nodejs/src/app.ts +3 -0
- package/templates/nodejs/src/page.tsx +14 -0
- package/templates/nodejs/test/app.test.ts +21 -0
- package/templates/nodejs/tsconfig.json +3 -1
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
|
|
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
|
-
-
|
|
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":"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"}
|
|
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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"}
|
package/docs/framework/README.md
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -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": "
|
|
5
|
+
"commit": "524406d2033cdc5ca5e01207f9ce29543bbf221b"
|
|
6
6
|
},
|
|
7
|
-
"files":
|
|
8
|
-
"digest": "sha256:
|
|
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
|
+
"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.
|
|
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": {
|
|
@@ -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
|
+
})
|