@webjsdev/cli 0.10.52 → 0.10.53

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/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
+ }
package/lib/create.js CHANGED
@@ -18,7 +18,7 @@ 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
22
 
23
23
  /**
24
24
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -541,6 +541,11 @@ export async function scaffoldApp(name, cwd, opts = {}) {
541
541
  { name: '@webjsdev/intellisense' },
542
542
  ],
543
543
  },
544
+ // `test/**/*` is in so `webjs typecheck` reads the tests you write, the
545
+ // same way Next / Remix / Astro's generated configs do (#1299). A type
546
+ // error in a test is then a gate failure rather than something a reviewer
547
+ // has to catch by eye, and it needs no second config to remember to run.
548
+ //
544
549
  // `.webjs/routes.d.ts` is the OPT-IN generated route-types overlay (#258):
545
550
  // run `webjs types` (or `webjs dev`, which emits it) to narrow the
546
551
  // @webjsdev/core `Route` href union + per-route `params`. Listed in
@@ -552,6 +557,7 @@ export async function scaffoldApp(name, cwd, opts = {}) {
552
557
  'components/**/*',
553
558
  'modules/**/*',
554
559
  'lib/**/*',
560
+ 'test/**/*',
555
561
  'middleware.js',
556
562
  'middleware.ts',
557
563
  '.webjs/routes.d.ts',
@@ -877,9 +883,14 @@ export default defineConfig({
877
883
  `);
878
884
 
879
885
  // Env vars: append DATABASE_URL to the .env.example the template already
880
- // copied (if present), idempotently.
886
+ // copied (if present), idempotently. The database segment is the app name
887
+ // normalized to a fold-stable PostgreSQL identifier, so the emitted URL
888
+ // names the same database whether the user runs `createdb` or types
889
+ // `CREATE DATABASE`. Hoisted because the post-scaffold guidance below names
890
+ // the same value, and the two must not be able to drift.
891
+ const dbName = toDatabaseName(name);
881
892
  const dbUrlLine = dialect === 'postgres'
882
- ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + name.replace(/[^a-z0-9_]/gi, '_')
893
+ ? 'DATABASE_URL=postgres://user:password@localhost:5432/' + dbName
883
894
  : 'DATABASE_URL=file:./db/dev.db';
884
895
  const envExample = join(appDir, '.env.example');
