Skip to content

Fix factual drift in three blog posts and the csp.js policy comment #1256

Description

@vivek7405

Problem

Six published statements about WebJs are wrong against the code they describe. Five are in blog posts, which is the surface search engines and AI agents read most, and one is a source comment that is the upstream cause of a wrong claim that already had to be corrected in two other places.

A wrong claim on the blog is worse than a wrong line in a reference doc. A reader who tries to satisfy a webjs check rule that never existed, or who expects an esbuild fallback that was deleted, is chasing something the framework has never had.

  1. blog/ai-first-is-plumbing.md names four webjs check rules that do not exist, and states the wrong rule count. Lines 52 to 61 list nine rules. Four are absent from RULES: tests-exist, no-server-imports-in-components (the real rule is no-server-import-in-browser-module, which does something different), no-json-data-files, and light-dom-css-prefix. Line 65 says "We have ~10 rules, not 100"; there are 20. Line 63 says new rules are about 20 lines apiece; the last three added 73, 109 and 165 lines.

  2. blog/light-dom-by-default.md:113 tells the reader webjs check ships a light-dom-css-prefix rule that "flags unprefixed class selectors in vanilla CSS for light-DOM components". It does not. The tag-prefix requirement is prose only (AGENTS.md invariant 7), so the post promises a safety net that will never fire.

  3. blog/stop-ai-agents-breaking-your-code.md:87 names the same phantom light-dom-css-prefix rule. Found by a corpus-wide scan rather than by reading, and it sits in a list whose other four entries are real.

  4. blog/strip-types-not-esbuild.md:62 describes an esbuild fallback that no longer exists. It says the server falls back to esbuild.transform on ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX, emitting an inline sourcemap. There is no esbuild in the strip path. packages/server/src/ts-strip.js has exactly two backends, and packages/server/src/dev.js:145 states in source that there is no bundler fallback. The same post's code block at line 55 also shows const TS_CACHE = new Map();, an identifier that does not exist.

  5. packages/server/src/csp.js attributes the style-src inline allowance to Tailwind. Lines 53 and 70 say Tailwind's browser runtime injects a <style> element. That is not why the default needs 'unsafe-inline', and it misleads: an app can drop Tailwind and still need it. The scaffold compiles Tailwind to a static public/tailwind.css, so a scaffolded app has no Tailwind browser runtime at all. This comment is the origin of the same wrong claim that had to be corrected on website/app/docs/deployment/page.ts and in blog/no-build-via-jspm-io.md.

  6. blog/client-router-turbo-drive-style.md states three explicit router non-goals and two are no longer true. Line 76 says the router does not prefetch, but link prefetch now ships on by default with a device-adaptive strategy. Line 80 says each navigation re-fetches the page from scratch, but the X-Webjs-Have header now returns only the divergent fragment, a mechanism the same post already describes at line 55. The middle non-goal (View Transitions off by default) is still accurate.

Implementation plan

Every claim below was re-verified against the current tree before this plan was written. This is a prose-only correction. No behaviour changes, no new API, no test-observable surface moves. The one source file touched is edited in its comments only.

Two shape decisions are settled here rather than left to the implementer. First, the blog stops enumerating the rule set and stops printing a rule count, because a hand-maintained list inside a post is precisely what drifted, so the posts name a few real rules as examples and point at webjs check --rules for the current set. Second, the CSP comment enumerates all four sources of un-nonced style rather than swapping Tailwind for one other single cause, since the single-cause framing is what consumed review rounds previously.

Verified claims

