@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 +73 -0
- package/lib/create.js +15 -4
- package/lib/doctor.js +81 -14
- package/package.json +1 -1
- package/templates/.agents/skills/webjs/SKILL.md +3 -1
- package/templates/.agents/skills/webjs/references/components.md +89 -1
- package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +1 -1
- package/templates/.agents/skills/webjs/references/routing-and-pages.md +2 -0
- package/templates/.agents/skills/webjs/references/styling.md +1 -1
- package/templates/.agents/skills/webjs/references/testing.md +8 -0
- package/templates/.agents/skills/webjs/references/typescript.md +17 -3
- package/templates/.claude/hooks/block-prose-punctuation.sh +66 -24
- package/templates/gallery/modules/directives/components/directive-demo.ts +4 -1
- package/templates/gallery/modules/gallery/components/gallery-nav.ts +5 -0
- package/templates/test/hello/e2e/hello.test.ts +26 -1
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/' +
|
|
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
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/cli",
|
|
3
|
-
"version": "0.10.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}`;
|