885
896
  if (existsSync(envExample)) {
@@ -1596,7 +1607,7 @@ ThemeToggle.register('theme-toggle');
1596
1607
  // local file with no .env). Point it at a running database; `dev` / `start`
1597
1608
  // then apply pending migrations via webjs.*.before.
1598
1609
  const pgNote = dialect === 'postgres'
1599
- ? `\nPostgres: copy .env.example to .env and set DATABASE_URL to a running database before \`${pm} run dev\`.\n`
1610
+ ? `\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
1611
  : '';
1601
1612
  // Use `npx webjsdev ui ...` here, not `npx webjs ui ...`. The bare
1602
1613
  // `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 reads the INSTALLED version from
844
- * `node_modules/@webjsdev/<pkg>/package.json` and checks it satisfies the
845
- * declared range. PASS when every @webjsdev dep is present + satisfied; WARN on
846
- * a missing install or a range drift.
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 installedPkg = join(appDir, 'node_modules', dep, 'package.json');
885
- if (!existsSync(installedPkg)) {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/cli",
3
- "version": "0.10.52",
3
+ "version": "0.10.53",
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": {
@@ -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` |
@@ -243,3 +243,5 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a
243
243
  - 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
244
  - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`.
245
245
  - Interpolating into a component's `<style>` / `<script>` body. Use `static styles` or Tailwind.
246
+ - 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.
247
+ - 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`.
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## What This Covers
4
4
 
5
+ - What a component owns (markup, state, listeners, styling), and the rules that follow from it: refs over selectors, no state on `<body>`, ARIA derived in `render()`
5
6
  - Declaring reactive properties through the `WebComponent({ ... })` factory and `prop()`, with options (`reflect`, `state`, `attribute`, `default`, `converter`, `hasChanged`)
6
7
  - Signals as the default state primitive for component-local and shared state, plus `effect` / `batch`
7
8
  - The Lit-aligned lifecycle and exactly which hooks SSR runs versus skips
@@ -15,6 +16,91 @@
15
16
 
16
17
  Read this when you are authoring or reviewing a `WebComponent`. For styling a component (Tailwind, the tag-prefix rule, host sizing) see `styling.md`. For streaming a slow region or programmatic navigation see `client-router-and-streaming.md`. For Lit habits that break WebJs see `muscle-memory-gotchas.md`.
17
18
 
19
+ ## Ownership: what a component owns
20
+
21
+ A component owns four things together: its markup, its state, its listeners, and its styling. The moment one of them lives in a different file from the rest, the feature can no longer be read, tested, or deleted as a unit, and the parts drift apart. These are CONVENTIONS, judged by a reader. `webjs check` has no rule for any of them, and adding one would be wrong, because a sensible app can legitimately want a delegated listener to pass.
22
+
23
+ Most of what follows restates widely held component-model advice, ported. Lit's base class exists to hold reactive state, scoped styles, and a declarative template TOGETHER (the `lit` package README), and Lit documents a ref's value as `undefined` once the node "is no longer rendered", which is precisely the signal a selector lookup cannot give you. React frames the same ideas as lifting state to the closest common owner (react.dev, "Sharing State Between Components") and treating a ref as an escape hatch rather than the normal way to reach a node (react.dev, "Escape Hatches"). Where WebJs moves the boundary, the rule that needs it says so inline and names the mechanism.
24
+
25
+ **1. Markup and the code that drives it live in the same component.** A class selector is not an interface. `document.querySelector('.nav-toggle')` keeps compiling, keeps type-checking, and keeps passing `webjs check` after someone renames the class in the other file. It just starts returning `null` at runtime. If you are writing a selector to find markup that another file rendered, write the component that renders it instead. Where a value genuinely has to exist in two places (a layout's pre-paint inline script cannot import), the second place READS the first declaration rather than restating it.
26
+
27
+ **2. Reach your own rendered node with a ref, never with a selector.** `render()` already owns the node, so let the handle flow out of the template with `ref()` / `createRef()` from `@webjsdev/core/directives` (the directives table below carries the one-line summary). A ref is scoped to the component, so it cannot match a node some other component rendered, and it goes `undefined` when the node stops being rendered, which makes a stale handle visible instead of silent. Two reads a ref cannot express stay vanilla: `this.closest('parent-tag')` for compound-component ancestor lookup, and `assignedNodes()` for slotted content.
28
+
29
+ **3. State lives on the component, never on `<body>` or `<html>`.** The client router swaps a range INSIDE the document, so the document shell sits outside every swap. An open flag parked on `<body>` therefore survives a navigation that removed the markup it described, and it re-opens a panel over the next page or leaves scrolling locked on a page with nothing open. State held in a reactive property or an INSTANCE signal dies with the element, which is the behaviour you wanted in the first place. A module-scope signal deliberately outlives it, which is what rule 7 reaches for, so it is the right home for state genuinely shared between components and the wrong one for one element's own open flag. The carve-out is a document-level EFFECT rather than one component's state, and it comes in two shapes. A TRANSIENT effect, a scroll lock being the usual case, belongs to the element that opened it and must be released in `disconnectedCallback`. A PERSISTENT one is a document-wide SETTING, the theme being the case the framework itself ships: the scaffold's theme toggle writes `data-theme` on `<html>` and persists it, deliberately without releasing it on disconnect, because it describes the document rather than the element (`styling.md` carries that pattern). What the rule forbids is neither of those. It is one component's own open / selected / active flag parked on the shell because that was the convenient place to reach it from.
30
+
31
+ **4. ARIA state is a hole in `render()`, derived from the same state that drives behaviour.** `aria-expanded=${this.open ? 'true' : 'false'}` cannot disagree with `this.open`. A second function that re-finds the button and calls `setAttribute` can, and does, the first time someone adds a close path that forgets to call it. The same holds for `class`, `?disabled`, and any `.prop`. Two caveats ride this rule:
32
+
33
+ - Write the string explicitly for a tri-state ARIA attribute. A plain-attribute hole holding `false` serves `aria-expanded="false"` from the server and hydrates to NO attribute, because the client removes an attribute for `null` / `undefined` / `false` while the server stringifies it. `?attr=${bool}` is not a substitute, since a boolean binding omits the attribute in BOTH renderers.
34
+ - A hole commits on the next render, one microtask later. The one place a direct write is still correct is a synchronous snapshot read such as `webjs:before-cache`, where the router reads `outerHTML` in the same task. That is a documented exception, not the normal path.
35
+
36
+ **5. Behaviour needs an importable surface, or its test is a copy of it.** An inline `<script>` in a layout has no module identity, so a browser test cannot import it. It can only transcribe the listener into the test file and assert against the transcription, which then needs a SECOND test to grep the original for drift. Two tests, neither running shipping code. A component is importable, so its browser test mounts the real element and drives real events. A page or layout may still carry an inline `<script>`, but only for pre-paint boot work no module can do: reading a stored theme before first paint so the wrong palette never flashes, or measuring the header height into a CSS custom property. It must not be interactivity, and WHERE it sits decides how often it runs. The ROOT layout's markup sits OUTSIDE every swap range, so a soft navigation does not re-run its script, which is what makes it the right home for boot work and the wrong home for anything that has to respond to a later navigation. A page or a NESTED layout sits inside the swap range instead, so its script re-executes on every navigation that swaps that range (#1102), which means it has to be idempotent or guard on a flag it sets the first time. Neither shape gives you a listener that simply works, which is what a custom element is for. Under an opt-in CSP the script also needs the nonce from `cspNonce()`. `client-router-and-streaming.md` carries the full re-execution rule.
37
+
38
+ **6. Listening on `document` is legitimate. Querying `document` usually is not.** An outside-click dismissal or an Escape handler has no choice, because the event happens outside the element, so the listener has to be global. What decides whether that is ownership or a reach across the app is what the handler then READS. `this.contains(e.target)` is a decision about the component's own subtree. `document.querySelector('.other-thing')` is a decision about someone else's markup. Add the listener in `connectedCallback`, remove it in `disconnectedCallback`, and store the handler in a field so `removeEventListener` gets the same reference back (a function created inline at add time can never be removed).
39
+
40
+ **7. Talk to an ancestor with an event, and to a stranger with a module-scope signal.** A child telling its own ancestor something dispatches a `CustomEvent` with `bubbles: true`, and the ancestor binds `@my-event=${...}` in the template that rendered it. Add `composed: true` as well when the component sets `static shadow = true`, or the event stops at the shadow boundary. Two components with NO ancestor relationship share a module-scope `signal` that both import, which is typed, greppable, and owned by a module. What neither case is: a made-up event name on `document` used as a global bus, which is a global variable with extra steps. Framework events such as `webjs:navigate` ride `document` because the router has no element to dispatch from, and that is not a licence to add your own.
41
+
42
+ The shape to fix, all four pieces in different places:
43
+
44
+ ```js
45
+ // In a layout's inline script, driving markup that another file rendered.
46
+ document.addEventListener('click', (e) => {
47
+ if (e.target.closest('.nav-toggle')) document.body.toggleAttribute('data-nav-open');
48
+ });
49
+ function syncNav() {
50
+ const btn = document.querySelector('.nav-toggle'); // another file's markup
51
+ const open = document.body.hasAttribute('data-nav-open'); // outlives the markup
52
+ if (btn) btn.setAttribute('aria-expanded', String(open)); // a second home for the state
53
+ }
54
+ ```
55
+
56
+ The shape to write, one component owning all four:
57
+
58
+ ```ts
59
+ import { WebComponent, prop, html } from '@webjsdev/core';
60
+ import { createRef, ref } from '@webjsdev/core/directives';
61
+
62
+ class NavDrawer extends WebComponent({ open: prop(Boolean, { reflect: true }) }) {
63
+ private toggleRef = createRef<HTMLButtonElement>();
64
+ // Stored in a field, so removeEventListener gets the same reference back.
65
+ private onDocClick = (e: MouseEvent) => {
66
+ if (!this.contains(e.target as Node)) this.open = false; // reads its OWN subtree
67
+ };
68
+ private onDocKeydown = (e: KeyboardEvent) => {
69
+ if (e.key !== 'Escape' || !this.open) return;
70
+ this.open = false;
71
+ // The ref lands after the FIRST client commit, and `ref()` is a no-op at
72
+ // SSR, so read `.value` from a handler or `firstUpdated`, never from the
73
+ // constructor. This is the reach a selector would otherwise have done.
74
+ this.toggleRef.value?.focus();
75
+ };
76
+
77
+ constructor() { super(); this.open = false; } // SSR runs the constructor
78
+
79
+ connectedCallback() {
80
+ super.connectedCallback();
81
+ document.addEventListener('click', this.onDocClick); // listening globally is fine
82
+ document.addEventListener('keydown', this.onDocKeydown);
83
+ }
84
+ disconnectedCallback() {
85
+ super.disconnectedCallback();
86
+ document.removeEventListener('click', this.onDocClick); // the state dies with the element
87
+ document.removeEventListener('keydown', this.onDocKeydown);
88
+ }
89
+
90
+ render() {
91
+ return html`
92
+ <button ${ref(this.toggleRef)}
93
+ aria-expanded=${this.open ? 'true' : 'false'}
94
+ @click=${() => { this.open = !this.open; }}>Menu</button>
95
+ <nav ?hidden=${!this.open}><slot></slot></nav>
96
+ `;
97
+ }
98
+ }
99
+ NavDrawer.register('nav-drawer');
100
+ ```
101
+
102
+ This repo's own website is the worked example. Before commit `b80de906` the docs drawer and the header menu were exactly the first shape, and every accessibility bug their tests now pin came out of the split. `website/components/docs-drawer.ts` and `website/components/site-nav-menu.ts` are the second shape, and `website/AGENTS.md` records the app-level version of these rules under "What stays inline script in the root layout".
103
+
18
104
  ## Reactive properties: the base-class factory
19
105
 
20
106
  Reactive properties are declared by passing their shape into `WebComponent({ ... })`. The types flow automatically to `this.<prop>`, so there is NO `static properties` block and NO `declare` line (a `static properties` block throws at runtime, caught by `no-static-properties`).
@@ -48,7 +134,7 @@ The bare form is shorthand: `count: Number` means `prop(Number)`. Use `prop()` t
48
134
  | Option | Default | Meaning |
49
135
  |---|---|---|
50
136
  | `type` | `String` | Constructor feeding the default attribute converter |
51
- | `reflect` | `false` | Property changes write back to the HTML attribute (a function value removes it instead, see below) |
137
+ | `reflect` | `false` | Property changes write back to the HTML attribute (a value with no attribute representation removes it instead, see below) |
52
138
  | `state` | `false` | Internal-only. No attribute, not observed |
53
139
  | `attribute` | derived from name | The HTML attribute name the property rides |
54
140
  | `default` | none | Declarative initial value (a function runs per instance for a fresh object / array) |
@@ -59,6 +145,8 @@ For an array-typed prop pass `Array`, not `Object` (`array-prop-uses-array-type`
59
145
 
60
146
  **A `reflect: true` property holding a FUNCTION drops its attribute instead of writing one, and so does one holding an array that carries a function, unless the prop is `Object` or `Array` typed.** A function has no HTML attribute representation, and the serializations it would otherwise get are both useless and dangerous. `String(fn)` is the function's SOURCE, so a reflected `'use server'` action would ship its whole body, closure secrets included, to every visitor, and `JSON.stringify(fn)` is `undefined`, which lands in the attribute as the literal four-character string. So the reflection path treats a function like `null`, removes the attribute, and warns naming the property, the tag, and the attribute. This holds on both sides, since SSR and the client-side setter run the same path, and it holds for every property name (the leak was never specific to one called `action`). Two exceptions. A property with a custom `converter.toAttribute` runs that converter first and is left alone, because an author who writes one has taken responsibility for serializing whatever they are handed. And an `Object` or `Array` typed property CARRYING a function keeps its data, because `JSON.stringify` drops the function to `null` and omits the key, so `[1, 2, fn]` reflects as `[1,2,null]` with no source and nothing else lost. If you need a function on a component, use a plain property or a signal and do not mark it `reflect`.
61
147
 
148
+ **An `Object` or `Array` typed reflected property whose value `JSON.stringify` cannot serialize AT ALL drops its attribute the same way, and warns.** Three shapes do this: a cycle (an object or array that reaches itself, which arrives from a parent/child graph, a linked node, a memo table, or anything a library hands back with a back-reference), a `BigInt` anywhere inside the value, and an author `toJSON()` that throws. The line to keep straight is that a value which serializes WITH A GAP in it keeps its data (the carried-function case above), while one that does not serialize at all has no string to put in the attribute and so has no attribute representation, exactly like a function. The property itself is untouched and still holds the value; only the attribute goes. Before this guard the throw escaped reflection entirely, which meant a client upgrade threw before the component's first render, and an SSR render was swallowed by per-component error isolation, which shows an error box in dev and renders the component EMPTY on a page that still returned 200 in production. To reflect something about a graph-shaped value, reflect a derived scalar (an id, a count) and keep the graph on a non-reflected property. On the read side an attribute that is PRESENT but not parseable JSON reads back as `null` rather than as the raw string, on both the SSR and the client reader. An ABSENT attribute is a different case: neither reader sees it, so the property keeps its constructor value.
149
+
62
150
  **Never use a class-field declaration OR initializer** (`count = 0`, `student: Student = {...}`, `todos!: Todo[]`). Under `useDefineForClassFields` even a type-only `todos!: Todo[]` compiles to define an own property after `super()`, which clobbers the prototype's reactive accessor and silently breaks reactivity. Only declare props in the factory and read/write them off `this`. The `reactive-props-no-class-field` rule catches this.
63
151
 
64
152
  ## Signals are the default state primitive
@@ -288,4 +288,4 @@ Context providers publish on connect via `hostConnected`, which does not run at
288
288
 
289
289
  ### Vanilla DOM instead of Lit idioms
290
290
 
291
- WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule.
291
+ WebJs components are Lit-shaped on purpose: the value is the declarative DX. Prefer a factory-declared reactive prop over `this.getAttribute`, a `signal` over a `state: true` prop for internal state, a `class=${...}` binding over `this.classList`, a `@click=${...}` binding over `this.addEventListener`, and `C.register('x')` over `customElements.define`. Vanilla DOM stays right only where the platform offers nothing declarative: `this.closest('ui-tabs')` for compound-component ancestor lookup (resolves at SSR too), slotted-content queries, global `document` / `window` listeners, and imperative `el.focus()`. This is a convention, not a lint rule. A global `document` / `window` LISTENER is one of those legitimate cases, because the event happens outside the element. A document QUERY is not: reaching for markup that another component rendered is the jQuery habit to drop, and for your OWN rendered node a `ref` replaces the selector entirely. The ownership rules at the top of `components.md` state the full test.
@@ -179,6 +179,8 @@ Three responses that are not the happy path:
179
179
 
180
180
  The submission is Origin-verified (the same `Sec-Fetch-Site` / `Origin` check the RPC endpoint applies), so a no-JS form needs no CSRF token field.
181
181
 
182
+ A submitter's own `formmethod` / `formenctype` / `formtarget` overrides the form's on PRESENCE, not on the value being non-empty, and the client router resolves them the same way (#1322). So `<button type="submit" formmethod="">` really does submit as a GET, because a present-but-empty enumerated attribute falls to its own invalid-value default rather than inheriting the form's `method="post"`.
183
+
182
184
  Refusals worth knowing: `formaction=${fn}` is supported on a `<button>` anywhere, bound form or not (#1307: the renderer gives the button its own `formmethod` and `formenctype`), and that button may not carry `name`, `value`, `form`, or a static `formaction` attribute (`<input type="submit">` is refused, because the identity needs its `value`, which is also its label). A bound form may not declare `method="get"`, and a function bound to `action=` that is not a `'use server'` export throws at render rather than producing a form that posts nowhere. See `muscle-memory-gotchas.md` for the full table.
183
185
 
184
186
  ## Error, loading, and 404 boundaries
@@ -70,7 +70,7 @@ Avoid `@apply`: it hides which utilities a class uses and creates a second sourc
70
70
 
71
71
  ### A design system for repeated PRIMITIVES: class helpers built on `@webjsdev/ui`
72
72
 
73
- An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so some prefixes are still grouped coarsely and a less common pair can collide (`bg-clip-text` against `bg-primary`, `shadow-lg` against `shadow-red-500`). When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
73
+ An `html`-fragment helper is right for a repeated CHUNK of markup (the rubric above). For a repeated UI PRIMITIVE (button, input, card, badge) that needs variants and sizes, use a class helper instead: a function that returns a Tailwind class STRING you spread onto a native element. That is exactly what `@webjsdev/ui` ships (`buttonClass({ variant, size })`, `cardClass()`, `inputClass()`, `badgeClass({ variant })`), and it is what the scaffold gallery uses in `components/ui/`. To style a ONE-OFF that a variant does not cover (a circular icon button, a pill), compose the helper and override the bespoke bits with `cn()`: `cn(buttonClass({ variant: 'secondary', size: 'none' }), 'w-9 h-9 rounded-full')`. `cn` resolves Tailwind conflicts so a later class wins, including a shorthand over the axis it subsumes (`p-0` beats an earlier `px-4 py-2`), so an override just works. Conflicts are keyed on the CSS PROPERTY wherever `cn` can tell the properties apart, rather than on the shared class prefix, so the common prefix collisions do NOT evict: `cn('border-2', 'border-primary')` keeps both (a width and a colour), `cn('flex', 'flex-1')` keeps both (a `display` and a `flex-grow`, the shape an element that is both a flex container and a flex child needs), `cn('shadow-lg', 'shadow-red-500')` keeps both (a box-shadow and its colour), `cn('bg-clip-text', 'bg-primary')` keeps both (a clip and a colour, so the gradient-text idiom survives a later background), and an arbitrary value carrying a type hint is read as the property the hint names (`cn('shadow-lg', 'shadow-[color:red]')` keeps both). It is a small hand-rolled merger, not `tailwind-merge`, so it is still coarse in two ways. A prefix outside the families it knows is not grouped at all, so both classes are emitted and the winner is left to compiled stylesheet order (`inset-shadow-sm` against `inset-shadow-red-500`, `ring-2` against `ring-red-500`). And where one prefix carries two properties it reads the value against Tailwind's DEFAULT scales, so a `@theme`-extended name it cannot know about can still be misread and evict the wrong class: a custom `--shadow-card` makes `shadow-card` a box-shadow, but `cn` sees an unfamiliar name under a prefix whose bare names are usually colours and treats it as one. When an override has to win and you are unsure, pass the one class rather than layering, or install `clsx` + `tailwind-merge` and replace the helper (its header comment shows the swap). For an icon button prefer `size: 'none'` (it states "I supply my own box" by dropping the helper's padding + radius) over layering a `p-0` on top of the default size.
74
74
 
75
75
  ```ts
76
76
  // components/ui/button.ts (npx webjsdev ui add button, themed to your app)
@@ -189,6 +189,14 @@ WEBJS_ELIDE=0 npm run test:e2e
189
189
 
190
190
  A test that passes under one and fails under the other is a wrong verdict, and `webjs elision` tells you which module and on what evidence. If the component's interactivity is genuinely invisible to static analysis, the fix is `static interactive = true` on it; see `components.md` for what that override does and does not rescue.
191
191
 
192
+ ## Type-checking your tests (`webjs typecheck`)
193
+
194
+ Your tests are inside the tsconfig `include`, so `npm run typecheck` reads them (#1299). Treat a type error in a test as a failed gate, not a review catch: the checker sees a wrong argument shape or an unannotated parameter in a test the same way it sees one in `app/`.
195
+
196
+ Write them to the same bar as app code, then. No `any`, no blanket `@ts-expect-error`. When a test needs a complete props object the framework would normally build, put a small typed helper in `test/helpers/` and import it rather than reaching for a cast; a cast in a test silences the one thing that would have told you the call was wrong.
197
+
198
+ `.js` test files follow whatever `checkJs` says. With it off they are parsed and not checked, which is the usual setup for browser tests a real browser runs.
199
+
192
200
  ## Convention validation (`webjs check`)
193
201
 
194
202
  `npm run check` is the correctness validator. Every rule catches code that is wrong to ship, a crash, a security leak, a reactive prop that silently stops re-rendering, or a type-strip failure. Run it and fix every violation before considering the change done (`npm run check -- --json` for an agent loop, `npm run check -- --rules` to list the rules). It is separate from `CONVENTIONS.md`, which carries the customizable project conventions you follow by judgment.
@@ -62,19 +62,33 @@ Prefer explicit `.ts` extensions in imports. A `.js` specifier pointing at a `.t
62
62
  "module": "NodeNext",
63
63
  "moduleResolution": "NodeNext",
64
64
  "lib": ["ES2022", "DOM", "DOM.Iterable"],
65
+ "types": ["node"],
65
66
  "strict": true,
66
67
  "noEmit": true,
67
- "checkJs": true,
68
- "allowJs": true,
69
68
  "allowImportingTsExtensions": true,
70
69
  "skipLibCheck": true,
71
70
  "erasableSyntaxOnly": true
72
- }
71
+ },
72
+ "include": [
73
+ "app/**/*",
74
+ "components/**/*",
75
+ "modules/**/*",
76
+ "lib/**/*",
77
+ "test/**/*",
78
+ "middleware.js",
79
+ "middleware.ts",
80
+ ".webjs/routes.d.ts"
81
+ ],
82
+ "exclude": ["node_modules", ".webjs/vendor", "db/migrations"]
73
83
  }
74
84
  ```
75
85
 
76
86
  `erasableSyntaxOnly: true` is the non-negotiable line. It aligns the compiler's accepted syntax with the stripper's, so violations surface as diagnostics instead of a runtime 500.
77
87
 
88
+ `test/**/*` is in the `include` on purpose (#1299), the way Next / Remix / Astro's generated configs cover the whole tree. Leave it there. A test file outside the `include` is a file `webjs typecheck` never opens, so an implicitly-`any` parameter or a wrong argument shape in a test survives until somebody reads the line, which is exactly how one reached review here. Do not add a second `tsconfig.test.json` either: a config nobody remembers to run reproduces the same gap in a new place.
89
+
90
+ Note what is absent: `checkJs`, the flag mentioned at the top of this file for a JSDoc-typed codebase (it implies `allowJs`, so it is the only one you add). Turning it on makes `tsc` read your `.js` files, which is the point, but it also pulls in browser tests written as `.js`. Those run in a real browser through web-test-runner, so their test globals are not in scope for `tsc` and each one reports a `Cannot find name 'test'`. Turn it on deliberately, and give the browser tests a `types` entry or their own exclude when you do.
91
+
78
92
  ## Full-stack type safety
79
93
 
80
94
  ### The rule: derive the type, never `unknown` or `any`
@@ -6,16 +6,18 @@
6
6
  #
7
7
  # 1. U+2014 em-dash, anywhere.
8
8
  # 2. Space-hyphen-space " - " in PROSE contexts (comment lines, markdown
9
- # lines, headings, blockquotes). Math expressions in code like
9
+ # lines, headings, blockquotes, a JSON "description" / "title" /
10
+ # "displayName" string value, and a column-0 YAML front-matter
11
+ # description: / title: / displayName: line). Math expressions in code like
10
12
  # `Math.abs(a - b)` or `arr.length - 1` are NOT flagged.
11
- # 3. Space-semicolon-space " ; " in PROSE contexts. JS / CSS statement
12
- # terminators (`;\n`) are NOT flagged.
13
+ # 3. Space-semicolon-space " ; " in the same PROSE contexts as rule 2.
14
+ # JS / CSS statement terminators (`;\n`) are NOT flagged.
13
15
  # 4. Code-shaped left-hand side immediately followed by a colon and prose:
14
16
  # - `<code>foo()</code>:` (markdown code-LHS in docs)
15
17
  # - `<my-tag>:` (custom-element tag with hyphen)
16
18
  # - Inline comment `// foo(): description`
17
19
  #
18
- # Why this exists: see AGENTS.md "Invariants", item 10. These patterns
20
+ # Why this exists: see AGENTS.md "Invariants", item 11. These patterns
19
21
  # confuse AI agents that try to parse the prose as TypeScript / shorthand-
20
22
  # method / object-literal syntax, and trip humans reading API docs.
21
23
  #
@@ -46,8 +48,13 @@ if [ -z "$new_content" ]; then
46
48
  exit 0
47
49
  fi
48
50
 
51
+ # Every match below reads from a here-string, never a pipe. `grep -q` exits on
52
+ # the first match, which closes a pipe under `printf`, and with `set -o pipefail`
53
+ # that SIGPIPE became the pipeline status, so the rule silently skipped on any
54
+ # payload past the pipe buffer (measured: 0 of 8 blocks at 128 KB).
55
+
49
56
  # --- 1. U+2014 em-dash --------------------------------------------------
50
- if printf '%s' "$new_content" | grep -q $'\xe2\x80\x94'; then
57
+ if grep -q $'\xe2\x80\x94' <<< "$new_content"; then
51
58
  cat >&2 <<'EOF'
52
59
  BLOCKED: em-dash (U+2014) detected in this tool call.
53
60
 
@@ -57,7 +64,7 @@ restructured sentence. Do NOT replace it with " - " or " ; " or a
57
64
  trailing colon on code: those are also banned. See rule 2 / 3 / 4
58
65
  below for the alternatives.
59
66
 
60
- Rule: AGENTS.md, Invariants section, item 10.
67
+ Rule: AGENTS.md, Invariants section, item 11.
61
68
  Hook: .claude/hooks/block-prose-punctuation.sh.
62
69
  EOF
63
70
  exit 2
@@ -81,25 +88,42 @@ block_pause_hyphen=0
81
88
  # `*` (markdown bold-start would have a letter after, distinguishable),
82
89
  # followed by prose with `\w+ - \w+` pattern. Specifically: catch lines
83
90
  # like `// foo - bar`, ` * foo - bar`, `* foo - bar`.
84
- if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
91
+ if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
85
92
  block_pause_hyphen=1
86
93
  fi
87
94
 
88
95
  # Markdown heading " - " pause: line starts with `#` followed by prose
89
96
  # and ` - ` pattern.
90
- if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
97
+ if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
91
98
  block_pause_hyphen=1
92
99
  fi
93
100
 
94
101
  # Markdown blockquote " - " pause: line starts with `>` followed by prose
95
102
  # and ` - ` pattern. (Single `>` blockquote, not table.)
96
- if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
103
+ if grep -qE '^>[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
97
104
  block_pause_hyphen=1
98
105
  fi
99
106
 
100
107
  # HTML / markdown <p>, <li>, <td> body " - " pause: line contains a
101
108
  # closing HTML tag from a prose context, then prose-style ` - `.
102
- if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]'; then
109
+ if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
110
+ block_pause_hyphen=1
111
+ fi
112
+
113
+ # JSON prose-value " - " pause: a string assignment whose KEY is one of the
114
+ # three prose-bearing keys this project's JSON uses. Scoping to the key is what
115
+ # keeps this off semver ranges, script commands, urls, paths and globs, every
116
+ # one of which lives under a different key. Shape, not file path: the Bash
117
+ # payload carries no file_path, so a heredoc writing a manifest is covered too.
118
+ if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
119
+ block_pause_hyphen=1
120
+ fi
121
+
122
+ # YAML front-matter " - " pause, same three keys. Anchored at column 0 with no
123
+ # leading whitespace, which is what confines it to document front matter: every
124
+ # nested YAML mapping is indented, including the workflow-input `description:`
125
+ # values in .github/workflows/release.yml.
126
+ if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)>][[:space:]]-[[:space:]][A-Za-z`(<]' <<< "$new_content"; then
103
127
  block_pause_hyphen=1
104
128
  fi
105
129
 
@@ -118,13 +142,17 @@ restructured phrasing.
118
142
  Bad: <li>Foo - bar.</li>
119
143
  Good: <li>Foo, with bar.</li>
120
144
 
145
+ Bad: "description": "A library - for things"
146
+ Good: "description": "A library for things"
147
+
121
148
  Plain hyphens are still fine in compound words (`AI-first`), CLI
122
149
  flags (`--http2`), filenames, ranges, and math expressions in code
123
150
  (`arr.length - 1`, `Math.abs(a - b)`). The hook only flags the
124
151
  ` < word > - < word > ` pause-pattern in prose contexts (comments,
125
- markdown headings, blockquotes, HTML prose tags).
152
+ markdown headings, blockquotes, HTML prose tags, and a JSON or
153
+ front-matter description / title / displayName value).
126
154
 
127
- Rule: AGENTS.md, Invariants section, item 10.
155
+ Rule: AGENTS.md, Invariants section, item 11.
128
156
  Hook: .claude/hooks/block-prose-punctuation.sh.
129
157
  EOF
130
158
  exit 2
@@ -134,19 +162,29 @@ fi
134
162
  # Same prose-context guard as #2.
135
163
  block_pause_semicolon=0
136
164
 
137
- if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
165
+ if grep -qE '^[[:space:]]*(//|\*)[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
138
166
  block_pause_semicolon=1
139
167
  fi
140
168
 
141
- if printf '%s\n' "$new_content" | grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
169
+ if grep -qE '^#{1,6}[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
142
170
  block_pause_semicolon=1
143
171
  fi
144
172
 
145
- if printf '%s\n' "$new_content" | grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
173
+ if grep -qE '^>[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
146
174
  block_pause_semicolon=1
147
175
  fi
148
176
 
149
- if printf '%s\n' "$new_content" | grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]'; then
177
+ if grep -qE '<(p|li|td|h[1-6]|strong|em|blockquote)[^>]*>[^<]*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
178
+ block_pause_semicolon=1
179
+ fi
180
+
181
+ # JSON prose-value " ; " pause, same three keys as rule 2.
182
+ if grep -qE '^[[:space:]]*"(description|title|displayName)"[[:space:]]*:[[:space:]]*".*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
183
+ block_pause_semicolon=1
184
+ fi
185
+
186
+ # YAML front-matter " ; " pause, column-0 anchored like rule 2.
187
+ if grep -qE '^(description|title|displayName):[[:space:]].*[A-Za-z`)][[:space:]];[[:space:]][A-Za-z`(]' <<< "$new_content"; then
150
188
  block_pause_semicolon=1
151
189
  fi
152
190
 
@@ -161,10 +199,14 @@ two sentences (period) or with a conjunction (", and", ", but", ", so").
161
199
  Good: // Forms work. Links work too.
162
200
  Good: // Forms work, and links work too.
163
201
 
202
+ Bad: "description": "Forms work ; links work too."
203
+ Good: "description": "Forms work. Links work too."
204
+
164
205
  Semicolons stay fine inside code (JS statement terminators, CSS
165
- declarations) since those are not flagged.
206
+ declarations) since those are not flagged. Only the space-surrounded
207
+ form is banned, so an ordinary English semicolon is untouched.
166
208
 
167
- Rule: AGENTS.md, Invariants section, item 10.
209
+ Rule: AGENTS.md, Invariants section, item 11.
168
210
  Hook: .claude/hooks/block-prose-punctuation.sh.
169
211
  EOF
170
212
  exit 2
@@ -175,7 +217,7 @@ fi
175
217
  # lowercase prose. The `)</code>:` shape is unambiguous: this is markdown,
176
218
  # not code, AND the inner code ends in `()` so the colon visually parses
177
219
  # as a return-type annotation.
178
- if printf '%s' "$new_content" | grep -qE '\)</code>:[[:space:]][a-z]'; then
220
+ if grep -qE '\)</code>:[[:space:]][a-z]' <<< "$new_content"; then
179
221
  cat >&2 <<'EOF'
180
222
  BLOCKED: code-LHS colon-then-prose detected ("<code>foo()</code>: ...").
181
223
 
@@ -186,7 +228,7 @@ parses as a TypeScript return-type annotation. Rewrite verb-led.
186
228
  Good: <code>repeat()</code> is the keyed list directive
187
229
  Good: <code>startServer()</code> creates an HTTP(S) server
188
230
 
189
- Rule: AGENTS.md, Invariants section, item 10.
231
+ Rule: AGENTS.md, Invariants section, item 11.
190
232
  Hook: .claude/hooks/block-prose-punctuation.sh.
191
233
  EOF
192
234
  exit 2
@@ -195,7 +237,7 @@ fi
195
237
  # --- 4b. Custom-element-tag <my-tag>: prose ------------------------------
196
238
  # HTML reserves hyphenated tag names for custom elements (W3C spec), so
197
239
  # `<x-y>:` is unambiguous prose, never JSX / TS / CSS.
198
- if printf '%s' "$new_content" | grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]'; then
240
+ if grep -qE '<[a-z][a-z0-9]*(-[a-z0-9]+)+([[:space:]][^>]*)?>:[[:space:]][a-z]' <<< "$new_content"; then
199
241
  cat >&2 <<'EOF'
200
242
  BLOCKED: custom-element-tag colon-then-prose detected ("<my-tag>: ...").
201
243
 
@@ -206,7 +248,7 @@ webjs bans `<my-tag>: <prose>` in comments and docs. Rewrite verb-led.
206
248
  Bad: // <ui-dialog-content>: the centered panel.
207
249
  Good: // <ui-dialog-content> is the centered panel.
208
250
 
209
- Rule: AGENTS.md, Invariants section, item 10.
251
+ Rule: AGENTS.md, Invariants section, item 11.
210
252
  Hook: .claude/hooks/block-prose-punctuation.sh.
211
253
  EOF
212
254
  exit 2
@@ -216,7 +258,7 @@ fi
216
258
  # Match comment-line prefix (`//` or leading `*`) before `\w+(...): ` and