Claim in the corpus Governing source Verdict
tests-exist, no-json-data-files, light-dom-css-prefix are check rules RULES in packages/server/src/check.js Never existed
no-server-imports-in-components is a check rule Real name is no-server-import-in-browser-module (check.js:129) Wrong name, wrong description
The other five rules named in ai-first-is-plumbing.md check.js RULES Still true, keep unchanged
"We have ~10 rules, not 100" RULES.length is 20, read by importing the module Wrong
"New rules are about 20 lines apiece" The last three rules added 73, 109 and 165 lines to check.js Wrong, off by roughly 4x
The scaffolded hook list in ai-first-is-plumbing.md packages/cli/templates/ Still true, do not widen the edit into it
esbuild fallback on ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX packages/server/src/ts-strip.js, plus dev.js:145 stating in source that there is no bundler fallback Wrong
const TS_CACHE = new Map(); No such identifier. The cache is per-handler state.tsCache; only TS_CACHE_MAX is module-level (dev.js:153, reason at dev.js:2572) Wrong, and not recorded in the original Problem section
The esbuild loader-hook history at strip-types-not-esbuild.md:50 ts-strip.js header, blog/no-build-via-jspm-io.md Still true, keep including its existing PR #9 reference
esbuild at website/app/docs/no-build/page.ts:156 Publish-time dist/ bundling of @webjsdev/core, a real and separate thing Still true, must not be "fixed"
Tailwind's browser runtime forces style-src 'unsafe-inline' Scaffold compiles Tailwind to a static public/tailwind.css (packages/cli/lib/create.js:324), so a scaffolded app has no Tailwind runtime Wrong, twice over
The framework's own head <style> carries the nonce packages/server/src/ssr.js:1881 emits <style${n}> Still true, and is the contrast the new comment turns on
Component styles carry no nonce packages/core/src/css.js:53 emits a bare <style> and takes no nonce New fact, needed for the fix
"No prefetching" (client-router-turbo-drive-style.md:76) packages/core/src/router-client.js:1639, the adaptive prefetch default Wrong
"No view-transitions API by default" (line 78) router-client.js:2725, opt-in via <meta name="view-transition" content="same-origin"> Still true, keep
"No nested-route data deduplication" (line 80) The x-webjs-have short-circuit in packages/server/src/ssr.js Wrong

Two guards were run corpus-wide and must be re-run after the edits. A scan of every rule-shaped backticked token in blog/ and website/app/docs/ against RULES found exactly the four phantom names above and nothing else, so there is no sixth surprise waiting. And esbuild survives in five legitimate places listed under Landmines.

Rejected

  • Renaming the client-router-turbo-drive-style slug. It is a published URL and an SEO asset, it is linked from blog/strict-csp-nonce-across-soft-navigation.md:51, and the borrowed-framing concern was already answered by an earlier title change.
  • Deleting the "What the router does not do" section outright. The post's argument is better for stating a real limit, and one real non-goal survives.
  • Keeping an enumerated rule list in either blog post and just correcting the names. That list is what drifted, and it will drift again on the next rule.
  • Printing the corrected count "20 rules". Same failure mode with a one-release fuse.
  • Adding a test that pins the blog prose to RULES. Prose is not a test surface, and it would red every future rule addition on an unrelated file.
  • Replacing Tailwind in the CSP comment with one other single cause.
  • Widening the edit into ai-first-is-plumbing.md's hook list. Every claim there was checked and holds.

Steps

  1. blog/ai-first-is-plumbing.md, "The convention validator". Delete the tests-exist, no-json-data-files and light-dom-css-prefix bullets. Replace the no-server-imports-in-components bullet with no-server-import-in-browser-module, described as what it does, which is that a module genuinely shipping to the browser must not import a server-only file, because the client gets a stub that throws at load. Keep the five accurate bullets byte-identical. Delete the "New rules are about 20 lines apiece" sentence. Replace "We have ~10 rules, not 100" with the same argument stated without a count, plus a pointer to webjs check --rules.
  2. blog/light-dom-by-default.md:113. Replace the clause after "The same applies to other naming-discipline approaches like BEM or scoped class prefixes" so it states the tag-prefix requirement as a convention the author follows, not a rule the checker enforces, which is also a reason Tailwind stays the lower-effort default. Do not claim any tooling backs it.
  3. blog/stop-ai-agents-breaking-your-code.md:87. Replace the light-dom-css-prefix bullet with no-browser-globals-in-render (check.js:119), which fits the section's "failure modes an agent trips over" framing, described as a browser global in the constructor or render() crashing the SSR pass. Keep the surrounding four bullets and the webjs check --rules snippet above them.
  4. blog/strip-types-not-esbuild.md. In the code block at line 54, drop the phantom const TS_CACHE = new Map(); line and keep TS_CACHE_MAX and the entry-shape comment. In the paragraph below it, note that the cache is per request handler rather than module-global, because the cached bytes bake in that handler's elision verdict. Then replace the esbuild paragraph at line 62 entirely: there is no fallback, non-erasable syntax throws at strip time and the server returns a clean 500, and erasable-typescript-only plus no-non-erasable-typescript catch it at edit time. State the one thing that does vary, the backend: Node's built-in module.stripTypeScriptTypes, and amaro on Bun, whose strip-only mode is what the Node built-in wraps, so the output is byte-identical. Leave line 50 alone.
  5. packages/server/src/csp.js, the DEFAULT_DIRECTIVES doc block around line 53. Replace the Tailwind clause with a pointer to app-authored styles that carry no nonce, deferring the detail to the style-src comment.
  6. packages/server/src/csp.js, the inline comment above style-src around line 70. Replace it with the enumerated real reason. WebJs's own head <style> (the @layer webjs-host rule) IS nonce-signed, so the allowance exists for app-authored style: a page or layout's inline <style>, a light-DOM component's <style>, a shadow component's static styles serialized into its declarative shadow root, and every style="..." attribute, the last of which can never carry a nonce because the mechanism applies to elements and not attributes. Name no styling library. Keep the existing closing sentence that style elements are not a script-injection vector.
  7. blog/client-router-turbo-drive-style.md, the "What the router does not do" section at lines 72 to 80. Three non-goals are now one, so rewrite the opening line. Keep the View Transitions item and name the <meta name="view-transition"> opt-in. Replace the prefetching item with what is true now, prefetch on by default with the strategy chosen per device, hover intent where there is a pointer and dwell-gated viewport entry on touch, linking /blog/device-adaptive-link-prefetch. Replace the deduplication item with a back-reference to the X-Webjs-Have mechanism the same post already describes. No maturity language about the old design.
  8. Run the corpus grep from the Acceptance criteria and confirm nothing else states any old version.

