@webjsdev/cli 0.10.9 → 0.10.11
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/bin/webjs.js +6 -4
- package/lib/create.js +25 -2
- package/package.json +1 -1
- package/templates/.claude/hooks/require-tests-with-src.sh +35 -15
- package/templates/.dockerignore +6 -4
- package/templates/AGENTS.md +171 -23
- package/templates/CONVENTIONS.md +31 -9
- package/templates/public/offline.html +34 -0
- package/templates/public/sw.js +106 -0
package/bin/webjs.js
CHANGED
|
@@ -255,10 +255,12 @@ async function main() {
|
|
|
255
255
|
|
|
256
256
|
if (rest.includes('--rules')) {
|
|
257
257
|
console.log('webjs check, correctness rules:');
|
|
258
|
-
console.log(' Every rule catches
|
|
259
|
-
console.log(' security leak,
|
|
260
|
-
console.log('
|
|
261
|
-
console.log('
|
|
258
|
+
console.log(' Every rule catches code that is wrong to ship: a crash, a');
|
|
259
|
+
console.log(' security leak, a build/type-strip failure, or (the one');
|
|
260
|
+
console.log(' sentinel-based rule, no-scaffold-placeholder) unreplaced');
|
|
261
|
+
console.log(' scaffold example content. They always run. Project');
|
|
262
|
+
console.log(' conventions (layout, style, process) are guidance in');
|
|
263
|
+
console.log(' CONVENTIONS.md, not rules here.\n');
|
|
262
264
|
for (const r of RULES) {
|
|
263
265
|
console.log(` ${r.name.padEnd(30)} ${r.description}`);
|
|
264
266
|
}
|
package/lib/create.js
CHANGED
|
@@ -636,6 +636,15 @@ export type ActionResult<T> =
|
|
|
636
636
|
if (existsSync(tailwindSrc)) {
|
|
637
637
|
await cp(tailwindSrc, join(publicDir, 'tailwind-browser.js'));
|
|
638
638
|
}
|
|
639
|
+
// Progressive-enhancement service worker (#271): ship the opt-in offline
|
|
640
|
+
// primitive (the worker + its offline fallback) into the UI scaffolds
|
|
641
|
+
// (full-stack / saas; this block is api-excluded since api has no UI).
|
|
642
|
+
// Dormant until the app registers it (see agent-docs/service-worker.md);
|
|
643
|
+
// it never changes the JS-disabled baseline.
|
|
644
|
+
for (const swFile of ['sw.js', 'offline.html']) {
|
|
645
|
+
const swSrc = join(TEMPLATES, 'public', swFile);
|
|
646
|
+
if (existsSync(swSrc)) await cp(swSrc, join(publicDir, swFile));
|
|
647
|
+
}
|
|
639
648
|
|
|
640
649
|
const utilsDir = join(appDir, 'lib', 'utils');
|
|
641
650
|
await mkdir(utilsDir, { recursive: true });
|
|
@@ -676,7 +685,8 @@ export type ActionResult<T> =
|
|
|
676
685
|
.replace(/`/g, '\\`')
|
|
677
686
|
.replace(/\$\{/g, '\\${');
|
|
678
687
|
|
|
679
|
-
await writeFile(join(appDir, 'app', 'layout.ts'),
|
|
688
|
+
await writeFile(join(appDir, 'app', 'layout.ts'), `// webjs-scaffold-placeholder. This is the example app chrome (brand, nav, content-width container). Adapt it to your app, then delete this line. webjs check fails while the marker remains.
|
|
689
|
+
import { html, cspNonce } from '@webjsdev/core';
|
|
680
690
|
import '@webjsdev/core/client-router';
|
|
681
691
|
import '../components/theme-toggle.ts';
|
|
682
692
|
// Webjs UI components are tiered:
|
|
@@ -837,11 +847,19 @@ ${SHADCN_THEME}
|
|
|
837
847
|
<span>${name}</span>
|
|
838
848
|
</a>
|
|
839
849
|
<nav class="flex gap-4 items-center">
|
|
850
|
+
<!-- Example nav. Replace with the real navigation for your app. -->
|
|
840
851
|
\${navLink('/', 'Home')}
|
|
841
852
|
<theme-toggle></theme-toggle>
|
|
842
853
|
</nav>
|
|
843
854
|
</header>
|
|
844
855
|
|
|
856
|
+
<!--
|
|
857
|
+
Content shell. The max-w-[760px] cap is a comfortable READING width,
|
|
858
|
+
right for prose, forms, and marketing. For a full-bleed app, dashboard,
|
|
859
|
+
or board, REPLACE it: widen the cap (for example max-w-[1400px]) or
|
|
860
|
+
drop the cap and mx-auto for an edge-to-edge layout. A wide layout left
|
|
861
|
+
inside the 760px reading column overflows into a horizontal scrollbar.
|
|
862
|
+
-->
|
|
845
863
|
<main class="block max-w-[760px] mx-auto px-4 sm:px-6 pt-[72px] pb-12 min-h-screen">
|
|
846
864
|
\${children}
|
|
847
865
|
</main>
|
|
@@ -849,7 +867,8 @@ ${SHADCN_THEME}
|
|
|
849
867
|
}
|
|
850
868
|
`);
|
|
851
869
|
|
|
852
|
-
await writeFile(join(appDir, 'app', 'page.ts'),
|
|
870
|
+
await writeFile(join(appDir, 'app', 'page.ts'), `// webjs-scaffold-placeholder. This is the example homepage. Replace it with your app's real page, then delete this line. webjs check fails while the marker remains.
|
|
871
|
+
import { html } from '@webjsdev/core';
|
|
853
872
|
import { rubric, displayH1, accentLink } from '../lib/utils/ui.ts';
|
|
854
873
|
import { buttonClass } from '../components/ui/button.ts';
|
|
855
874
|
import { badgeClass } from '../components/ui/badge.ts';
|
|
@@ -1067,6 +1086,10 @@ For AI agents, read this before editing scaffolded files:
|
|
|
1067
1086
|
Replace them with the app the user actually asked for. Don't ship
|
|
1068
1087
|
the scaffold's example User model or "Hello from …" page as the
|
|
1069
1088
|
final product.
|
|
1089
|
+
• This fresh app intentionally FAILS \`webjs check\` with two
|
|
1090
|
+
no-scaffold-placeholder violations (app/page.ts, app/layout.ts).
|
|
1091
|
+
That is the signal to replace the example content. Delete each
|
|
1092
|
+
marker comment line as you do, and the check goes green.
|
|
1070
1093
|
• Use Prisma + SQLite for app data. It's already wired up. Define
|
|
1071
1094
|
real models in prisma/schema.prisma and run \`webjs db migrate\`.
|
|
1072
1095
|
NEVER store app data in JSON files, in-memory arrays, or
|
package/package.json
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
|
-
# PreToolUse hook (scaffolded by `webjs create`):
|
|
3
|
+
# PreToolUse hook (scaffolded by `webjs create`): WARN on a `git commit`
|
|
4
4
|
# that adds or changes application code without any accompanying test.
|
|
5
5
|
#
|
|
6
|
-
# webjs is AI-first
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# webjs is AI-first, and "every change ships with a test" is the right
|
|
7
|
+
# default. But it is a CONVENTION, not a correctness check: a sensible
|
|
8
|
+
# app can legitimately want a test-less commit (a spike, a vendored
|
|
9
|
+
# file, a pure refactor). The convention-vs-check principle in this
|
|
10
|
+
# app's AGENTS.md and CONVENTIONS.md says guidance like this WARNS, it
|
|
11
|
+
# does not hard-block by default. So this hook surfaces a loud reminder
|
|
12
|
+
# and lets the commit proceed.
|
|
9
13
|
#
|
|
10
14
|
# What a hook CANNOT do: judge WHICH test layer a change needs (a unit
|
|
11
|
-
# test vs a browser/e2e test is a judgement call). So it
|
|
12
|
-
# floor (some real test
|
|
13
|
-
# browser/e2e coverage for interactive surfaces.
|
|
14
|
-
#
|
|
15
|
+
# test vs a browser/e2e test is a judgement call). So it nudges toward
|
|
16
|
+
# the floor (some real test should accompany app code) and reminds you
|
|
17
|
+
# to add browser/e2e coverage for interactive surfaces. The actual test
|
|
18
|
+
# suite runs in CI (.github/workflows/ci.yml), which is the real gate.
|
|
15
19
|
#
|
|
16
20
|
# Scope: fires only on `git commit`. Inspects the STAGED diff.
|
|
17
21
|
#
|
|
18
|
-
#
|
|
19
|
-
# components/, lib/) but stages no test (test/** or *.test.* / *.spec.*)
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
+
# Behavior when the staged diff changes app code (app/, modules/,
|
|
23
|
+
# components/, lib/) but stages no test (test/** or *.test.* / *.spec.*):
|
|
24
|
+
# - Default: WARN via additionalContext, then allow the commit (exit 0).
|
|
25
|
+
# - WEBJS_TEST_GATE=block: restore the old hard floor (print BLOCKED,
|
|
26
|
+
# exit 2), for a project that wants the strict gate. Set it in
|
|
27
|
+
# .claude/settings.json env, your shell, or CI.
|
|
28
|
+
# - WEBJS_NO_TEST_GATE=1: skip entirely (no warn, no block), for a
|
|
29
|
+
# genuine non-code commit (docs, config).
|
|
22
30
|
#
|
|
23
31
|
# Bypass (humans, emergencies): git commit --no-verify.
|
|
24
32
|
|
|
@@ -52,7 +60,9 @@ test_staged=$(printf '%s\n' "$staged" \
|
|
|
52
60
|
| grep -E '(^|/)test/|\.test\.[mc]?[jt]sx?$|\.spec\.[mc]?[jt]sx?$' || true)
|
|
53
61
|
|
|
54
62
|
if [ -z "$test_staged" ]; then
|
|
55
|
-
|
|
63
|
+
# Hard-mode opt-in: restore the old block when the project asks for it.
|
|
64
|
+
if [ "${WEBJS_TEST_GATE:-}" = "block" ] || [ "${WEBJS_TEST_GATE:-}" = "hard" ]; then
|
|
65
|
+
cat >&2 <<'EOF'
|
|
56
66
|
BLOCKED: this commit changes app code but stages no test.
|
|
57
67
|
|
|
58
68
|
You staged application code (app/, modules/, components/, lib/) with no
|
|
@@ -66,11 +76,21 @@ Pick the layer the change needs (a unit test is not always enough):
|
|
|
66
76
|
real behaviour in a browser, not just the function in isolation.
|
|
67
77
|
|
|
68
78
|
See `webjs test` and the testing guide. Genuine non-code commit (docs,
|
|
69
|
-
config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1.
|
|
79
|
+
config) that needs no test? Re-run with WEBJS_NO_TEST_GATE=1. Hard mode is
|
|
80
|
+
on because WEBJS_TEST_GATE=block is set; unset it to fall back to a warning.
|
|
70
81
|
|
|
71
82
|
Hook: .claude/hooks/require-tests-with-src.sh
|
|
72
83
|
EOF
|
|
73
|
-
|
|
84
|
+
exit 2
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Default: warn loudly via additionalContext, then allow the commit.
|
|
88
|
+
# A missing test for app code subsumes the interactive-component
|
|
89
|
+
# reminder, so emit this warning alone and skip that reminder below.
|
|
90
|
+
jq -n --arg ctx "Heads up: this commit stages app code (app/, modules/, components/, lib/) with no test. Every change should ship with a test (it is a convention, not a hard gate). Pick the layer the change needs: a unit test for logic/actions/queries/utils, and a browser or e2e test for a component, hydration, the client router, or a server action called from the client. The suite runs in CI regardless. To enforce a hard block locally, set WEBJS_TEST_GATE=block. To silence this for a genuine non-code commit, set WEBJS_NO_TEST_GATE=1." '{
|
|
91
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: $ctx }
|
|
92
|
+
}'
|
|
93
|
+
exit 0
|
|
74
94
|
fi
|
|
75
95
|
|
|
76
96
|
# Reminder for interactive surfaces: a unit test alone rarely covers them.
|
package/templates/.dockerignore
CHANGED
|
@@ -6,10 +6,12 @@ node_modules
|
|
|
6
6
|
# `.webjs/` is ignored EXCEPT for `.webjs/vendor/`, which holds the committed
|
|
7
7
|
# importmap manifest (and optionally downloaded bundle bytes) the server needs
|
|
8
8
|
# at boot without reaching api.jspm.io. DO NOT collapse to `**/.webjs`: parent
|
|
9
|
-
# exclusion blocks child negations and the vendor files would never ship.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
# exclusion blocks child negations and the vendor files would never ship. The
|
|
10
|
+
# `**/` prefix matches `.webjs/` at any depth so a nested build context does
|
|
11
|
+
# not ship the per-machine cache, mirroring the `.gitignore` pattern.
|
|
12
|
+
**/.webjs/*
|
|
13
|
+
!**/.webjs/vendor/
|
|
14
|
+
!**/.webjs/vendor/**
|
|
13
15
|
|
|
14
16
|
dist
|
|
15
17
|
build
|
package/templates/AGENTS.md
CHANGED
|
@@ -14,7 +14,19 @@ now (`app/page.ts` printing "Hello from {{APP_NAME}}", the example `User`
|
|
|
14
14
|
model in `prisma/schema.prisma`, the `theme-toggle` component, the
|
|
15
15
|
example users module in api/saas templates) are **starting-point
|
|
16
16
|
references, not the final product**. Your job is to replace them with
|
|
17
|
-
the app the user actually asked for.
|
|
17
|
+
the app the user actually asked for. That includes adapting
|
|
18
|
+
`app/layout.ts`, not just the page. Set the real brand, replace the
|
|
19
|
+
example `Home` nav, and pick a content-width container that fits. The
|
|
20
|
+
default `<main class="max-w-[760px]">` is a reading column for prose and
|
|
21
|
+
forms, so for a full-bleed app, dashboard, or board, widen the cap or
|
|
22
|
+
remove it (keep the theme tokens). A wide layout left in the 760px
|
|
23
|
+
reading column overflows into a horizontal scrollbar. This is ENFORCED:
|
|
24
|
+
the example `app/page.ts` and `app/layout.ts` carry a
|
|
25
|
+
`webjs-scaffold-placeholder` marker comment, and `webjs check` fails
|
|
26
|
+
while any marker remains, so this freshly scaffolded app fails the check
|
|
27
|
+
until you replace the example content (or deliberately keep it) and
|
|
28
|
+
delete the marker line. The delivered app must contain only what the
|
|
29
|
+
user asked for, never leftover scaffold code.
|
|
18
30
|
|
|
19
31
|
**Non-negotiables for every webjs app:**
|
|
20
32
|
|
|
@@ -449,15 +461,18 @@ URLs or transitive deps drift. Pin is a deliberate developer action,
|
|
|
449
461
|
like `npm install` itself.
|
|
450
462
|
|
|
451
463
|
**Do NOT modify the `.webjs/` lines in `.gitignore` / `.dockerignore`.**
|
|
452
|
-
The scaffolded pattern is three lines (
|
|
453
|
-
+
|
|
454
|
-
to a single `.webjs/` excludes the parent
|
|
455
|
-
is excluded, git cannot re-include
|
|
456
|
-
negation (gitignore semantics: parent
|
|
457
|
-
negations). The breakage is invisible: `webjs
|
|
458
|
-
files, and git silently ignores them.
|
|
459
|
-
importmap.json and the server falls back to
|
|
460
|
-
every cold start. The
|
|
464
|
+
The scaffolded `.gitignore` pattern is three lines (`**/.webjs/*` +
|
|
465
|
+
`!**/.webjs/vendor/` + `!**/.webjs/vendor/**`) and is structurally
|
|
466
|
+
load-bearing. Collapsing it to a single `.webjs/` excludes the parent
|
|
467
|
+
directory; once the parent is excluded, git cannot re-include
|
|
468
|
+
`.webjs/vendor/` via a child negation (gitignore semantics: parent
|
|
469
|
+
exclusion blocks child negations). The breakage is invisible: `webjs
|
|
470
|
+
vendor pin` runs, writes files, and git silently ignores them.
|
|
471
|
+
Production then has no importmap.json and the server falls back to
|
|
472
|
+
calling api.jspm.io on every cold start. The `**/` prefix matters too:
|
|
473
|
+
it ignores `.webjs/` at any depth, so an app nested below its repo root
|
|
474
|
+
(a monorepo package) does not leak its generated `.webjs/routes.d.ts`
|
|
475
|
+
into `git status`. The `gitignore-vendor-not-ignored` lint rule
|
|
461
476
|
(`webjs check`) verifies the pattern with `git check-ignore` and will
|
|
462
477
|
fail CI if it regresses.
|
|
463
478
|
|
|
@@ -765,12 +780,116 @@ return html`
|
|
|
765
780
|
|
|
766
781
|
The router's `closest('webjs-frame')` detection takes precedence over
|
|
767
782
|
layout markers. Only the frame's content swaps. Use this sparingly,
|
|
768
|
-
folder-based layouts handle 99% of cases.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
783
|
+
folder-based layouts handle 99% of cases.
|
|
784
|
+
|
|
785
|
+
**External targeting + `_top` (Turbo-style).** A trigger does not have to be
|
|
786
|
+
nested in the frame it drives. An `<a>` or `<form>` (or any ancestor)
|
|
787
|
+
carrying `data-webjs-frame="<id>"` drives the frame with that id from
|
|
788
|
+
anywhere (an external sidebar/nav link, a filter form), resolved via
|
|
789
|
+
`getElementById`. The reserved token `data-webjs-frame="_top"` on a trigger
|
|
790
|
+
INSIDE a frame breaks OUT to a full-page navigation. An id that does not
|
|
791
|
+
resolve to a live `<webjs-frame>` warns once and falls back to a normal nav
|
|
792
|
+
(never throws). With JS disabled a `data-webjs-frame` link is an inert
|
|
793
|
+
attribute on a plain `<a href>`, so the click is a normal full navigation.
|
|
794
|
+
|
|
795
|
+
**Busy state.** While a frame nav is in flight the router sets the native
|
|
796
|
+
`aria-busy="true"` on the frame (cleared to `"false"` on any exit: success,
|
|
797
|
+
error, abort, or a missing frame), so AT announces it and CSS can style
|
|
798
|
+
`webjs-frame[aria-busy="true"]`. It also dispatches a bubbling
|
|
799
|
+
`webjs:frame-busy` event on the frame at start and finish (detail
|
|
800
|
+
`{ frameId, busy }`).
|
|
801
|
+
|
|
802
|
+
**Self-loading (`src` + `loading`).** A frame can fetch its OWN content:
|
|
803
|
+
`<webjs-frame id="comments" src="/posts/42/comments" loading="lazy">` self-fetches
|
|
804
|
+
that URL as a frame nav and applies the matching `<webjs-frame id>` subtree into
|
|
805
|
+
itself, through the same frame-swap path (so the busy lifecycle + navigation-error
|
|
806
|
+
recovery + frame-missing fallback all apply). `loading="eager"` (or absent)
|
|
807
|
+
fetches on connect; `loading="lazy"` fetches on viewport entry. The request sends
|
|
808
|
+
the `x-webjs-frame` header, so the SERVER returns ONLY the matched subtree (not
|
|
809
|
+
the full page), falling back to the full page when the frame is absent. A `src` is
|
|
810
|
+
JS-DEPENDENT (the browser does not natively fetch a `<webjs-frame src>`), so with
|
|
811
|
+
JS off the frame shows only the children rendered into it; use it for DEFERRED
|
|
812
|
+
content (comments, a recommendations rail) where a no-JS placeholder is fine, and
|
|
813
|
+
render content server-side into the frame when it must exist without JS.
|
|
814
|
+
|
|
815
|
+
**View Transitions + persistent elements (opt-in).** Add
|
|
816
|
+
`<meta name="view-transition" content="same-origin">` to the page head and the
|
|
817
|
+
router wraps every swap (the layout-marker swap, the `<webjs-frame>` swap, and
|
|
818
|
+
the full-body fallback) in `document.startViewTransition` for an animated
|
|
819
|
+
crossfade. OFF by default (no animation surprise); a browser without the API
|
|
820
|
+
falls back to the identical synchronous swap. To keep a live element running
|
|
821
|
+
across a navigation (a playing `<audio>` / `<video>`, a map, a stateful
|
|
822
|
+
widget), mark it `data-webjs-permanent` AND give it an `id`: the router keeps
|
|
823
|
+
the SAME DOM node by identity across the swap instead of recreating it (Turbo's
|
|
824
|
+
permanent-element behaviour). Inert with JS off.
|
|
825
|
+
|
|
826
|
+
When a frame nav's response lacks the matching `<webjs-frame id>` (e.g. an
|
|
827
|
+
auth redirect), the router fires a cancelable, bubbling `webjs:frame-missing`
|
|
828
|
+
event (detail `{ frameId, url, document }`) and leaves the frame unchanged
|
|
829
|
+
rather than silently swapping the whole page; call `preventDefault()` to take
|
|
830
|
+
over the outcome (e.g. `location.assign(e.detail.url)`).
|
|
831
|
+
|
|
832
|
+
### 5. Stream actions for surgical element-level updates
|
|
833
|
+
|
|
834
|
+
When a region swap is too coarse (append ONE comment, remove ONE row, bump a
|
|
835
|
+
count, insert a toast), a server response can declare per-element actions as
|
|
836
|
+
plain HTML, a `<webjs-stream action target>` wrapping one `<template>`:
|
|
837
|
+
|
|
838
|
+
```html
|
|
839
|
+
<webjs-stream action="append" target="comments">
|
|
840
|
+
<template><li>Nice post!</li></template>
|
|
841
|
+
</webjs-stream>
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
Actions (Turbo's set): `append` / `prepend` (last / first child of the target
|
|
845
|
+
id), `before` / `after` (sibling), `replace` (the target element), `update`
|
|
846
|
+
(its children), `remove` (delete it). The `<webjs-stream>` element self-applies
|
|
847
|
+
on connect and removes itself. ONE applier serves two paths:
|
|
848
|
+
|
|
849
|
+
- **A content-negotiated `<form>`.** The router adds `Accept:
|
|
850
|
+
text/vnd.webjs-stream.html` on a JS-driven submission, so the server returns a
|
|
851
|
+
stream only then (apply it surgically) and a JS-OFF form gets a normal
|
|
852
|
+
render/redirect. Additive and progressive-enhancement-safe.
|
|
853
|
+
- **A live channel.** `renderStream(message)` from a `connectWS` handler applies
|
|
854
|
+
a `broadcast()`ed payload, so chat / notifications reuse the same applier.
|
|
855
|
+
|
|
856
|
+
Build the payload server-side and apply it client-side:
|
|
857
|
+
|
|
858
|
+
```ts
|
|
859
|
+
// app/posts/[id]/route.ts
|
|
860
|
+
import { stream, streamResponse, acceptsStream, broadcast } from '@webjsdev/server';
|
|
861
|
+
export async function POST(req: Request, { params }) {
|
|
862
|
+
const c = await addComment(params.id, await req.formData());
|
|
863
|
+
const html = stream.append('comments', `<li>${escapeHtml(c.text)}</li>`);
|
|
864
|
+
broadcast(`post:${params.id}`, html); // fan out to other viewers
|
|
865
|
+
if (acceptsStream(req)) return streamResponse(html); // JS client: surgical
|
|
866
|
+
return Response.redirect(`/posts/${params.id}`, 303); // no-JS: normal render
|
|
867
|
+
}
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
```ts
|
|
871
|
+
// a component, for the live channel
|
|
872
|
+
import { connectWS, renderStream } from '@webjsdev/core';
|
|
873
|
+
connectWS(`/posts/${id}/feed`, { onMessage: (m) => renderStream(m) });
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
`stream.*` escapes the target id but NOT the content (server-authored HTML, like
|
|
877
|
+
an `html` hole, so escape any user substring yourself). `renderStream` is
|
|
878
|
+
auto-registered by the client router.
|
|
879
|
+
|
|
880
|
+
**Failed navigations recover in place, never a destructive full reload.** A
|
|
881
|
+
successful swap and an HTML error body of any status (e.g. a `422` re-rendered
|
|
882
|
+
form) both apply in place. For the remaining failure cases (a non-HTML error
|
|
883
|
+
response like a `500` with a JSON body, or a transport/parse failure) the
|
|
884
|
+
router fires a cancelable, bubbling `webjs:navigation-error` event on
|
|
885
|
+
`document` (detail `{ url, status, error }`, where `status` is the HTTP status
|
|
886
|
+
or `null`, and `error` is the `Error` or `null`). `preventDefault()` hands
|
|
887
|
+
recovery to you and leaves the page exactly as it is (shell, scroll, focus,
|
|
888
|
+
client state preserved); otherwise the router renders a minimal in-place
|
|
889
|
+
`<div role="alert">` into the deepest layout children slot (outer chrome
|
|
890
|
+
preserved), only hard-loading as a last resort when there is no shared layout
|
|
891
|
+
marker. An AbortError (a superseding nav) is a normal supersede and never fires
|
|
892
|
+
the event.
|
|
774
893
|
|
|
775
894
|
### 5. `loading.ts` for per-segment skeletons
|
|
776
895
|
|
|
@@ -800,6 +919,32 @@ default export, scoped to that boundary (outer layouts stay alive).
|
|
|
800
919
|
|
|
801
920
|
Full reference: see the [Client Router docs](https://docs.webjs.dev/docs/client-router) and the framework AGENTS.md "Client navigation" section.
|
|
802
921
|
|
|
922
|
+
## Offline support (opt-in service worker)
|
|
923
|
+
|
|
924
|
+
The UI scaffolds (full-stack and saas) ship a progressive-enhancement service
|
|
925
|
+
worker at `public/sw.js` plus a `public/offline.html` fallback (the api template
|
|
926
|
+
has no UI, so it omits them). They are **dormant until you register them**, so
|
|
927
|
+
the JS-disabled baseline is unchanged. To enable offline support, add the opt-in
|
|
928
|
+
registration snippet to the root layout `<head>`:
|
|
929
|
+
|
|
930
|
+
```html
|
|
931
|
+
<script>
|
|
932
|
+
if ('serviceWorker' in navigator) {
|
|
933
|
+
addEventListener('load', () => {
|
|
934
|
+
const tag = document.querySelector('script[type="importmap"]');
|
|
935
|
+
const build = (tag && tag.dataset.webjsBuild) || '';
|
|
936
|
+
navigator.serviceWorker.register('/sw.js' + (build ? '?v=' + build : ''));
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
</script>
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
Navigations become network-first (fresh server HTML, with an offline fallback to
|
|
943
|
+
a cached page or `/offline.html`); same-origin assets are stale-while-revalidate.
|
|
944
|
+
The cache version ties to the deploy via the `?v=<build>` id, so a new deploy
|
|
945
|
+
evicts the old cache automatically. `sw.js` is YOUR file, so edit the strategy as
|
|
946
|
+
needed. Full reference: `agent-docs/service-worker.md`.
|
|
947
|
+
|
|
803
948
|
## Metadata (per-page)
|
|
804
949
|
|
|
805
950
|
The `metadata` export is Next.js-compatible. Common fields shown below;
|
|
@@ -963,13 +1108,16 @@ composition, so a nested shell ends up dropped by the HTML parser.
|
|
|
963
1108
|
feature surface changed, `webjs check` passing. A unit test is not
|
|
964
1109
|
always enough: a component, hydration, the client router, or a server
|
|
965
1110
|
action called from the client needs a browser test
|
|
966
|
-
(`webjs test --browser`) asserting the behaviour in a real browser.
|
|
967
|
-
commit that stages app code (`app/`, `modules/`,
|
|
968
|
-
with no test
|
|
969
|
-
`.claude/hooks/require-tests-with-src.sh
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1111
|
+
(`webjs test --browser`) asserting the behaviour in a real browser. For
|
|
1112
|
+
Claude Code, a commit that stages app code (`app/`, `modules/`,
|
|
1113
|
+
`components/`, `lib/`) with no test WARNS via
|
|
1114
|
+
`.claude/hooks/require-tests-with-src.sh` (every change should still ship
|
|
1115
|
+
with a test, but that is a convention, not a hard gate). A project that
|
|
1116
|
+
wants the strict floor opts into a hard block by setting
|
|
1117
|
+
`WEBJS_TEST_GATE=block` (in `.claude/settings.json` env, your shell, or
|
|
1118
|
+
CI). The real enforcement is CI: the test suite runs in
|
|
1119
|
+
`.github/workflows/ci.yml`, not in the pre-commit hook, so `git commit`
|
|
1120
|
+
stays fast and the gate cannot be skipped with a local `--no-verify`.
|
|
973
1121
|
3. Commit and push **per logical unit**, not at the end. A logical unit is one
|
|
974
1122
|
feature, one fix, one rename, one doc rewrite. If you have 5+ unstaged files
|
|
975
1123
|
spanning different concerns, commit the current group before continuing.
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -308,11 +308,28 @@ When the user asks the agent to build their actual app:
|
|
|
308
308
|
need a theme picker.
|
|
309
309
|
4. **Delete the example users module** (api/saas templates) if the app
|
|
310
310
|
doesn't use it.
|
|
311
|
-
5. **
|
|
311
|
+
5. **Adapt `app/layout.ts` to the app, not just the page.** Set the real
|
|
312
|
+
brand, replace the example `Home` nav with the app's navigation, and
|
|
313
|
+
pick a content-width container that fits. The default
|
|
314
|
+
`<main class="max-w-[760px]">` is a reading column for prose, forms,
|
|
315
|
+
and marketing. Widen it or drop the cap for a full-bleed app,
|
|
316
|
+
dashboard, or board, or a wide layout overflows into an unnecessary
|
|
317
|
+
horizontal scrollbar. Keep the design tokens and theme setup, those
|
|
318
|
+
are infrastructure.
|
|
319
|
+
6. **Keep:** the Prisma setup, the test config, the agent config files
|
|
312
320
|
(`AGENTS.md`, `CONVENTIONS.md`, `CLAUDE.md`, `.cursorrules`, etc.),
|
|
313
321
|
`lib/prisma.server.ts`, the directory conventions, the design tokens in
|
|
314
322
|
`app/layout.ts`. These are the infrastructure, not the example app.
|
|
315
323
|
|
|
324
|
+
This is enforced, not just advised. The example `app/page.ts` and
|
|
325
|
+
`app/layout.ts` carry a `webjs-scaffold-placeholder` marker comment, and
|
|
326
|
+
the `no-scaffold-placeholder` check fails while any marker remains, so a
|
|
327
|
+
freshly scaffolded app fails `webjs check` until you address each
|
|
328
|
+
placeholder. The marker is acknowledge-and-remove: replace the example
|
|
329
|
+
content, or deliberately keep it, and in either case delete the marker
|
|
330
|
+
line. So the delivered app contains only what the user asked for, never
|
|
331
|
+
leftover scaffold code.
|
|
332
|
+
|
|
316
333
|
The scaffold exists so the agent doesn't reinvent the directory layout,
|
|
317
334
|
the Prisma wiring, the test runner config, or the convention files. It
|
|
318
335
|
does NOT exist so the agent ships the example homepage.
|
|
@@ -494,14 +511,19 @@ This is also why the auth test lives at `test/auth/auth.test.ts` (the
|
|
|
494
511
|
feature-folder convention), NOT `test/unit/auth.test.ts`. Test KIND is a
|
|
495
512
|
subfolder inside a feature, never the top level.
|
|
496
513
|
|
|
497
|
-
**Every change ships with a test.**
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
514
|
+
**Every change ships with a test.** This is a convention, not a hard
|
|
515
|
+
gate, consistent with the convention-vs-check principle this file
|
|
516
|
+
states (a sensible app can legitimately want a test-less commit for a
|
|
517
|
+
spike, a vendored file, or a pure refactor). For Claude Code, a commit
|
|
518
|
+
that stages app code (`app/`, `modules/`, `components/`, `lib/`) without
|
|
519
|
+
staging a test WARNS via `.claude/hooks/require-tests-with-src.sh`, then
|
|
520
|
+
lets the commit through. A project that wants the strict floor opts into
|
|
521
|
+
a hard block by setting `WEBJS_TEST_GATE=block` (in
|
|
522
|
+
`.claude/settings.json` env, your shell, or CI). A unit test alone is
|
|
523
|
+
not enough for interactive or component code: add the browser test that
|
|
524
|
+
asserts the rendered/hydrated behaviour. The real enforcement is CI: the
|
|
525
|
+
test suite runs in `.github/workflows/ci.yml` on every PR and push to
|
|
526
|
+
main, so the gate cannot be skipped with a local `--no-verify`.
|
|
505
527
|
|
|
506
528
|
### Choosing a feature folder
|
|
507
529
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Offline</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light dark; }
|
|
9
|
+
body {
|
|
10
|
+
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
|
11
|
+
font: 16px/1.6 system-ui, sans-serif; background: #fafafa; color: #1a1a1a;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) { body { background: #0d0d10; color: #e6e6e6; } }
|
|
14
|
+
main { max-width: 28rem; padding: 2rem; text-align: center; }
|
|
15
|
+
h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
16
|
+
p { margin: 0 0 1.5rem; opacity: 0.8; }
|
|
17
|
+
.retry {
|
|
18
|
+
display: inline-block; font: inherit; padding: 0.6rem 1.2rem;
|
|
19
|
+
border-radius: 0.5rem; background: #1a1a1a; color: #fff;
|
|
20
|
+
text-decoration: none; cursor: pointer;
|
|
21
|
+
}
|
|
22
|
+
@media (prefers-color-scheme: dark) { .retry { background: #e6e6e6; color: #0d0d10; } }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<main>
|
|
27
|
+
<h1>You are offline</h1>
|
|
28
|
+
<p>This page is not available without a network connection. Pages you have already visited still work offline.</p>
|
|
29
|
+
<!-- An empty href reloads the current URL (the page the user tried to
|
|
30
|
+
reach), so retry works with NO inline JS, staying CSP-compatible. -->
|
|
31
|
+
<a class="retry" href="">Try again</a>
|
|
32
|
+
</main>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* webjs progressive-enhancement service worker (OPT-IN, #271).
|
|
3
|
+
*
|
|
4
|
+
* This adds an offline fallback and an asset cache WITHOUT changing the
|
|
5
|
+
* JavaScript-disabled baseline: with JS off no service worker registers, so
|
|
6
|
+
* pages, links, and forms behave exactly as they do today. It is registered
|
|
7
|
+
* explicitly (see the opt-in snippet in agent-docs/service-worker.md), never
|
|
8
|
+
* automatically.
|
|
9
|
+
*
|
|
10
|
+
* Strategy:
|
|
11
|
+
* - Navigations are NETWORK-FIRST: always try the network so the user sees
|
|
12
|
+
* fresh server-rendered HTML, caching each successful page (the SSR shell)
|
|
13
|
+
* so a later OFFLINE visit to a page you have seen still renders. When the
|
|
14
|
+
* network fails and nothing is cached, serve /offline.html.
|
|
15
|
+
* - Same-origin static assets (the per-file ESM modules, the framework
|
|
16
|
+
* runtime under /__webjs/core/, vendor bundles, public assets) are
|
|
17
|
+
* stale-while-revalidate, so a repeat visit works offline. In production
|
|
18
|
+
* these URLs carry a ?v=<hash> content fingerprint, so a changed file gets
|
|
19
|
+
* a new URL and the cache can never serve stale bytes.
|
|
20
|
+
*
|
|
21
|
+
* Versioning ties to the deploy. The page registers this worker as
|
|
22
|
+
* `/sw.js?v=<data-webjs-build>` (the importmap build id), so a new deploy
|
|
23
|
+
* changes the worker's own URL, the browser fetches the new worker, and its
|
|
24
|
+
* `activate` deletes every cache that is not the current version. The cache
|
|
25
|
+
* name is derived from that `?v=` below.
|
|
26
|
+
*
|
|
27
|
+
* NEVER cached: non-GET requests, cross-origin requests, the action RPC
|
|
28
|
+
* endpoint (/__webjs/action/), the dev live-reload SSE (/__webjs/events) and
|
|
29
|
+
* dev reload client (/__webjs/reload.js).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const BUILD = new URL(self.location.href).searchParams.get('v') || 'dev';
|
|
33
|
+
const CACHE = 'webjs-' + BUILD;
|
|
34
|
+
const OFFLINE_URL = '/offline.html';
|
|
35
|
+
|
|
36
|
+
self.addEventListener('install', (event) => {
|
|
37
|
+
event.waitUntil((async () => {
|
|
38
|
+
const cache = await caches.open(CACHE);
|
|
39
|
+
// Precache the offline fallback. `reload` bypasses the HTTP cache so the
|
|
40
|
+
// freshly-deployed offline page is stored, not a stale one.
|
|
41
|
+
await cache.add(new Request(OFFLINE_URL, { cache: 'reload' }));
|
|
42
|
+
await self.skipWaiting();
|
|
43
|
+
})());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
self.addEventListener('activate', (event) => {
|
|
47
|
+
event.waitUntil((async () => {
|
|
48
|
+
const keys = await caches.keys();
|
|
49
|
+
await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)));
|
|
50
|
+
await self.clients.claim();
|
|
51
|
+
})());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** Decide whether a GET request to a same-origin path is a cacheable asset. */
|
|
55
|
+
function isCacheableAsset(pathname) {
|
|
56
|
+
if (pathname.startsWith('/__webjs/action/')) return false; // RPC, never cache
|
|
57
|
+
if (pathname === '/__webjs/events' || pathname === '/__webjs/reload.js') return false; // dev
|
|
58
|
+
if (pathname.startsWith('/__webjs/core/') || pathname.startsWith('/__webjs/vendor/')) return true;
|
|
59
|
+
return /\.(?:js|mjs|ts|css|woff2?|png|jpe?g|svg|webp|gif|ico|json|map)$/.test(pathname);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
self.addEventListener('fetch', (event) => {
|
|
63
|
+
const req = event.request;
|
|
64
|
+
if (req.method !== 'GET') return; // never cache writes
|
|
65
|
+
const url = new URL(req.url);
|
|
66
|
+
if (url.origin !== self.location.origin) return; // only same-origin
|
|
67
|
+
|
|
68
|
+
// Network-first for page navigations: fresh server HTML, cache it for offline,
|
|
69
|
+
// fall back to the cached page then the offline page.
|
|
70
|
+
if (req.mode === 'navigate') {
|
|
71
|
+
event.respondWith((async () => {
|
|
72
|
+
try {
|
|
73
|
+
const fresh = await fetch(req);
|
|
74
|
+
// Cache ONLY a successful page (never a 404/500 error page, or an
|
|
75
|
+
// offline visit would serve the cached error instead of the fallback).
|
|
76
|
+
// waitUntil keeps the worker alive until the write lands (a worker can
|
|
77
|
+
// be terminated the moment respondWith settles).
|
|
78
|
+
if (fresh && fresh.ok) {
|
|
79
|
+
const copy = fresh.clone();
|
|
80
|
+
event.waitUntil(caches.open(CACHE).then((cache) => cache.put(req, copy)));
|
|
81
|
+
}
|
|
82
|
+
return fresh;
|
|
83
|
+
} catch (_err) {
|
|
84
|
+
const cache = await caches.open(CACHE);
|
|
85
|
+
const cached = await cache.match(req);
|
|
86
|
+
return cached || (await cache.match(OFFLINE_URL)) || Response.error();
|
|
87
|
+
}
|
|
88
|
+
})());
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Stale-while-revalidate for static assets.
|
|
93
|
+
if (isCacheableAsset(url.pathname)) {
|
|
94
|
+
event.respondWith((async () => {
|
|
95
|
+
const cache = await caches.open(CACHE);
|
|
96
|
+
const cached = await cache.match(req);
|
|
97
|
+
const network = fetch(req)
|
|
98
|
+
.then((res) => { if (res && res.ok) cache.put(req, res.clone()); return res; })
|
|
99
|
+
.catch(() => cached);
|
|
100
|
+
// Keep the worker alive for the background revalidation + write, which
|
|
101
|
+
// would otherwise be a floating promise lost on worker termination.
|
|
102
|
+
event.waitUntil(network.catch(() => {}));
|
|
103
|
+
return cached || network;
|
|
104
|
+
})());
|
|
105
|
+
}
|
|
106
|
+
});
|