@webjsdev/cli 0.10.52 → 0.10.54
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/README.md +2 -2
- package/bin/webjs.js +17 -0
- package/lib/app-name.js +73 -0
- package/lib/check-target.js +145 -0
- package/lib/create.js +42 -26
- package/lib/doctor.js +91 -16
- package/lib/gallery-shell-files.js +36 -0
- package/package.json +3 -3
- package/templates/.agents/skills/webjs/SKILL.md +6 -2
- package/templates/.agents/skills/webjs/references/built-ins.md +1 -1
- package/templates/.agents/skills/webjs/references/components.md +95 -3
- package/templates/.agents/skills/webjs/references/data-and-actions.md +29 -2
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +25 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +24 -1
- package/templates/.agents/skills/webjs/references/styling.md +14 -1
- package/templates/.agents/skills/webjs/references/testing.md +8 -0
- package/templates/.agents/skills/webjs/references/typescript.md +17 -3
- package/templates/.agents/skills/webjs/references/ui-kit.md +15 -0
- package/templates/.claude/hooks/block-prose-punctuation.sh +66 -24
- package/templates/gallery/app/icon.ts +10 -5
- package/templates/gallery/modules/directives/components/directive-demo.ts +4 -1
- package/templates/gallery/modules/gallery/components/gallery-nav.ts +5 -0
- package/templates/gallery/modules/gallery/nav.ts +12 -3
- package/templates/gallery/modules/todo/actions/submit-todo.server.ts +7 -0
- package/templates/test/hello/e2e/hello.test.ts +26 -1
- package/templates/.cursor/hooks/nudge-uncommitted.sh +0 -38
- package/templates/.cursor/hooks.json +0 -8
- package/templates/.cursorrules +0 -21
- package/templates/.gemini/hooks/nudge-uncommitted.sh +0 -42
- package/templates/.gemini/settings.json +0 -15
- package/templates/.github/copilot-instructions.md +0 -9
- package/templates/.opencode/plugins/nudge-uncommitted.ts +0 -62
- package/templates/GEMINI.md +0 -11
package/README.md
CHANGED
|
@@ -68,8 +68,8 @@ the CLI gives you `webjs ui` automatically. See
|
|
|
68
68
|
|
|
69
69
|
The scaffold seeds opinionated defaults so AI agents produce consistent code:
|
|
70
70
|
|
|
71
|
-
- `AGENTS.md` + `CONVENTIONS.md` (
|
|
72
|
-
- `.
|
|
71
|
+
- `AGENTS.md` + `CONVENTIONS.md` + `.agents/skills/webjs/` (single cross-agent source of truth)
|
|
72
|
+
- `.agents/rules/workflow.md` & `.claude/` protective hooks
|
|
73
73
|
- `test/<feature>/` (with optional `browser/` / `e2e/` subfolders per kind) with example tests
|
|
74
74
|
- Tailwind CSS via CLI (no browser runtime at build time)
|
|
75
75
|
- TypeScript, `.editorconfig`, `.gitignore`
|
package/bin/webjs.js
CHANGED
|
@@ -9,6 +9,7 @@ import { checkNodeInline, nodeInlineMessage } from '../lib/node-preflight.js';
|
|
|
9
9
|
import { loadAppEnv, resolvePort } from '../lib/port.js';
|
|
10
10
|
import { planDevSupervisor } from '../lib/dev-supervisor.js';
|
|
11
11
|
import { checkAppName, appNameErrorMessage } from '../lib/app-name.js';
|
|
12
|
+
import { findCheckTarget, notAnAppMessage, notAnAppJson } from '../lib/check-target.js';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
@@ -722,6 +723,22 @@ async function main() {
|
|
|
722
723
|
break;
|
|
723
724
|
}
|
|
724
725
|
|
|
726
|
+
// #1301: `webjs check` is an APP-level tool. At a workspace root it
|
|
727
|
+
// walks every package's tests and every app at once and reports
|
|
728
|
+
// cross-app collisions that no single runtime ever sees (67 false
|
|
729
|
+
// findings at this repo's root). Refuse instead, naming the member
|
|
730
|
+
// apps to run it in. Exit 1: an agent gates on the exit status, and
|
|
731
|
+
// 0 would read as "clean", the exact false signal this fixes.
|
|
732
|
+
const target = await findCheckTarget(process.cwd());
|
|
733
|
+
if (!target.isApp) {
|
|
734
|
+
if (rest.includes('--json')) {
|
|
735
|
+
console.log(JSON.stringify(notAnAppJson(process.cwd(), target.workspaceApps)));
|
|
736
|
+
} else {
|
|
737
|
+
console.error(notAnAppMessage(process.cwd(), target.workspaceApps));
|
|
738
|
+
}
|
|
739
|
+
process.exit(1);
|
|
740
|
+
}
|
|
741
|
+
|
|
725
742
|
const violations = await checkConventions(process.cwd());
|
|
726
743
|
|
|
727
744
|
// --json emits the raw structured violations + a summary count as JSON,
|
package/lib/app-name.js
CHANGED
|
@@ -206,3 +206,76 @@ export function assertValidAppName(name) {
|
|
|
206
206
|
}
|
|
207
207
|
return /** @type {string} */ (name);
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* PostgreSQL's hard cap on an identifier, `NAMEDATALEN - 1` bytes. An
|
|
212
|
+
* over-length `CREATE DATABASE` name is silently truncated to this with only a
|
|
213
|
+
* NOTICE, which would reproduce the same name mismatch in a new guise, so the
|
|
214
|
+
* derivation caps it here instead.
|
|
215
|
+
*/
|
|
216
|
+
export const DB_NAME_MAX_LENGTH = 63;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Derive the PostgreSQL database name the scaffold writes into the generated
|
|
220
|
+
* `.env.example` `DATABASE_URL`. The APP NAME itself is never touched by this:
|
|
221
|
+
* the directory, the `package.json` `name`, the `{{APP_NAME}}` substitution and
|
|
222
|
+
* `metadata.title` all keep the name exactly as typed. Only the database
|
|
223
|
+
* segment of that one URL is normalized.
|
|
224
|
+
*
|
|
225
|
+
* The point is a QUOTING-INVARIANT name. A result in `[a-z_][a-z0-9_]*` under
|
|
226
|
+
* 63 bytes folds to itself under `CREATE DATABASE <name>;` AND is passed
|
|
227
|
+
* through unquoted by `createdb` (which builds its statement through `fmtId`),
|
|
228
|
+
* so the emitted URL names the same database whichever route the user takes.
|
|
229
|
+
* A case-preserving name does not have that property: `CREATE DATABASE MyApp;`
|
|
230
|
+
* creates `myapp` while `createdb MyApp` creates `MyApp`.
|
|
231
|
+
*
|
|
232
|
+
* One qualification on that property. A name that folds to a PostgreSQL
|
|
233
|
+
* KEYWORD (`order`, `user`, `table`, `group`, `check`, `window`, `limit`) is
|
|
234
|
+
* still not quoting-invariant: `CREATE DATABASE order;` is a syntax error
|
|
235
|
+
* rather than a fold, while `createdb order` succeeds because `fmtId` quotes a
|
|
236
|
+
* keyword. That is deliberately not detected here. The keyword list is
|
|
237
|
+
* version-dependent and roughly 470 entries, which is disproportionate in a
|
|
238
|
+
* helper whose whole point is being pure and dependency-free, and the failure
|
|
239
|
+
* is a loud syntax error on a placeholder line the user is editing anyway,
|
|
240
|
+
* not the silent wrong-database mismatch this function exists to remove.
|
|
241
|
+
*
|
|
242
|
+
* Three sub-rules, in this order, and the order is load-bearing:
|
|
243
|
+
*
|
|
244
|
+
* 1. Fold with `toLowerCase()`, NOT `toLocaleLowerCase()`. The latter is
|
|
245
|
+
* locale-dependent, so a Turkish-locale machine would fold `I` to the
|
|
246
|
+
* dotless `ı`, which the class below then turns into `_`, making the
|
|
247
|
+
* generated file machine-dependent.
|
|
248
|
+
* 2. Map every remaining character outside `[a-z0-9_]` to `_`, one for one.
|
|
249
|
+
* No run-collapsing, no trimming: both are legal identifier characters,
|
|
250
|
+
* and a 1:1 fold is one a reader can apply by eye. `checkAppName` already
|
|
251
|
+
* restricts the input to `[A-Za-z0-9._-]`, so in practice this only ever
|
|
252
|
+
* rewrites `.` and `-`.
|
|
253
|
+
* 3. Prefix a single `_` when the first character is a digit, BEFORE the
|
|
254
|
+
* slice so the cap governs the final string. `ALLOWED_FIRST_CHAR` admits
|
|
255
|
+
* a leading digit, and an unquoted PostgreSQL identifier may not start
|
|
256
|
+
* with one.
|
|
257
|
+
*
|
|
258
|
+
* Slicing LAST is what makes the byte cap correct: after the fold every
|
|
259
|
+
* surviving character is one ASCII byte, so a code-unit slice is a byte slice,
|
|
260
|
+
* and there is no surrogate pair or percent-escape for it to bisect (every
|
|
261
|
+
* character is unreserved under RFC 3986, so the segment needs no encoding).
|
|
262
|
+
*
|
|
263
|
+
* Precondition: `name` has passed `checkAppName`. `scaffoldApp` asserts that
|
|
264
|
+
* before any file is written. That is what guarantees a non-empty result (a
|
|
265
|
+
* validated name's first character is `[A-Za-z0-9]`, which folds into the
|
|
266
|
+
* class and survives), so there is no empty-result fallback branch here.
|
|
267
|
+
*
|
|
268
|
+
* Collisions are accepted and NOT detected. `My-App` and `my_app` both fold to
|
|
269
|
+
* `my_app`. This is a placeholder in `.env.example`, not a provisioned
|
|
270
|
+
* resource: the scaffold contacts no server and cannot know what exists, and a
|
|
271
|
+
* uniquifying suffix would make the name untraceable to the app name. Rails
|
|
272
|
+
* takes the same position in `railties/lib/rails/generators/app_name.rb`.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} name an app name that has passed `checkAppName`
|
|
275
|
+
* @returns {string} a fold-stable, unquoted-safe PostgreSQL database name
|
|
276
|
+
*/
|
|
277
|
+
export function toDatabaseName(name) {
|
|
278
|
+
const folded = name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
279
|
+
const prefixed = /^[0-9]/.test(folded) ? `_${folded}` : folded;
|
|
280
|
+
return prefixed.slice(0, DB_NAME_MAX_LENGTH);
|
|
281
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `webjs check` invocation-target guard (#1301).
|
|
3
|
+
*
|
|
4
|
+
* `webjs check` is an APP-level tool: every rule assumes one application,
|
|
5
|
+
* meaning one module graph, one custom-element registry, one runtime. Run at a
|
|
6
|
+
* workspace root it walks whatever JS/TS happens to live under that path (two
|
|
7
|
+
* apps, every package's test suite, editor fixtures, the scaffold templates)
|
|
8
|
+
* and reports collisions no single runtime ever sees. At this repo's root that
|
|
9
|
+
* was 67 findings, all false.
|
|
10
|
+
*
|
|
11
|
+
* The predicate is the presence of an `app/` directory, nothing else. That is
|
|
12
|
+
* the same test `check.js` already applies per-rule, `app/` cannot be renamed
|
|
13
|
+
* (AGENTS.md "App layout"), and BOTH scaffold templates create it. Next.js
|
|
14
|
+
* refuses on the identical predicate in
|
|
15
|
+
* `packages/next/src/lib/find-pages-dir.ts`. A `workspaces` key is NOT part of
|
|
16
|
+
* the predicate (a directory with no `app/` is not an app either way); it only
|
|
17
|
+
* enriches the MESSAGE with the member apps to run instead.
|
|
18
|
+
*
|
|
19
|
+
* PURE apart from directory reads: it never prints and never exits. The bin
|
|
20
|
+
* owns rendering and the exit code.
|
|
21
|
+
*
|
|
22
|
+
* @module check-target
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { statSync } from 'node:fs';
|
|
26
|
+
import { readFile, glob } from 'node:fs/promises';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{ isApp: boolean, workspaceApps: string[] }} CheckTarget
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether `dir` holds an `app/` DIRECTORY. A plain file named `app` is not one,
|
|
35
|
+
* and `existsSync` alone would call it one, so the type is checked. A broken
|
|
36
|
+
* symlink or an unreadable parent throws out of `statSync` rather than
|
|
37
|
+
* returning false, so it is caught: an unreadable path is not an app either.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} dir
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function hasAppDir(dir) {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(join(dir, 'app')).isDirectory();
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify `cwd` as a checkable app or not, and (when it declares npm
|
|
52
|
+
* workspaces) list the member directories that ARE apps, sorted, as
|
|
53
|
+
* cwd-relative POSIX paths.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @returns {Promise<CheckTarget>}
|
|
57
|
+
*/
|
|
58
|
+
export async function findCheckTarget(cwd) {
|
|
59
|
+
if (hasAppDir(cwd)) return { isApp: true, workspaceApps: [] };
|
|
60
|
+
return { isApp: false, workspaceApps: await workspaceApps(cwd) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Expand `package.json` `workspaces` (the array form and yarn's
|
|
65
|
+
* `{ packages: [...] }` form) and keep the members that have an `app/`
|
|
66
|
+
* directory. Any read / parse failure yields an empty list: the message
|
|
67
|
+
* degrades to the generic form and the refusal still stands.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} cwd
|
|
70
|
+
* @returns {Promise<string[]>}
|
|
71
|
+
*/
|
|
72
|
+
async function workspaceApps(cwd) {
|
|
73
|
+
let patterns;
|
|
74
|
+
try {
|
|
75
|
+
const pkg = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
|
|
76
|
+
const ws = pkg.workspaces;
|
|
77
|
+
patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
if (!patterns) return [];
|
|
82
|
+
/** @type {Set<string>} */
|
|
83
|
+
const apps = new Set();
|
|
84
|
+
for (const pattern of patterns) {
|
|
85
|
+
if (typeof pattern !== 'string') continue;
|
|
86
|
+
try {
|
|
87
|
+
for await (const match of glob(pattern, { cwd })) {
|
|
88
|
+
// `glob` yields whatever matched, files included, so the app test is
|
|
89
|
+
// what filters a stray same-named file out too.
|
|
90
|
+
if (hasAppDir(join(cwd, match))) apps.add(match.split('\\').join('/'));
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// A malformed pattern drops out of the listing, never out of the refusal.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...apps].sort();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The human refusal, for stderr.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} cwd
|
|
103
|
+
* @param {string[]} apps
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function notAnAppMessage(cwd, apps) {
|
|
107
|
+
const lines = [
|
|
108
|
+
'webjs check: this directory is not a WebJs app, so nothing was checked.',
|
|
109
|
+
'',
|
|
110
|
+
` ${cwd}`,
|
|
111
|
+
'',
|
|
112
|
+
'There is no `app/` directory here. Every check assumes ONE application',
|
|
113
|
+
'(one module graph, one custom-element registry, one runtime), so running',
|
|
114
|
+
'them over a workspace root reports collisions no single runtime ever sees.',
|
|
115
|
+
'',
|
|
116
|
+
];
|
|
117
|
+
if (apps.length) {
|
|
118
|
+
lines.push('This is a workspace root. Run the check inside each app:', '');
|
|
119
|
+
for (const app of apps) lines.push(` ( cd ${app} && npx webjs check )`);
|
|
120
|
+
} else {
|
|
121
|
+
lines.push('Change into your app directory (the one holding `app/`) and re-run.');
|
|
122
|
+
}
|
|
123
|
+
lines.push('', '`webjs check --rules` lists the rules and works from anywhere.');
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The `--json` refusal. It carries NO `violations` key on purpose: a consumer
|
|
129
|
+
* that ignores the exit code and reads `report.violations.length` must throw
|
|
130
|
+
* rather than be told the workspace is clean.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} cwd
|
|
133
|
+
* @param {string[]} apps
|
|
134
|
+
* @returns {{ error: { code: string, message: string, cwd: string, apps: string[] } }}
|
|
135
|
+
*/
|
|
136
|
+
export function notAnAppJson(cwd, apps) {
|
|
137
|
+
return {
|
|
138
|
+
error: {
|
|
139
|
+
code: 'NOT_AN_APP',
|
|
140
|
+
message: 'No `app/` directory here, so webjs check has no application to check.',
|
|
141
|
+
cwd,
|
|
142
|
+
apps,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
package/lib/create.js
CHANGED
|
@@ -12,13 +12,14 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { mkdir, writeFile, readFile, cp } from 'node:fs/promises';
|
|
15
|
-
import { join, resolve, dirname } from 'node:path';
|
|
15
|
+
import { join, resolve, dirname, relative } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
19
|
import { spawnSync } from 'node:child_process';
|
|
20
20
|
import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
|
|
21
|
-
import { assertValidAppName } from './app-name.js';
|
|
21
|
+
import { assertValidAppName, toDatabaseName } from './app-name.js';
|
|
22
|
+
import { isGalleryAppShellFile } from './gallery-shell-files.js';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Detect which package manager invoked us. Reads `npm_config_user_agent`,
|
|
@@ -116,13 +117,20 @@ const UI_REGISTRY_ROOT = resolveUiRegistryRoot();
|
|
|
116
117
|
* Ships verbatim (no `{{APP_NAME}}` substitution): the examples are self-
|
|
117
118
|
* contained and reference only `@webjsdev/*`, drizzle, `#db/*`, and each other.
|
|
118
119
|
* The scaffold's own `app/page.ts` / `app/layout.ts` are written AFTER this and
|
|
119
|
-
* the
|
|
120
|
-
* `app/` and `modules/` dirs rather than
|
|
120
|
+
* are filtered out of the copy here (see GALLERY_APP_SHELL_FILES), so there is
|
|
121
|
+
* no clobber. `cp` merges into existing `app/` and `modules/` dirs rather than
|
|
122
|
+
* replacing them.
|
|
123
|
+
*
|
|
124
|
+
* The source is the canonical repo-root `gallery/` app in monorepo dev, and the
|
|
125
|
+
* `templates/gallery/` copy `prepack` bundles into the tarball when installed
|
|
126
|
+
* from npm. Both are filtered identically, so the two modes emit the same app.
|
|
121
127
|
*
|
|
122
128
|
* @param {string} appDir
|
|
123
129
|
*/
|
|
124
130
|
async function copyGallery(appDir) {
|
|
125
|
-
const
|
|
131
|
+
const bundledGallery = join(TEMPLATES, 'gallery');
|
|
132
|
+
const repoRootGallery = resolve(__dirname, '..', '..', '..', 'gallery');
|
|
133
|
+
const galleryDir = existsSync(bundledGallery) ? bundledGallery : repoRootGallery;
|
|
126
134
|
// `test` carries the auth card's real request-pipeline test (test/auth); it
|
|
127
135
|
// ships with the gallery and is pruned by gallery:clear alongside the card.
|
|
128
136
|
// `components` carries the gallery's EXAMPLE design system (components/ui/ class
|
|
@@ -135,7 +143,15 @@ async function copyGallery(appDir) {
|
|
|
135
143
|
// the ui bootstrap's cn.ts/dom.ts (written earlier); gallery:clear removes just
|
|
136
144
|
// ui.ts. cp is recursive-merge, so the pre-written lib/utils/ files are kept.
|
|
137
145
|
for (const sub of ['app', 'modules', 'test', 'components', 'lib']) {
|
|
138
|
-
|
|
146
|
+
const srcSub = join(galleryDir, sub);
|
|
147
|
+
if (!existsSync(srcSub)) continue;
|
|
148
|
+
await cp(srcSub, join(appDir, sub), {
|
|
149
|
+
recursive: true,
|
|
150
|
+
// Skip the gallery's own app shell (root layout, home page, theme toggle,
|
|
151
|
+
// cn.ts). Those exist because gallery/ is a live app; the scaffold writes
|
|
152
|
+
// its own, with the app's displayName and the ui-registry cn.ts.
|
|
153
|
+
filter: (src) => !isGalleryAppShellFile(relative(galleryDir, src)),
|
|
154
|
+
});
|
|
139
155
|
}
|
|
140
156
|
}
|
|
141
157
|
|
|
@@ -541,6 +557,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
541
557
|
{ name: '@webjsdev/intellisense' },
|
|
542
558
|
],
|
|
543
559
|
},
|
|
560
|
+
// `test/**/*` is in so `webjs typecheck` reads the tests you write, the
|
|
561
|
+
// same way Next / Remix / Astro's generated configs do (#1299). A type
|
|
562
|
+
// error in a test is then a gate failure rather than something a reviewer
|
|
563
|
+
// has to catch by eye, and it needs no second config to remember to run.
|
|
564
|
+
//
|
|
544
565
|
// `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
|
|
545
566
|
// run `webjs types` (or `webjs dev`, which emits it) to narrow the
|
|
546
567
|
// @webjsdev/core `Route` href union + per-route `params`. Listed in
|
|
@@ -552,6 +573,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
552
573
|
'components/**/*',
|
|
553
574
|
'modules/**/*',
|
|
554
575
|
'lib/**/*',
|
|
576
|
+
'test/**/*',
|
|
555
577
|
'middleware.js',
|
|
556
578
|
'middleware.ts',
|
|
557
579
|
'.webjs/routes.d.ts',
|
|
@@ -562,26 +584,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
562
584
|
// --- Templates (AGENTS.md, CONVENTIONS.md, CLAUDE.md, test files, Claude hooks) ---
|
|
563
585
|
|
|
564
586
|
const templateFiles = [
|
|
565
|
-
// Single cross-agent source:
|
|
587
|
+
// Single cross-agent source: AGENTS.md points at .agents/skills/webjs/; the
|
|
566
588
|
// .agents/rules workflow rules and the Claude enforcement hooks back it up.
|
|
567
589
|
'AGENTS.md',
|
|
568
590
|
'CONVENTIONS.md',
|
|
569
591
|
'.agents/rules/workflow.md',
|
|
570
|
-
// Per-agent files. Content is single-source (AGENTS.md + the skill); these
|
|
571
|
-
// are thin bridges plus each tool's own config and commit-nudge hook. Claude
|
|
572
|
-
// Code (CLAUDE.md @-imports AGENTS.md), Gemini CLI (GEMINI.md), Copilot in VS
|
|
573
|
-
// Code (copilot-instructions.md). Cursor / opencode / Antigravity read
|
|
574
|
-
// AGENTS.md natively; Cursor also gets a .cursorrules bridge, and each of
|
|
575
|
-
// Cursor / Gemini / opencode ships a "commit often" nudge hook.
|
|
576
592
|
'CLAUDE.md',
|
|
577
|
-
'GEMINI.md',
|
|
578
|
-
'.github/copilot-instructions.md',
|
|
579
|
-
'.cursorrules',
|
|
580
|
-
'.cursor/hooks.json',
|
|
581
|
-
'.cursor/hooks/nudge-uncommitted.sh',
|
|
582
|
-
'.gemini/settings.json',
|
|
583
|
-
'.gemini/hooks/nudge-uncommitted.sh',
|
|
584
|
-
'.opencode/plugins/nudge-uncommitted.ts',
|
|
585
593
|
// Claude Code config + the protective enforcement hooks (no design ceremony).
|
|
586
594
|
'.claude.json',
|
|
587
595
|
'.claude/settings.json',
|
|
@@ -623,7 +631,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
|
|
|
623
631
|
// rewrites; the three infra files get their file-specific transform. On Node,
|
|
624
632
|
// every file is copied byte-identical (the map is empty).
|
|
625
633
|
const PROSE_REWRITE = new Set([
|
|
626
|
-
'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md',
|
|
634
|
+
'AGENTS.md', 'CLAUDE.md', 'CONVENTIONS.md',
|
|
627
635
|
'.agents/rules/workflow.md',
|
|
628
636
|
'test/hello/browser/hello.test.js', 'test/hello/e2e/hello.test.ts',
|
|
629
637
|
]);
|
|
@@ -877,9 +885,14 @@ export default defineConfig({
|
|
|
877
885
|
`);
|
|
878
886
|
|
|
879
887
|
// Env vars: append DATABASE_URL to the .env.example the template already
|
|
880
|
-
// copied (if present), idempotently.
|
|
888
|
+
// copied (if present), idempotently. The database segment is the app name
|
|
889
|
+
// normalized to a fold-stable PostgreSQL identifier, so the emitted URL
|
|
890
|
+
// names the same database whether the user runs `createdb` or types
|
|
891
|
+
// `CREATE DATABASE`. Hoisted because the post-scaffold guidance below names
|
|
892
|
+
// the same value, and the two must not be able to drift.
|
|
893
|
+
const dbName = toDatabaseName(name);
|
|
881
894
|
const dbUrlLine = dialect === 'postgres'
|
|
882
|
-
? 'DATABASE_URL=postgres://user:password@localhost:5432/' +
|
|
895
|
+
? 'DATABASE_URL=postgres://user:password@localhost:5432/' + dbName
|
|
883
896
|
: 'DATABASE_URL=file:./db/dev.db';
|
|
884
897
|
const envExample = join(appDir, '.env.example');
|
|
885
898
|
if (existsSync(envExample)) {
|
|
@@ -1365,13 +1378,16 @@ import { cardClass } from '#components/ui/card.ts';
|
|
|
1365
1378
|
import { badgeClass } from '#components/ui/badge.ts';
|
|
1366
1379
|
// The demo index is defined once in modules/gallery/nav.ts (the same source the
|
|
1367
1380
|
// left sidebar reads), so the home cards and the sidebar can never drift.
|
|
1368
|
-
import {
|
|
1381
|
+
import { featureList, EXAMPLES } from '#modules/gallery/nav.ts';
|
|
1369
1382
|
|
|
1370
1383
|
export const metadata = {
|
|
1371
1384
|
title: '${displayName}',
|
|
1372
1385
|
};
|
|
1373
1386
|
|
|
1374
1387
|
export default function Home() {
|
|
1388
|
+
// Flattened HERE rather than at module scope. A top-level call is a module
|
|
1389
|
+
// side effect, which would ship this page to the browser for nothing.
|
|
1390
|
+
const FEATURES = featureList();
|
|
1375
1391
|
return html\`
|
|
1376
1392
|
<div class="py-8 flex flex-col items-center gap-16">
|
|
1377
1393
|
<!-- Hero -->
|
|
@@ -1596,7 +1612,7 @@ ThemeToggle.register('theme-toggle');
|
|
|
1596
1612
|
// local file with no .env). Point it at a running database; `dev` / `start`
|
|
1597
1613
|
// then apply pending migrations via webjs.*.before.
|
|
1598
1614
|
const pgNote = dialect === 'postgres'
|
|
1599
|
-
? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\n`
|
|
1615
|
+
? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\nThe example URL names the database \`${dbName}\`. Create that database or edit the URL.\n`
|
|
1600
1616
|
: '';
|
|
1601
1617
|
// Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
|
|
1602
1618
|
// `webjs` npm name is owned by an unrelated package; `npx webjs
|
package/lib/doctor.js
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
import { existsSync, statSync, readdirSync, readFileSync } from 'node:fs';
|
|
54
54
|
import { readFile } from 'node:fs/promises';
|
|
55
|
-
import { join, relative } from 'node:path';
|
|
55
|
+
import { dirname, join, relative } from 'node:path';
|
|
56
56
|
import { createRequire } from 'node:module';
|
|
57
57
|
import { checkNodeInline } from './node-preflight.js';
|
|
58
58
|
|
|
@@ -837,13 +837,87 @@ async function checkImportmapCoherence(appDir, opts) {
|
|
|
837
837
|
};
|
|
838
838
|
}
|
|
839
839
|
|
|
840
|
+
/**
|
|
841
|
+
* Read a dependency's INSTALLED version as resolved FROM `appDir`, or null when
|
|
842
|
+
* it does not resolve there at all.
|
|
843
|
+
*
|
|
844
|
+
* Node's own resolver is the ground truth here, not a directory read. The check
|
|
845
|
+
* this serves asks "would this app resolve this dependency at runtime, and at
|
|
846
|
+
* what version", and Node's resolution algorithm IS that question's definition,
|
|
847
|
+
* so anything re-implementing it can only be a worse approximation. Asking Node
|
|
848
|
+
* handles workspace hoisting (the bug this fixes: under npm workspaces the
|
|
849
|
+
* `@webjsdev/*` deps hoist to the ROOT node_modules, so an app subdirectory has
|
|
850
|
+
* no local copy and a per-app `node_modules/<dep>/package.json` read reported
|
|
851
|
+
* every declared dep missing on a healthy install), symlinked workspace links,
|
|
852
|
+
* nested non-hoisted trees, and `package.json` `imports`, for free and for ever.
|
|
853
|
+
*
|
|
854
|
+
* The direct `<dep>/package.json` resolve is attempted FIRST because a package
|
|
855
|
+
* may declare no main entry at all: `@webjsdev/cli` is bin-only (no `main`, no
|
|
856
|
+
* `exports`), so `require.resolve('@webjsdev/cli')` throws MODULE_NOT_FOUND.
|
|
857
|
+
* The ERR_PACKAGE_PATH_NOT_EXPORTED fallback exists because a package may lock
|
|
858
|
+
* its manifest out of its `exports` map: `@webjsdev/server` exports only `.`,
|
|
859
|
+
* `./check`, `./testing`, and `./webjs-config.schema.json`, so the direct
|
|
860
|
+
* manifest resolve is refused and the main entry plus a bounded walk up to the
|
|
861
|
+
* package root is the way in. Neither strategy alone resolves all four
|
|
862
|
+
* `@webjsdev/*` packages; both halves are required.
|
|
863
|
+
*
|
|
864
|
+
* Local rather than `getPackageVersion` from `@webjsdev/server` for two reasons.
|
|
865
|
+
* Doctor must stay usable when the framework does not resolve from the app dir
|
|
866
|
+
* at all, which is the #954 fresh-worktree case doctor exists to diagnose, so
|
|
867
|
+
* this check cannot import the server (the same argument `frameworkResolves`
|
|
868
|
+
* below already follows). And `getPackageVersion` resolves the main entry only,
|
|
869
|
+
* so it returns null for a bin-only package, which would leave `@webjsdev/cli`
|
|
870
|
+
* reported missing: the same false positive with more machinery.
|
|
871
|
+
*
|
|
872
|
+
* Pinned by the workspace, bin-only, and exports-locked fixtures in
|
|
873
|
+
* `test/cli/doctor.test.mjs`.
|
|
874
|
+
* @param {string} dep package name, e.g. `@webjsdev/server`
|
|
875
|
+
* @param {string} appDir directory to anchor resolution at
|
|
876
|
+
* @returns {Promise<string|null>} the installed version, or null when unresolvable
|
|
877
|
+
*/
|
|
878
|
+
async function readInstalledVersion(dep, appDir) {
|
|
879
|
+
// The base file need not exist; createRequire only uses it to anchor the
|
|
880
|
+
// node_modules lookup at appDir.
|
|
881
|
+
const require = createRequire(join(appDir, '__webjs_resolve_probe__.js'));
|
|
882
|
+
let manifestPath = null;
|
|
883
|
+
try {
|
|
884
|
+
manifestPath = require.resolve(dep + '/package.json');
|
|
885
|
+
} catch (err) {
|
|
886
|
+
if (err?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') return null;
|
|
887
|
+
let entry;
|
|
888
|
+
try {
|
|
889
|
+
entry = require.resolve(dep);
|
|
890
|
+
} catch {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
let dir = dirname(entry);
|
|
894
|
+
for (let i = 0; i < 12; i++) {
|
|
895
|
+
const candidate = join(dir, 'package.json');
|
|
896
|
+
if (existsSync(candidate)) {
|
|
897
|
+
manifestPath = candidate;
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
const parent = dirname(dir);
|
|
901
|
+
if (parent === dir) break;
|
|
902
|
+
dir = parent;
|
|
903
|
+
}
|
|
904
|
+
if (!manifestPath) return null;
|
|
905
|
+
}
|
|
906
|
+
try {
|
|
907
|
+
return JSON.parse(await readFile(manifestPath, 'utf8')).version || null;
|
|
908
|
+
} catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
840
913
|
/**
|
|
841
914
|
* CHECK 5, @webjsdev/* version coherence. WARN-level only (a version drift is
|
|
842
915
|
* not a crash). Reads the app package.json `@webjsdev/*` ranges across
|
|
843
|
-
* dependencies + devDependencies, then for each
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
916
|
+
* dependencies + devDependencies, then for each resolves the INSTALLED version
|
|
917
|
+
* through Node's own resolver anchored at the app dir (see
|
|
918
|
+
* `readInstalledVersion`, which is why a workspace-hoisted install resolves)
|
|
919
|
+
* and checks it satisfies the declared range. PASS when every @webjsdev dep is
|
|
920
|
+
* present + satisfied; WARN on a missing install or a range drift.
|
|
847
921
|
* @param {string} appDir
|
|
848
922
|
* @returns {Promise<DoctorResult>}
|
|
849
923
|
*/
|
|
@@ -881,15 +955,8 @@ async function checkWebjsVersions(appDir) {
|
|
|
881
955
|
const missing = [];
|
|
882
956
|
const drift = [];
|
|
883
957
|
for (const dep of webjsDeps) {
|
|
884
|
-
const
|
|
885
|
-
if (!
|
|
886
|
-
missing.push(dep);
|
|
887
|
-
continue;
|
|
888
|
-
}
|
|
889
|
-
let installedVersion = '';
|
|
890
|
-
try {
|
|
891
|
-
installedVersion = JSON.parse(await readFile(installedPkg, 'utf8')).version || '';
|
|
892
|
-
} catch {
|
|
958
|
+
const installedVersion = await readInstalledVersion(dep, appDir);
|
|
959
|
+
if (!installedVersion) {
|
|
893
960
|
missing.push(dep);
|
|
894
961
|
continue;
|
|
895
962
|
}
|
|
@@ -1329,11 +1396,19 @@ function unmarkedStylesheetHref(tag, basePath = '') {
|
|
|
1329
1396
|
* Ported rather than imported because that helper is not on `@webjsdev/server`'s
|
|
1330
1397
|
* public surface, and because doctor must stay usable when the framework does
|
|
1331
1398
|
* not resolve from the app dir at all (the #954 fresh-worktree case this same
|
|
1332
|
-
* command exists to diagnose).
|
|
1399
|
+
* command exists to diagnose). The port is intentional and stays. What makes it
|
|
1400
|
+
* safe is that the drift is tested rather than trusted.
|
|
1401
|
+
*
|
|
1402
|
+
* `test/cli/base-path-parity.test.mjs` feeds one input table through BOTH this
|
|
1403
|
+
* function and the server's `readBasePath`, asserting they agree with each other
|
|
1404
|
+
* and with the expected value. Change either side without the other and it reds.
|
|
1405
|
+
* So edit this body only alongside `packages/server/src/base-path.js`, and run
|
|
1406
|
+
* that test. (`test/cli/doctor.test.mjs` covers the check that consumes this,
|
|
1407
|
+
* not the normalization forms themselves.)
|
|
1333
1408
|
* @param {string} appDir
|
|
1334
1409
|
* @returns {Promise<string>}
|
|
1335
1410
|
*/
|
|
1336
|
-
async function readAppBasePath(appDir) {
|
|
1411
|
+
export async function readAppBasePath(appDir) {
|
|
1337
1412
|
let raw;
|
|
1338
1413
|
try {
|
|
1339
1414
|
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files the canonical `gallery/` app owns only because it is a RUNNABLE
|
|
3
|
+
* WebJs app, and which the scaffold (`webjs create`) generates itself.
|
|
4
|
+
*
|
|
5
|
+
* `gallery/` is both the single source of the scaffold's feature gallery AND a
|
|
6
|
+
* live app deployed on its own, so it needs a root layout, a home page, a theme
|
|
7
|
+
* toggle, and the `cn()` helper. The scaffold writes all four itself, with
|
|
8
|
+
* things the gallery's copies cannot carry: the app's `displayName`, the
|
|
9
|
+
* `cspNonce()` wiring, `LayoutProps` typing, the `metadata.icons` favicon, and
|
|
10
|
+
* a `cn.ts` read verbatim from the `@webjsdev/ui` registry so `webjs ui add`
|
|
11
|
+
* stays in lockstep with the kit.
|
|
12
|
+
*
|
|
13
|
+
* So they are the ONE part of `gallery/` that is not scaffold payload. They are
|
|
14
|
+
* dropped from the published bundle (`scripts/sync-scaffold-gallery.mjs`) and
|
|
15
|
+
* skipped by `copyGallery()` so monorepo-dev and installed-npm scaffolding emit
|
|
16
|
+
* byte-identical apps. Paths are POSIX-relative to the gallery root.
|
|
17
|
+
*
|
|
18
|
+
* @type {readonly string[]}
|
|
19
|
+
*/
|
|
20
|
+
export const GALLERY_APP_SHELL_FILES = Object.freeze([
|
|
21
|
+
'app/layout.ts',
|
|
22
|
+
'app/page.ts',
|
|
23
|
+
'components/theme-toggle.ts',
|
|
24
|
+
'lib/utils/cn.ts',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when `relPath` (POSIX-relative to the gallery root) is a gallery-only
|
|
29
|
+
* app-shell file rather than scaffold payload.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} relPath
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isGalleryAppShellFile(relPath) {
|
|
35
|
+
return GALLERY_APP_SHELL_FILES.includes(relPath.split('\\').join('/'));
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.54",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The CLI for WebJs, a full-stack JavaScript framework built on web components with server-side rendering and no build step. Runs the dev and production servers, scaffolds apps, validates conventions, and drives the database. Node 24+ or Bun.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"webjs": "bin/webjs.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"prepack": "node ../../scripts/sync-scaffold-skill.mjs",
|
|
11
|
-
"postpack": "node ../../scripts/sync-scaffold-skill.mjs --clean"
|
|
10
|
+
"prepack": "node ../../scripts/sync-scaffold-skill.mjs && node ../../scripts/sync-scaffold-gallery.mjs",
|
|
11
|
+
"postpack": "node ../../scripts/sync-scaffold-skill.mjs --clean && node ../../scripts/sync-scaffold-gallery.mjs --clean"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
@@ -38,7 +38,7 @@ Classify the task first, then load the smallest useful reference set. Each refer
|
|
|
38
38
|
| Task involves... | Start with |
|
|
39
39
|
| --------------------------------------------------------------------------- | --------------------------------------------- |
|
|
40
40
|
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | `references/routing-and-pages.md` |
|
|
41
|
-
| Writing components: reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
41
|
+
| Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | `references/components.md` |
|
|
42
42
|
| Why a component's JS was or was not downloaded, `webjs elision`, `static interactive = true` | `references/components.md` |
|
|
43
43
|
| Server actions, mutations, queries, validation, the `ActionResult` envelope | `references/data-and-actions.md` |
|
|
44
44
|
| Sessions, login flows, route protection, `forbidden()` / `unauthorized()` | `references/auth-and-sessions.md` |
|
|
@@ -238,8 +238,12 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
|
|
|
238
238
|
- Writing a bare `<form method="post">` and expecting a page `action` export to catch it. There is no such export; bind the action with `action=${fn}` or the submission is a 405.
|
|
239
239
|
- Putting a submitter's `formaction=${fn}` on anything that is not a submit control, or on a button carrying its own `name` / `value`. The identity IS the button's name/value pair, so both halves are spoken for.
|
|
240
240
|
- Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.
|
|
241
|
-
- Binding an action whose file declares `export const method = 'GET'`.
|
|
241
|
+
- Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`).
|
|
242
|
+
- Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.
|
|
243
|
+
- Writing `method="get"` on a bound `<form action=${fn}>`. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.
|
|
242
244
|
- Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there.
|
|
243
245
|
- A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`.
|
|
244
246
|
- A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
|
|
245
247
|
- Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
|
|
248
|
+
- Driving a component's markup from a delegated `document` listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.
|
|
249
|
+
- Parking one component's UI state on `<body>` or `<html>`. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in `disconnectedCallback`.
|
|
@@ -145,7 +145,7 @@ setFileStore(diskStore({ dir: '/var/data/uploads', baseUrl: '/files' }));
|
|
|
145
145
|
|
|
146
146
|
## The `"webjs"` config block (package.json)
|
|
147
147
|
|
|
148
|
-
All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot
|
|
148
|
+
All keys are optional, and a malformed entry in a key the SERVER reads is dropped at boot rather than crashing the pipeline, some readers warning as they drop it. `headers` and `redirects` warn on every drop. Both warn for the key itself when it is present but not an array, and for each rule or entry inside it; `headers` has a third level and warns per header directive too. An absent key is the default and says nothing. An UNKNOWN top-level key no longer passes in silence either (#1300): the block is validated against the published JSON Schema once per boot, in dev and in prod alike, and one aggregated warning names what was ignored. So a `"redirect"` typed for `"redirects"` now says so in the server output instead of silently leaving the feature at its default. It never fails the boot. Know its exact reach, since what it passes over looks like what it catches. It reports any unknown TOP-LEVEL key, whatever it is called, and it checks the VALUE of 9 of the 17 known keys, being the one `enum` and the `boolean` / `integer` leaves. It does not descend into a nested object, so a misspelling inside `dev` or `start` is not reported, and it does not type-check the other 8 (`headers`, `redirects`, `basePath`, `allowedOrigins`, `csp`, `dev`, `start`, `doctor`), whose schemas are the free-form ones a blunt check would start refusing working configs over. Whether anything else notices a value this check passes over is up to that value's own reader, and it varies by reader. `doctor.gate` varies furthest, since the boot check never descends into any key and the `webjs doctor` CLI, not the server, is what reads this one, rejecting a bad entry outright (see the doctor severity gate below): a gate whose typo was quietly ignored would leave CI un-gated while looking gated, which is the one thing that mechanism cannot afford.
|
|
149
149
|
|
|
150
150
|
### Security headers
|
|
151
151
|
|