Tests

  • N/A for the prose, because no test layer covers blog copy. website/test/ssr/pre-block-a11y.test.ts renders the shared markdown post body, but from a synthetic inline string rather than a real post file, so no test pins the sentences being edited and none should be added to.
  • packages/server/test/csp/csp.test.js covers buildCspHeader and DEFAULT_DIRECTIVES behaviour. The change to csp.js is comment-only, so that suite must stay green untouched, which is itself the check that no directive value moved.
  • Counterfactual: none is possible for a prose fix, which is why the verification table above exists in its place. The substitute is the corpus scan plus a manual re-read of each edited claim against the file named in the table.

Verification

  1. Re-run the phantom-rule scan. Import RULES from packages/server/src/check.js and assert every rule-shaped backticked token surviving in blog/ and website/app/docs/ is in it. Must come back empty. Do not count rules by eye or from CLI output; three rule names are long enough to change the column spacing, which is how a wrong count got published before.
  2. Re-run the esbuild / light-dom-css-prefix / unsafe-inline greps and confirm the five legitimate mentions are intact.
  3. Confirm csp.js is comment-only. Its diff must show no change to any directive string, and packages/server/test/csp/csp.test.js must pass unedited.
  4. Boot the website in prod mode through createRequestHandler (pass the PROJECT ROOT as appDir, since buildRouteTable does join(appDir, 'app')) and GET each edited post plus /blog and /blog/device-adaptive-link-prefetch, asserting 200 and no broken modulepreload hints. Nothing pins post prose, so this render is the only thing standing between a broken fence and production, and website/test/ssr/docs-links.test.ts validates /docs/... links only, so the new /blog/... cross-link is checked here or nowhere.
  5. Re-run the CSP probe empirically rather than trusting a recorded result. Boot a throwaway app with "webjs": { "csp": true } carrying a page-level inline <style>, a style="..." attribute, a light-DOM component with a <style>, and a shadow component with static styles, and no Tailwind anywhere. Expect four <style> tags with exactly one nonce, on the framework's head rule. Keep the scratch app outside the repo with node_modules symlinked in.
  6. Full suites after the review cycle. Browser, e2e and Bun layers are N/A for a prose change with a comment-only source edit, stated as such in the PR body.

Doc surfaces

  • blog/ai-first-is-plumbing.md, blog/light-dom-by-default.md, blog/stop-ai-agents-breaking-your-code.md, blog/strip-types-not-esbuild.md, blog/client-router-turbo-drive-style.md.
  • packages/server/src/csp.js.
  • The docs site does NOT repeat any of these claims. website/app/docs/conventions/page.ts:34 describes the checker's narrowness without enumerating rules or printing a count, and website/app/docs/troubleshooting/page.ts names only real rules. No light-dom-css-prefix or stale-esbuild reference exists under website/app/docs/. So no docs-site edit is owed, and none should be invented.
  • AGENTS.md and .agents/skills/webjs/ need no change. Invariant 7 already states the tag prefix as a convention and invariant 10 already states there is no bundler fallback, which is what makes the blog posts wrong rather than the reference docs.

