@webjsdev/cli 0.10.21 → 0.10.23
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 +1 -1
- package/lib/create.js +23 -2
- package/lib/doctor.js +49 -1
- package/package.json +1 -1
- package/templates/.agents/rules/workflow.md +17 -2
- package/templates/.claude/hooks/block-raw-htmlelement.sh +83 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.cursorrules +3 -1
- package/templates/.github/copilot-instructions.md +3 -2
- package/templates/AGENTS.md +6 -3
- package/templates/CONVENTIONS.md +18 -4
package/bin/webjs.js
CHANGED
|
@@ -47,7 +47,7 @@ const USAGE = `webjs commands:
|
|
|
47
47
|
webjs test [--server|--browser] Run server + browser tests
|
|
48
48
|
webjs check [--json] Run correctness checks on the app (--json emits structured violations)
|
|
49
49
|
webjs mcp Start the read-only MCP server (routes / actions / components / check)
|
|
50
|
-
webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook)
|
|
50
|
+
webjs doctor Verify project health (Node, tsconfig, env, vendor pins, importmap coherence, @webjsdev versions, git hook, page/layout elision)
|
|
51
51
|
webjs types Generate .webjs/routes.d.ts (typed Route union + per-route params)
|
|
52
52
|
webjs typecheck [tsc args...] Type-check the app with the project's tsc --noEmit (non-zero on errors)
|
|
53
53
|
webjs create <name> [--template full-stack|api|saas] [--db sqlite|postgres] [--runtime node|bun] [--no-install] Scaffold a new webjs app
|
package/lib/create.js
CHANGED
|
@@ -918,7 +918,6 @@ export type ActionResult<T> =
|
|
|
918
918
|
|
|
919
919
|
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.
|
|
920
920
|
import { html, cspNonce } from '@webjsdev/core';
|
|
921
|
-
import '@webjsdev/core/client-router';
|
|
922
921
|
import '#components/theme-toggle.ts';
|
|
923
922
|
// Webjs UI components are tiered:
|
|
924
923
|
// - Tier 1 (button, card, input, label, alert, badge, separator, etc.) are
|
|
@@ -975,6 +974,26 @@ export default function RootLayout({ children }: { children: unknown }) {
|
|
|
975
974
|
mq.addEventListener('change', apply);
|
|
976
975
|
} catch (_) {}
|
|
977
976
|
})();
|
|
977
|
+
// The header is position:fixed (not sticky): a sticky header flickers on
|
|
978
|
+
// iOS WebKit during a client-router nav. fixed leaves normal flow, so
|
|
979
|
+
// --header-h reserves its height for the content below. Measured here so
|
|
980
|
+
// it tracks the real (responsive) height; degrades fine with no JS via
|
|
981
|
+
// the :root default.
|
|
982
|
+
(function(){
|
|
983
|
+
function measure(){
|
|
984
|
+
try {
|
|
985
|
+
var hdr = document.querySelector('header');
|
|
986
|
+
if (!hdr) return;
|
|
987
|
+
var apply = function(){
|
|
988
|
+
document.documentElement.style.setProperty('--header-h', hdr.offsetHeight + 'px');
|
|
989
|
+
};
|
|
990
|
+
apply();
|
|
991
|
+
if (window.ResizeObserver) new ResizeObserver(apply).observe(hdr);
|
|
992
|
+
} catch (_) {}
|
|
993
|
+
}
|
|
994
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', measure);
|
|
995
|
+
else measure();
|
|
996
|
+
})();
|
|
978
997
|
</script>
|
|
979
998
|
<script src="/public/tailwind-browser.js"></script>
|
|
980
999
|
<!--
|
|
@@ -1064,7 +1083,9 @@ ${SHADCN_THEME}
|
|
|
1064
1083
|
}
|
|
1065
1084
|
/* Body + pseudo-elements utility classes can't reach. */
|
|
1066
1085
|
html, body { margin: 0; }
|
|
1086
|
+
:root { --header-h: 56px; } /* fixed-header offset, kept exact by the script above */
|
|
1067
1087
|
body {
|
|
1088
|
+
padding-top: var(--header-h);
|
|
1068
1089
|
background: var(--bg);
|
|
1069
1090
|
color: var(--fg);
|
|
1070
1091
|
font: 16px/1.65 var(--font-sans);
|
|
@@ -1073,7 +1094,7 @@ ${SHADCN_THEME}
|
|
|
1073
1094
|
::selection { background: var(--accent-tint); color: var(--fg); }
|
|
1074
1095
|
</style>
|
|
1075
1096
|
|
|
1076
|
-
<header class="
|
|
1097
|
+
<header class="fixed inset-x-0 top-0 z-20 flex items-center gap-6 px-4 sm:px-6 py-3 border-b border-border bg-[color-mix(in_oklch,var(--bg)_75%,transparent)] backdrop-blur-[18px]">
|
|
1077
1098
|
<a href="/" class="mr-auto inline-flex items-center gap-2 no-underline text-fg font-semibold text-[15px] leading-none tracking-tight">
|
|
1078
1099
|
<span>${name}</span>
|
|
1079
1100
|
</a>
|
package/lib/doctor.js
CHANGED
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { existsSync, statSync } from 'node:fs';
|
|
38
38
|
import { readFile } from 'node:fs/promises';
|
|
39
|
-
import { join } from 'node:path';
|
|
39
|
+
import { join, relative } from 'node:path';
|
|
40
40
|
import { checkNodeInline } from './node-preflight.js';
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -795,6 +795,53 @@ function checkGitHook(appDir) {
|
|
|
795
795
|
* instead of a real live resolve / node_modules read.
|
|
796
796
|
* @returns {Promise<DoctorResult[]>}
|
|
797
797
|
*/
|
|
798
|
+
/**
|
|
799
|
+
* Advisory (#646): name why a page/layout SHIPS its module to the browser
|
|
800
|
+
* instead of being elided. A page/layout that is a pure carrier (import-only
|
|
801
|
+
* #605 / inert #179) stays out of the browser; one that ships whole is pinned
|
|
802
|
+
* by a specific client-effecting NON-component in its closure (a util touching
|
|
803
|
+
* a client global, a module-scope side effect, a bare side-effect import) or by
|
|
804
|
+
* its own client work. This turns that invisible #605/#179 regression into a
|
|
805
|
+
* named line. WARN only: a page legitimately MAY ship, and the analyser is
|
|
806
|
+
* biased toward shipping by design (server AGENTS invariant 7), so this is a
|
|
807
|
+
* "you may not have intended this" hint, never a hard fail.
|
|
808
|
+
* @param {string} appDir
|
|
809
|
+
* @returns {Promise<DoctorResult>}
|
|
810
|
+
*/
|
|
811
|
+
async function checkElisionCarriers(appDir) {
|
|
812
|
+
const name = 'Page/layout elision (carrier hygiene)';
|
|
813
|
+
let report;
|
|
814
|
+
try {
|
|
815
|
+
const { analyzeAppElision } = await import('@webjsdev/server');
|
|
816
|
+
report = await analyzeAppElision(appDir);
|
|
817
|
+
} catch {
|
|
818
|
+
// Analysis unavailable (no app, malformed, server import failed): no advice.
|
|
819
|
+
return { name, status: 'pass', message: 'not analysed (no routable app or analysis unavailable)' };
|
|
820
|
+
}
|
|
821
|
+
if (!report.analysed) {
|
|
822
|
+
return { name, status: 'pass', message: 'not analysed (no routable app, or elision is disabled)' };
|
|
823
|
+
}
|
|
824
|
+
if (report.shipped.length === 0) {
|
|
825
|
+
return { name, status: 'pass', message: 'every page/layout is elided (a pure import-only or inert carrier)' };
|
|
826
|
+
}
|
|
827
|
+
const rel = (f) => relative(appDir, f) || f;
|
|
828
|
+
// Name the FIRST client-effecting blocker (there may be more than one; the
|
|
829
|
+
// module stays shipped until every such blocker is moved out).
|
|
830
|
+
const lines = report.shipped.map(({ file, blocker, reason }) =>
|
|
831
|
+
blocker
|
|
832
|
+
? `${rel(file)} ships whole. Its first client-effecting blocker is ${rel(blocker)}, which ${reason} and is not a component`
|
|
833
|
+
: `${rel(file)} ships whole because it ${reason}`,
|
|
834
|
+
);
|
|
835
|
+
return {
|
|
836
|
+
name,
|
|
837
|
+
status: 'warn',
|
|
838
|
+
message:
|
|
839
|
+
`${report.shipped.length} page/layout module(s) ship to the browser instead of being elided:\n` +
|
|
840
|
+
lines.map((l) => ` ${l}`).join('\n'),
|
|
841
|
+
fix: 'Move the client work out of the page/layout closure (into a component, or a .server module reached through an action) so the carrier can be elided, or accept that it ships. See agent-docs/components.md.',
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
798
845
|
export async function runDoctorChecks(appDir, opts = {}) {
|
|
799
846
|
const cliDir = opts.cliDir || new URL('.', import.meta.url).pathname;
|
|
800
847
|
const results = await Promise.all([
|
|
@@ -806,6 +853,7 @@ export async function runDoctorChecks(appDir, opts = {}) {
|
|
|
806
853
|
checkWebjsVersions(appDir),
|
|
807
854
|
checkImportmapCoherence(appDir, opts),
|
|
808
855
|
Promise.resolve(checkGitHook(appDir)),
|
|
856
|
+
checkElisionCarriers(appDir),
|
|
809
857
|
]);
|
|
810
858
|
return results;
|
|
811
859
|
}
|
package/package.json
CHANGED
|
@@ -137,6 +137,7 @@ self-review loop.
|
|
|
137
137
|
`static styles = css\`...\`` for scoped CSS.
|
|
138
138
|
- Custom-element tag names are passed to `.register('tag-name')`. They are NOT
|
|
139
139
|
a static field on the class.
|
|
140
|
+
- **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
|
|
140
141
|
- One function per server action file (`*.server.ts`).
|
|
141
142
|
- Server-only code (a DB driver like `better-sqlite3`/`pg`, `node:*`, anything that needs Node APIs)
|
|
142
143
|
goes only in `.server.{js,ts}` files, `route.ts` handlers, or
|
|
@@ -145,8 +146,22 @@ self-review loop.
|
|
|
145
146
|
stub for the browser. `lib/` holds both server-only infra
|
|
146
147
|
(the DB in `db/*.server.ts`) and browser-safe utilities (`lib/utils/cn.ts` with
|
|
147
148
|
`cn`); follow the same rule per file.
|
|
148
|
-
-
|
|
149
|
-
|
|
149
|
+
- Keep pages and layouts as pure carriers so their modules stay out of the
|
|
150
|
+
network tab. A page/layout never hydrates; the framework drops its module
|
|
151
|
+
from the browser as long as its only browser job is registering the
|
|
152
|
+
components it imports. It starts shipping its own module (invisible in tests,
|
|
153
|
+
an elision verdict) the moment its closure does any OTHER client work. So do
|
|
154
|
+
not give a page/layout module-scope client work (a top-level call, a
|
|
155
|
+
`window` / `document` / `customElements` access, a bare side-effect import,
|
|
156
|
+
or a `@webjsdev/core/client-router` import: routing is automatic), and do not
|
|
157
|
+
import a client-global-touching non-component util into it. Put client
|
|
158
|
+
behaviour in a component, server-only code in `.server.{js,ts}`. Self-check:
|
|
159
|
+
`page.ts` / `layout.ts` should not appear in the browser's network tab.
|
|
160
|
+
- Directives: webjs exports the lit directives with no clean native equivalent
|
|
161
|
+
(`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `cache`, `until`, `ref` /
|
|
162
|
+
`createRef`, `templateContent`, `asyncAppend` / `asyncReplace`, `watch`).
|
|
163
|
+
`classMap` / `styleMap` / `ifDefined` / `when` / `choose` are NOT exported.
|
|
164
|
+
For those, use plain template-literal expressions
|
|
150
165
|
(`class=${active ? 'btn active' : 'btn'}`, `style=${'color:' + color}`,
|
|
151
166
|
`${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in
|
|
152
167
|
`firstUpdated`) instead of Lit's `classMap` / `styleMap` / `ref` / `when` /
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Guardrail: a webjs custom element must extend the framework's WebComponent
|
|
3
|
+
# base class, never raw HTMLElement.
|
|
4
|
+
#
|
|
5
|
+
# Why: a raw `extends HTMLElement` custom element is invisible to the webjs
|
|
6
|
+
# elision analyser (it ships unconditionally, defeats display-only elision, and
|
|
7
|
+
# keeps any importing page/layout from being import-only), it bypasses the
|
|
8
|
+
# SSR / lifecycle / reactive-prop machinery, and it usually applies its DOM work
|
|
9
|
+
# in connectedCallback (client-only), a progressive-enhancement bug.
|
|
10
|
+
#
|
|
11
|
+
# Scope: fires ONLY when the edited file lives in a webjs project (a package.json
|
|
12
|
+
# up the tree depends on @webjsdev/*), so vanilla-JS projects are never touched.
|
|
13
|
+
# Exempts framework source (packages/, node_modules/), since the framework
|
|
14
|
+
# legitimately defines WebComponent and the SSR-inert <webjs-frame> / -stream /
|
|
15
|
+
# -suspense primitives on raw HTMLElement. Honours an explicit escape-hatch
|
|
16
|
+
# marker `webjs-allow-htmlelement: <reason>` for the rare native-API case
|
|
17
|
+
# WebComponent cannot express (a form-associated element via ElementInternals,
|
|
18
|
+
# a customized built-in via `extends HTMLButtonElement`, etc.).
|
|
19
|
+
#
|
|
20
|
+
# PreToolUse contract: exit 0 = allow, exit 2 = block (message on stderr).
|
|
21
|
+
#
|
|
22
|
+
# NOTE: No em-dashes, spaces around hyphens as pauses, or semicolons as pauses
|
|
23
|
+
# are allowed in comments per project rules.
|
|
24
|
+
set -euo pipefail
|
|
25
|
+
|
|
26
|
+
input=$(cat)
|
|
27
|
+
fp=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
|
|
28
|
+
[ -z "$fp" ] && exit 0
|
|
29
|
+
case "$fp" in
|
|
30
|
+
*.ts|*.tsx|*.js|*.jsx|*.mts|*.mjs) ;;
|
|
31
|
+
*) exit 0 ;;
|
|
32
|
+
esac
|
|
33
|
+
|
|
34
|
+
# The text being written: Write -> .content, Edit -> .new_string, MultiEdit -> .edits[]?.new_string.
|
|
35
|
+
content=$(printf '%s' "$input" | jq -r '(.tool_input.content // empty), (.tool_input.new_string // empty), (.tool_input.edits[]?.new_string // empty)')
|
|
36
|
+
[ -z "$content" ] && exit 0
|
|
37
|
+
|
|
38
|
+
# Only a class that extends raw HTMLElement is the target (not `typeof
|
|
39
|
+
# HTMLElement` guards, not `instanceof HTMLElement`, not another base).
|
|
40
|
+
printf '%s' "$content" \
|
|
41
|
+
| grep -Eq 'class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]+extends[[:space:]]+HTMLElement([[:space:]{]|$)' \
|
|
42
|
+
|| exit 0
|
|
43
|
+
|
|
44
|
+
# Explicit, acknowledged exception.
|
|
45
|
+
printf '%s' "$content" | grep -qi 'webjs-allow-htmlelement' && exit 0
|
|
46
|
+
|
|
47
|
+
# Framework source / installed deps are never app components.
|
|
48
|
+
case "$fp" in
|
|
49
|
+
*/packages/*|*/node_modules/*|packages/*|node_modules/*) exit 0 ;;
|
|
50
|
+
esac
|
|
51
|
+
|
|
52
|
+
# Webjs context: a package.json up the tree references @webjsdev/* (a webjs app
|
|
53
|
+
# or the framework repo). Outside a webjs project this hook is a no-op.
|
|
54
|
+
dir=$(CDPATH= cd -- "$(dirname -- "$fp")" 2>/dev/null && pwd || dirname -- "$fp")
|
|
55
|
+
is_webjs=0
|
|
56
|
+
while [ -n "$dir" ] && [ "$dir" != "/" ]; do
|
|
57
|
+
if [ -f "$dir/package.json" ] && grep -q '@webjsdev/' "$dir/package.json" 2>/dev/null; then
|
|
58
|
+
is_webjs=1
|
|
59
|
+
break
|
|
60
|
+
fi
|
|
61
|
+
dir=$(dirname -- "$dir")
|
|
62
|
+
done
|
|
63
|
+
[ "$is_webjs" -eq 0 ] && exit 0
|
|
64
|
+
|
|
65
|
+
cat >&2 <<'MSG'
|
|
66
|
+
BLOCKED: a webjs custom element must extend the WebComponent base class, not raw HTMLElement.
|
|
67
|
+
|
|
68
|
+
import { WebComponent } from '@webjsdev/core';
|
|
69
|
+
class MyThing extends WebComponent {
|
|
70
|
+
render() { return html`...`; }
|
|
71
|
+
}
|
|
72
|
+
MyThing.register('my-thing');
|
|
73
|
+
|
|
74
|
+
A display-only element (just host classes / static markup) can set its classes
|
|
75
|
+
in the constructor (runs at SSR, so it is progressive-enhancement-safe) and
|
|
76
|
+
stays elidable, so it ships zero JS. A raw `extends HTMLElement` element cannot
|
|
77
|
+
be elided, defeats import-only routes, and applies its work client-only.
|
|
78
|
+
|
|
79
|
+
If WebComponent genuinely cannot express this (a rare native-API edge case),
|
|
80
|
+
add a marker comment containing `webjs-allow-htmlelement: <reason>` to the file
|
|
81
|
+
to acknowledge the exception, and this guardrail will allow it.
|
|
82
|
+
MSG
|
|
83
|
+
exit 2
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
3
|
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": ".claude/hooks/block-raw-htmlelement.sh"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"matcher": "Write|Edit|MultiEdit|NotebookEdit|Bash",
|
|
6
15
|
"hooks": [
|
package/templates/.cursorrules
CHANGED
|
@@ -113,8 +113,10 @@ self-review loop.
|
|
|
113
113
|
- Shadow-DOM components opt in with `static shadow = true` and use `static styles = css` for scoped CSS, not inline styles. That is the right home for scoped CSS.
|
|
114
114
|
- One function per server action file (*.server.ts)
|
|
115
115
|
- Components must call customElements.define('tag', Class)
|
|
116
|
+
- **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
|
|
116
117
|
- Server-only code (the DB driver `better-sqlite3` / `pg`, node:*, anything that needs Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap the access in a .server.{js,ts} file; the framework rewrites that import into an RPC stub for the browser. The DB lives in db/*.server.ts (db/connection.server.ts); lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); follow the same rule per file: if a lib/ file needs Node APIs, only import it from server-only files.
|
|
117
|
-
- Directives
|
|
118
|
+
- Directives: webjs exports the lit directives with no clean native equivalent (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `cache`, `until`, `ref` / `createRef`, `templateContent`, `asyncAppend` / `asyncReplace`, `watch`). Lit's `classMap` / `styleMap` / `ifDefined` / `when` / `choose` are NOT exported. For those, use plain template-literal expressions (`class=${cond ? 'a' : 'b'}`, `${cond ? a : b}`) and lifecycle hooks (`this.query('#el')` in `firstUpdated`) instead.
|
|
118
119
|
- **Progressive enhancement is the default.** Pages AND every web component are SSR'd to real HTML. Write components so the first paint is the right content. Read SSR-meaningful defaults in `constructor()`. `connectedCallback` is never called on the server, so anything there only runs after hydration. Initial data for components comes from the page function (server-side fetch plus pass as attribute/property) OR from an `async render()` in the component itself (`const u = await getUser(this.uid)`, which SSR awaits so the data is in the first paint), NOT from `fetch` calls in `connectedCallback`. Prefer the co-located `async render()` over prop-drilling; `renderFallback()` is the optional re-fetch loading state (never first paint), and a `Task` is for genuinely client-only data. For write-paths, prefer `<form action=...>` plus server action over `fetch` plus click handler. The framework upgrades plain forms to partial-swap submissions automatically.
|
|
119
120
|
- **Client navigation is auto-magic.** Real `<a href>` and `<form action>` get partial-swap behavior with no opt-in. Because layouts persist across navigation, put shared chrome (sidenav, header) in `layout.ts` and page-specific content in `page.ts`. For validation errors, return 4xx HTML from a `route.ts` POST handler; the router renders it in place preserving the user's input. For non-layout swap regions, wrap in `<webjs-frame id="...">`. See "Client navigation patterns" in AGENTS.md.
|
|
121
|
+
- **Keep pages and layouts as pure carriers** so their modules stay out of the network tab. A page/layout never hydrates; the framework drops its module from the browser as long as its only browser job is registering the components it imports. It starts shipping its own module (invisible in tests, an elision verdict) the moment its closure does any OTHER client work. Don't give a page/layout module-scope client work (a top-level call, a window/document/customElements access, a bare side-effect import, or a @webjsdev/core/client-router import: routing is automatic) or import a client-global-touching non-component util into it. Put client behaviour in a component, server-only code in .server.{js,ts}. Self-check: page.ts/layout.ts should not appear in the browser's network tab.
|
|
120
122
|
- See AGENTS.md for the complete directive decision guide
|
|
@@ -107,13 +107,14 @@ each change must include.
|
|
|
107
107
|
- **Erasable TypeScript only.** The runtime strips types via `module.stripTypeScriptTypes` (Node's built-in, or `amaro` on Bun) (whitespace replacement, byte-exact position preservation, no sourcemap). The scaffold's tsconfig.json sets `erasableSyntaxOnly: true`, so the TS compiler rejects `enum`, `namespace` with values, constructor parameter properties, legacy decorators with `emitDecoratorMetadata`, and `import = require`. Use erasable equivalents: `const X = { ... } as const` plus a derived union type instead of `enum`; explicit fields plus constructor body assignments instead of parameter properties. If `erasableSyntaxOnly` is disabled and non-erasable syntax is used, the dev server fails at strip time and returns a 500 pointing at the `no-non-erasable-typescript` lint rule. webjs is buildless end-to-end and has no bundler fallback.
|
|
108
108
|
- Tagged template: html`<div>${value}</div>` with css`...` for styles.
|
|
109
109
|
- **Tailwind-first styling.** Tailwind utilities are the strong default for pages AND light-DOM components (the default DOM mode): layout, spacing, color (via `@theme` tokens), typography, borders, radius, shadows, interaction states. Light DOM does not scope, so utilities apply directly. The lit reflex to scope CSS (`static styles = css\`...\``) or write an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component is wrong: the scoped block needs `static shadow = true`, and inline class names leak globally. When a utility bundle repeats, extract a `lib/utils/ui.ts` helper returning an `html` fragment, not a CSS class. Reserve raw CSS for the allowlist (design tokens / `@theme`, `@property` + `@keyframes`, `::-webkit-scrollbar`, `prefers-reduced-motion`, complex `color-mix()` / gradients); when unavoidable in a light-DOM component, prefix every class selector with the component tag. Shadow-DOM components (`static shadow = true`) legitimately author `static styles = css\`...\`` for scoped CSS; don't use inline `style="..."` there.
|
|
110
|
-
- Components: extend the `WebComponent({ ... })` factory to declare reactive properties (e.g. `extends WebComponent({ count: Number })`; per-prop options via `prop(Number, { reflect: true })`), add `static styles` for shadow-DOM components, call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field. A hand-written `static properties` throws at construction (`no-static-properties`); set defaults via the `default` option or the constructor, never a class-field initializer (`reactive-props-no-class-field`).
|
|
110
|
+
- Components: extend the `WebComponent({ ... })` factory to declare reactive properties (e.g. `extends WebComponent({ count: Number })`; per-prop options via `prop(Number, { reflect: true })`), add `static styles` for shadow-DOM components, call `Class.register('tag-name')` at the bottom of the file. The tag name is the argument to `.register()`, not a static field. A hand-written `static properties` throws at construction (`no-static-properties`); set defaults via the `default` option or the constructor, never a class-field initializer (`reactive-props-no-class-field`). Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`), flagged by `array-prop-uses-array-type`. **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
|
|
111
111
|
- Async data in a component: prefer an `async render()` (`const u = await getUser(this.uid)`), which SSR awaits so the data is in the first paint, over prop-drilling from the page or fetching in `connectedCallback`. `renderFallback()` is the optional re-fetch loading state (never first paint); error isolation is automatic (`renderError()` customizes it); a `Task` is for genuinely client-only data.
|
|
112
112
|
- Server actions: *.server.ts files with one exported async function each.
|
|
113
113
|
- Server-only code (a DB driver like better-sqlite3/pg, node:*, anything needing Node APIs) goes only in .server.{js,ts} files, route.ts handlers, or middleware.ts. Never in pages, layouts, or components. Wrap in a .server.{js,ts} file; the framework rewrites that import to an RPC stub for the browser. The DB lives in db/*.server.ts; lib/ holds other server-only infra and browser-safe utilities (lib/utils/cn.ts with cn); apply the same rule per file.
|
|
114
|
-
- Directives: webjs
|
|
114
|
+
- Directives: webjs exports the lit directives with no clean native equivalent (`repeat`, `unsafeHTML`, `live`, `keyed`, `guard`, `cache`, `until`, `ref` / `createRef`, `templateContent`, `asyncAppend` / `asyncReplace`, `watch`). Lit's `classMap` / `styleMap` / `ifDefined` / `when` / `choose` are NOT exported; use plain template-literal expressions instead.
|
|
115
115
|
- Context: import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context'
|
|
116
116
|
- Task: import { Task, TaskStatus } from '@webjsdev/core/task'
|
|
117
117
|
- Routing: file-based under app/ (page.ts, layout.ts, route.ts, middleware.ts).
|
|
118
|
+
- Keep pages and layouts as pure carriers so their modules stay out of the network tab. A page/layout never hydrates; the framework drops its module from the browser as long as its only browser job is registering the components it imports. It starts shipping its own module (invisible in tests, an elision verdict) the moment its closure does any OTHER client work. Don't give a page/layout module-scope client work (a top-level call, a window/document/customElements access, a bare side-effect import, or a @webjsdev/core/client-router import: routing is automatic) or import a client-global-touching non-component util into it. Put client behaviour in a component, server-only code in .server.{js,ts}. Self-check: page.ts/layout.ts should not appear in the network tab.
|
|
118
119
|
- Component state lives in signals from @webjsdev/core. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the `WebComponent({ ... })` factory) are for HTML attributes and .prop=${...} hydration.
|
|
119
120
|
- Don't skip tests or documentation updates.
|
package/templates/AGENTS.md
CHANGED
|
@@ -562,7 +562,6 @@ CI gate).
|
|
|
562
562
|
|
|
563
563
|
```ts
|
|
564
564
|
import { html, css, WebComponent } from '@webjsdev/core';
|
|
565
|
-
import '@webjsdev/core/client-router'; // enable SPA nav
|
|
566
565
|
import { unsafeHTML, live } from '@webjsdev/core/directives';
|
|
567
566
|
import { createContext } from '@webjsdev/core/context';
|
|
568
567
|
import { Task } from '@webjsdev/core/task';
|
|
@@ -721,6 +720,9 @@ Practical consequences for agents writing webjs code.
|
|
|
721
720
|
| Class-field initializer for a reactive property (`student: Student = {...}`) | Silently breaks reactivity (overwrites the framework accessor) | Pass the shape to the base-class factory `WebComponent({ student: Object })` and set the default in the constructor (flagged by `reactive-props-no-class-field`) |
|
|
722
721
|
| `@property()` decorator | Banned by invariant 10 (erasable TS) | Pass the shape to the base-class factory `WebComponent({ ... })` (the only supported form) |
|
|
723
722
|
| Hand-written `static properties = { ... }` | Throws at construction (the factory owns property setup) | Pass the same shape to the base-class factory `WebComponent({ ... })` (flagged by `no-static-properties`) |
|
|
723
|
+
| Array-typed prop declared with `Object` (`items: prop<Tag[]>(Object)`) | Works (Object and Array share one JSON converter), but misstates the prop's shape | Pass the `Array` constructor (`items: prop<Tag[]>(Array)`), flagged by `array-prop-uses-array-type` |
|
|
724
|
+
| Extending raw `HTMLElement` directly | Bypasses SSR, reactive properties, elision, and lifecycle hooks; keeps the component from being elided | Always subclass `WebComponent` (or the factory form `WebComponent({...})`) |
|
|
725
|
+
| Module-scope client work in a `page.ts` / `layout.ts` (a top-level call, a `window` / `document` / `customElements` access, a `@webjsdev/core/client-router` import), or importing a client-global-touching non-component util into one | The page/layout module stops being a droppable carrier and SHIPS its own JS to the browser (it shows up in the network tab); invisible in tests because it is an elision verdict, not a behaviour change | Keep pages/layouts pure carriers (their only browser job is registering the components they import; routing is automatic). Put client behaviour in a component, server-only code in `.server.{js,ts}`. Self-check: `page.ts` / `layout.ts` should not appear in the network tab |
|
|
724
726
|
| Scoped `static styles = css` or an inline `<style>` with semantic class names (`.hero`, `.card`) in a light-DOM component | Scoped block does nothing without `static shadow = true`; inline `<style>` class names leak globally | Tailwind utilities (the light-DOM default); or `static shadow = true` for genuinely scoped CSS |
|
|
725
727
|
| `willUpdate` computing SSR-visible derived state | Works (runs at SSR), but overriding it opts the component out of elision | Fine for interactive components; for display-only, derive inline in `render()` |
|
|
726
728
|
| `this.hasAttribute` / `getAttribute` in `render()` | Works (server attribute shim backs the attribute methods at SSR) | Read attributes directly, or declare a reactive prop via the base-class factory `WebComponent({ ... })` |
|
|
@@ -787,8 +789,9 @@ reference: https://docs.webjs.com/docs/server-actions
|
|
|
787
789
|
|
|
788
790
|
## Client navigation patterns (auto-magic)
|
|
789
791
|
|
|
790
|
-
The client router enables itself
|
|
791
|
-
`@webjsdev/core
|
|
792
|
+
The client router enables itself automatically: it turns on whenever
|
|
793
|
+
`@webjsdev/core` loads in the browser, which happens on any page that
|
|
794
|
+
ships a component, so there is no import to add. **Every `<a href>` and
|
|
792
795
|
`<form action>` on the page is enhanced into a partial-swap navigation
|
|
793
796
|
or submission automatically**. You don't call a router API. Write
|
|
794
797
|
standard HTML; the swap happens.
|
package/templates/CONVENTIONS.md
CHANGED
|
@@ -403,6 +403,7 @@ modules/
|
|
|
403
403
|
- **Server-only code goes in `.server.{js,ts}` files, `route.ts` handlers, or `middleware.ts`. Never in pages, layouts, or components.** Direct imports of a DB driver (`better-sqlite3` / `pg`) or `node:*` from pages, layouts, or components crash the browser at module load. Wrap in a `.server.{js,ts}` file; the framework rewrites that import to an RPC stub on the browser side. The DB lives in `db/*.server.ts`; `lib/` holds other server-only infra and browser-safe utilities (`lib/utils/cn.ts` with `cn`); the convention is "if a `lib/` file needs Node APIs, only import it from server-only files."
|
|
404
404
|
- Routes (`app/**/page.ts`, `app/**/route.ts`) must be thin: import logic from modules
|
|
405
405
|
- **Fetch server data in the component that needs it, with an `async render()`, not by prop-drilling.** A leaf component can write `const u = await getUser(this.uid)` directly in `render()`; SSR awaits it so the data is in the first paint, and the client uses stale-while-revalidate on a re-fetch. Reach for `renderFallback()` only to show a re-fetch loading state, and `Task` / signals only for genuinely client-only data (a `Task` shows its pending state at SSR, losing first-paint data). Do not put `await getData()` in a page / layout when a leaf component can own it (page fetches run sequentially, a route-level waterfall).
|
|
406
|
+
- **Keep pages and layouts as pure carriers, so their modules stay out of the network tab.** A page/layout never hydrates; the framework drops its module from the browser as long as its only browser-relevant job is registering the components it imports. It starts shipping its own module (invisible in tests) the moment its closure does any OTHER client work. So do not give a page/layout module-scope client work (a top-level call, a `window` / `document` / `customElements` access, a bare side-effect import, or a `@webjsdev/core/client-router` import: routing is automatic), and do not import a client-global-touching non-component util into it. Put client behaviour in a component, server-only code in `.server.{js,ts}`. Self-check: `page.ts` / `layout.ts` should not appear in the browser's network tab.
|
|
406
407
|
|
|
407
408
|
---
|
|
408
409
|
|
|
@@ -505,7 +506,7 @@ SSR, page actions, server-action RPC, auth + CSRF), drive
|
|
|
505
506
|
|
|
506
507
|
```ts
|
|
507
508
|
import { createRequestHandler } from '@webjsdev/server';
|
|
508
|
-
import { testRequest,
|
|
509
|
+
import { testRequest, invokeActionForTest, loginAndGetCookies, withSessionCookie }
|
|
509
510
|
from '@webjsdev/server/testing';
|
|
510
511
|
|
|
511
512
|
const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
@@ -523,8 +524,9 @@ const out = await invokeActionForTest(app, 'modules/posts/actions/create.server.
|
|
|
523
524
|
|
|
524
525
|
Prefer `invokeActionForTest` over a direct import of the action when you want
|
|
525
526
|
to verify the production contract: it exercises the wire serializer (a `Date` /
|
|
526
|
-
`Map` arg survives),
|
|
527
|
-
|
|
527
|
+
`Map` arg survives), the Origin / Sec-Fetch-Site CSRF check (it models a
|
|
528
|
+
same-origin POST), and prod error sanitization, which a direct call bypasses.
|
|
529
|
+
The saas template's `test/auth/auth.test.ts` is a worked example.
|
|
528
530
|
|
|
529
531
|
This is also why the auth test lives at `test/auth/auth.test.ts` (the
|
|
530
532
|
feature-folder convention), NOT `test/unit/auth.test.ts`. Test KIND is a
|
|
@@ -678,9 +680,10 @@ Reactive properties are declared one way: pass the properties shape directly to
|
|
|
678
680
|
- **If a light-DOM component authors its own custom CSS (a `<style>` block in `render()` or an imported stylesheet), every class selector MUST be prefixed with the component's tag name.** Either pattern works. Pick one and stay consistent:
|
|
679
681
|
- `.my-widget__body`, `.my-widget__title` (BEM-ish)
|
|
680
682
|
- `my-widget .body`, `my-widget .title` (descendant selector)
|
|
683
|
+
- **Never extend raw HTMLElement directly for app components.** Always subclass `WebComponent` (or the factory form `WebComponent({...})`) to hook into SSR, lifecycle, elision, and the reactive property system. Extend raw HTMLElement only for rare native-API edge cases (like form-associated `ElementInternals` or customized built-in elements), and add a `webjs-allow-htmlelement: <reason>` comment to acknowledge the exception.
|
|
681
684
|
- Tag name must contain a hyphen (HTML spec)
|
|
682
685
|
- Always call `Class.register('tag')`. That's the standard DOM API.
|
|
683
|
-
- **Reactive props are declared via the base-class factory `WebComponent({ ... })`.** A hand-written `static properties = { ... }` throws at construction (`webjs check` flags it via `no-static-properties`). Never write `propName = value` or `propName: Type = value` as a class-field initializer on a reactive prop. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders (`webjs check` flags this via `reactive-props-no-class-field`). Set defaults via the `default` option or in the constructor.
|
|
686
|
+
- **Reactive props are declared via the base-class factory `WebComponent({ ... })`.** A hand-written `static properties = { ... }` throws at construction (`webjs check` flags it via `no-static-properties`). Never write `propName = value` or `propName: Type = value` as a class-field initializer on a reactive prop. It compiles to `Object.defineProperty(this, …)` after `super()` and clobbers the framework's reactive accessor, silently breaking re-renders (`webjs check` flags this via `reactive-props-no-class-field`). Set defaults via the `default` option or in the constructor. Declare an array-typed prop with the `Array` constructor, not `Object` (`items: prop<Tag[]>(Array)`): the two share one JSON converter so neither crashes, but `Array` states the shape and `webjs check` flags the `Object` form via `array-prop-uses-array-type`.
|
|
684
687
|
- Component state lives in signals. Import `signal` from `@webjsdev/core`, read via `signal.get()` inside `render()`, write via `signal.set(value)`. Module-scope signals share state across components; instance signals (created in the constructor) carry component-local state. Reactive properties (declared via the factory) wrap HTML attributes, attribute reflection, and `.prop=${value}` SSR hydration.
|
|
685
688
|
- Use lifecycle hooks (`firstUpdated`, `updated`) only when needed
|
|
686
689
|
|
|
@@ -729,6 +732,17 @@ color (via the `@theme` tokens), typography, borders, radius, shadows,
|
|
|
729
732
|
and interaction states (hover/focus/active/disabled, dark mode). Light
|
|
730
733
|
DOM does not scope styles, so utilities apply directly.
|
|
731
734
|
|
|
735
|
+
**Pin a header with `position: fixed`, never `position: sticky`.** A
|
|
736
|
+
sticky header flickers its background for one frame on iOS WebKit (every
|
|
737
|
+
iOS browser) during a client-router navigation, because the preserved
|
|
738
|
+
header plus the scroll-to-top trips a WebKit sticky-repaint bug that the
|
|
739
|
+
usual GPU-promotion hacks (`translateZ`, `will-change`) do NOT fix. Use
|
|
740
|
+
`position: fixed` and reserve the header height on the content with a
|
|
741
|
+
`--header-height` variable (the scaffolded `app/layout.ts` does exactly
|
|
742
|
+
this, kept exact by a `ResizeObserver`). It is iOS-only, invisible on
|
|
743
|
+
desktop, Android, and in DevTools emulation, so it shows only on a real
|
|
744
|
+
device.
|
|
745
|
+
|
|
732
746
|
**The lit muscle-memory trap.** If you have written lit, the habit is to
|
|
733
747
|
scope CSS in a shadow root (`static styles = css\`\``) or write an inline
|
|
734
748
|
`<style>` with semantic class names (`.hero`, `.feature`, `.card`) for
|