217
259
  # lowercase prose. Avoids TS return-type annotations because those never
218
260
  # appear inside comment lines.
219
- if printf '%s\n' "$new_content" | grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]'; then
261
+ if grep -qE '^[[:space:]]*(//|\*)[[:space:]][^(]*[A-Za-z_][A-Za-z0-9_]*\([^)]*\):[[:space:]][a-z]' <<< "$new_content"; then
220
262
  cat >&2 <<'EOF'
221
263
  BLOCKED: comment-line code-LHS colon-then-prose detected ("// foo(): ...").
222
264
 
@@ -227,7 +269,7 @@ webjs bans `xyz(): <prose>` inside comments and JSDoc. Rewrite verb-led.
227
269
  Bad: // closest(): null if the click wasn't inside a frame
228
270
  Good: // closest() returns null when the click wasn't inside a frame
229
271
 
230
- Rule: AGENTS.md, Invariants section, item 10.
272
+ Rule: AGENTS.md, Invariants section, item 11.
231
273
  Hook: .claude/hooks/block-prose-punctuation.sh.
232
274
  EOF
233
275
  exit 2
@@ -31,7 +31,10 @@ export class DirectiveDemo extends WebComponent {
31
31
  // A controlled value (for `live`) and a key (for `keyed`).
32
32
  private text = signal('type here');
33
33
  private variant = signal(0);
34
- // A handle to the input node, attached by `ref` in the browser.
34
+ // A handle to the input node, attached by `ref` in the browser. A ref rather
35
+ // than a `querySelector` because the template already owns the node, so the
36
+ // handle flows out of `render()` instead of being re-found by a selector that
37
+ // could match someone else's markup (or nothing at all after a rename).
35
38
  private inputRef = createRef<HTMLInputElement>();
36
39
  // Created ONCE (not per render), so `until` keeps the resolved value across
37
40
  // re-renders instead of flashing back to the fallback each time.
@@ -6,6 +6,11 @@
6
6
  // active item from location.pathname, so the highlight follows soft-nav. SSR is
7
7
  // still correct: `render()` reads the `current` prop (the pathname the layout
8
8
  // passes) for the first paint, and the client takes over from location after.
9
+ // The document LISTENER below is the legitimate case, not a reach across the
10
+ // app: the router has no element to dispatch from, and the handler reads only
11
+ // location.pathname and writes only this module's own signal, so it queries
12
+ // nothing outside itself. Querying the document for another file's markup is
13
+ // the shape to avoid.
9
14
  import { WebComponent, prop, html, signal } from '@webjsdev/core';
10
15
  import { FEATURE_GROUPS } from '#modules/gallery/nav.ts';
11
16
 
@@ -20,6 +20,13 @@ import { createServer } from 'node:net';
20
20
  // minimal structural types keep the file typed in the meantime; swap them for
21
21
  // the real imports once puppeteer-core is in package.json. Reaching for `any`
22
22
  // here would silently un-type every call below.
23
+ //
24
+ // They also stay in force when the package IS present, which is the case a
25
+ // generated app usually hits, since @web/test-runner pulls puppeteer-core in
26
+ // transitively. The real Page / Browser are far richer than these, so letting
27
+ // them flow in would fail against the narrow shapes here for the goto return
28
+ // type and the event-handler signature. The single import below is the one
29
+ // boundary where that is resolved, and it is the only suppressed line.
23
30
  type Page = {
24
31
  // goto resolves an HTTPResponse this file never reads, and modelling that
25
32
  // type would mean re-declaring puppeteer's. Returning void is the honest
@@ -30,6 +37,10 @@ type Page = {
30
37
  removeAllListeners(event: string): void;
31
38
  };
32
39
  type Browser = { newPage(): Promise<Page>; close(): Promise<void> };
40
+ // The module's own default export, narrowed to the one call this file makes.
41
+ type Puppeteer = {
42
+ launch(opts: { executablePath?: string; headless?: boolean; args?: string[] }): Promise<Browser>;
43
+ };
33
44
 
34
45
  let browser: Browser, page: Page, serverProcess: ChildProcess, baseUrl: string;
35
46
 
@@ -46,9 +57,23 @@ function freePort(): Promise<number> {
46
57
  }
47
58
 
48
59
  before(async () => {
49
- let puppeteer;
60
+ let puppeteer: Puppeteer | undefined;
61
+ // The next line is where the optional dependency enters, and what it reports
62
+ // depends on whether puppeteer-core is installed: an unresolved specifier
63
+ // when it is absent, a type mismatch against the structural shapes above
64
+ // when it is present. Suppressing it keeps the rest of the file checked
65
+ // against those shapes either way.
66
+ //
67
+ // It is deliberately ts-ignore rather than the expect-error directive, whose
68
+ // name is spelled out here rather than written, because a comment line
69
+ // starting with that token IS a live directive to tsc even inside prose. The
70
+ // expect-error form is wrong on its own merits too: it errors when there is
71
+ // nothing to suppress, so it would break whenever the package resolves
72
+ // cleanly.
73
+ // @ts-ignore
50
74
  try { puppeteer = (await import('puppeteer-core')).default; }
51
75
  catch { console.log('# Skipping: puppeteer-core not installed'); return; }
76
+ if (!puppeteer) return;
52
77
 
53
78
  const port = await freePort();
54
79
  baseUrl = `http://localhost:${port}`;