Implementation notes (for the implementing agent)

Landmines.

  • Every prose file here is under .claude/hooks/block-prose-punctuation.sh. No em-dashes, no space-hyphen-space or space-semicolon-space as pause punctuation, and WebJs capitalized in prose except as a literal code token. The hook scans new content only, so an existing line can be edited freely.
  • The hook's colon rule is narrower than it looks, and this was checked against the script rather than assumed. It fires only on )</code>:, on a hyphenated <my-tag>:, and on a comment line shaped like foo(): prose. A markdown backtick span holding a plain identifier matches none of them, so ai-first-is-plumbing.md keeps its existing `rule-name`: description list style and the replacement bullet joins it in that form, rather than restyling five correct lines for no reason. The comment-line form DOES bind the csp.js edit, so no new comment line there may be shaped someFn(): prose.
  • No internal issue or PR numbers in published blog prose. blog/strip-types-not-esbuild.md:50 already carries a PR #9 reference; leaving it is fine since it is existing content, but do not add more.
  • Do not write a bare webjs dev or webjs start into user-facing prose. webjs check and webjs check --rules are fine and already appear in both posts.
  • Do not frame Tailwind as something WebJs is coupled to. In the CSP comment it becomes absent entirely, and in light-dom-by-default.md it stays a recommended default rather than a requirement.
  • Step 5 and 6 touch packages/server/src/**, which trips .claude/hooks/require-docs-with-src.sh and .claude/hooks/require-tests-with-src.sh. The blog edits in the same commit should satisfy the doc gate; WEBJS_NO_DOC_GATE=1 is the documented bypass for a comment-only change with no behaviour change. csp.js is not on the Bun-parity hook's runtime-sensitive list, so no test/bun/** addition is needed for a comment.
  • The corpus cross-references itself. After editing, grep blog/ and website/app/docs/ for esbuild, light-dom-css-prefix, and unsafe-inline. Five mentions are correct and must not be "fixed": website/app/docs/no-build/page.ts:156 (publish-time bundling of the @webjsdev/core dist), blog/no-build-via-jspm-io.md (esbuild as removed history), blog/full-stack-type-safety-no-build.md:21 and blog/fix-relative-import-paths.md:39 (esbuild as a generic example of a build step), and blog/strict-csp-nonce-across-soft-navigation.md:40 (unsafe-inline inside a real header dump).

Invariants to respect. AGENTS.md invariant 7 (the light-DOM tag prefix is a convention, not a check rule) is the fact steps 2 and 3 turn on. Invariant 10 (erasable TypeScript, no bundler fallback) is the fact step 4 turns on.

Commit shape. One logical unit, one commit, docs: subject, in its own worktree cut from origin/main. A docs: prefix keeps it out of the changelog, which is right for a prose correction.

Acceptance criteria

  • blog/ai-first-is-plumbing.md no longer names a rule that is absent from RULES, states no rule count, and no longer claims a per-rule line count
  • blog/light-dom-by-default.md no longer promises a light-dom-css-prefix check rule
  • blog/stop-ai-agents-breaking-your-code.md no longer names light-dom-css-prefix, and every rule it names is in RULES
  • blog/strip-types-not-esbuild.md describes the real strip path (built-in stripper plus amaro, no bundler fallback) instead of an esbuild fallback, and its cache snippet names no identifier that does not exist
  • blog/client-router-turbo-drive-style.md no longer lists prefetching or per-navigation full refetch as router non-goals, keeps the View Transitions non-goal, and links the prefetch post
  • The packages/server/src/csp.js comment explains the style-src allowance by all four things that lack a nonce, names no styling library, and states that the framework's own head style IS nonce-signed
  • packages/server/test/csp/csp.test.js still passes with no edits to it, and the csp.js diff touches no directive string
  • Every rule name surviving anywhere in blog/ is present in RULES, asserted by importing the module rather than by eye
  • A grep across blog/ and website/app/docs/ confirms no other page states any old version, and the five legitimate esbuild / unsafe-inline mentions are still intact
  • The website serves each edited post at 200 with no broken modulepreload

Metadata

Metadata

Assignees

Labels

documentationImprovements or additions to documentation

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions