@webjsdev/cli 0.10.50 → 0.10.52

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.
Files changed (51) hide show
  1. package/README.md +3 -1
  2. package/bin/webjs.js +437 -32
  3. package/lib/api-gallery.js +6 -7
  4. package/lib/app-name.js +208 -0
  5. package/lib/create.js +37 -9
  6. package/lib/doctor.js +566 -21
  7. package/package.json +2 -2
  8. package/templates/.agents/rules/workflow.md +9 -1
  9. package/templates/.agents/skills/webjs/SKILL.md +26 -11
  10. package/templates/.agents/skills/webjs/references/auth-and-sessions.md +2 -2
  11. package/templates/.agents/skills/webjs/references/built-ins.md +26 -7
  12. package/templates/.agents/skills/webjs/references/client-router-and-streaming.md +16 -2
  13. package/templates/.agents/skills/webjs/references/components.md +59 -2
  14. package/templates/.agents/skills/webjs/references/data-and-actions.md +92 -7
  15. package/templates/.agents/skills/webjs/references/muscle-memory-gotchas.md +75 -19
  16. package/templates/.agents/skills/webjs/references/optimistic-ui.md +35 -14
  17. package/templates/.agents/skills/webjs/references/routing-and-pages.md +34 -15
  18. package/templates/.agents/skills/webjs/references/runtime.md +5 -1
  19. package/templates/.agents/skills/webjs/references/styling.md +1 -1
  20. package/templates/.agents/skills/webjs/references/testing.md +80 -3
  21. package/templates/.agents/skills/webjs/references/typescript.md +71 -2
  22. package/templates/.agents/skills/webjs/references/ui-kit.md +5 -2
  23. package/templates/.github/pull_request_template.md +1 -0
  24. package/templates/.github/workflows/ci.yml +13 -0
  25. package/templates/AGENTS.md +31 -5
  26. package/templates/CONVENTIONS.md +4 -1
  27. package/templates/gallery/app/examples/layout.ts +2 -1
  28. package/templates/gallery/app/examples/todo/page.ts +3 -16
  29. package/templates/gallery/app/features/auth/dashboard/layout.ts +2 -1
  30. package/templates/gallery/app/features/auth/signup/page.ts +4 -23
  31. package/templates/gallery/app/features/caching/page.ts +6 -6
  32. package/templates/gallery/app/features/file-storage/page.ts +8 -19
  33. package/templates/gallery/app/features/forms/page.ts +12 -38
  34. package/templates/gallery/app/features/layout.ts +6 -2
  35. package/templates/gallery/app/features/route-handler/data/route.ts +2 -1
  36. package/templates/gallery/app/features/view-transitions/page.ts +1 -1
  37. package/templates/gallery/app/global-error.ts +7 -4
  38. package/templates/gallery/modules/async-render/components/server-clock.ts +3 -2
  39. package/templates/gallery/modules/auth/actions/signup.server.ts +27 -11
  40. package/templates/gallery/modules/file-storage/actions/store-upload.server.ts +21 -10
  41. package/templates/gallery/modules/forms/actions/send-message.server.ts +34 -0
  42. package/templates/gallery/modules/gallery/nav.ts +1 -1
  43. package/templates/gallery/modules/server-actions/actions/greet.test.ts +6 -5
  44. package/templates/gallery/modules/todo/actions/submit-todo.server.ts +33 -0
  45. package/templates/gallery/modules/todo/components/todo-app.ts +8 -5
  46. package/templates/gallery/modules/todo/types.ts +15 -10
  47. package/templates/gallery/test/auth/auth.test.ts +31 -16
  48. package/templates/partials/agents-playbook-api.md +5 -0
  49. package/templates/partials/agents-playbook-fullstack.md +5 -0
  50. package/templates/scripts/clear-gallery.mjs +5 -4
  51. package/templates/test/hello/e2e/hello.test.ts +18 -1
@@ -13,8 +13,7 @@ import { join } from 'node:path';
13
13
  /**
14
14
  * Write the api backend-features gallery into `<appDir>/app/api/features/**`
15
15
  * plus a boot-time env-validation example at `app/env.ts`. Each demo carries a
16
- * `webjs-scaffold-placeholder` marker so `webjs check` fails until it is
17
- * pruned or adapted.
16
+ * comment naming exactly what to delete when it is pruned.
18
17
  * @param {string} appDir
19
18
  */
20
19
  export async function writeApiGallery(appDir) {
@@ -35,7 +34,7 @@ export async function writeApiGallery(appDir) {
35
34
  ].join('\n'));
36
35
  await mkdir(feat('validate'), { recursive: true });
37
36
  await writeFile(feat('validate', 'route.ts'), [
38
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/validate route AND modules/widgets), then delete this marker line. webjs check fails while the marker remains.",
37
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/validate route AND modules/widgets).",
39
38
  "// route() turns a 'use server' action into a REST endpoint: it merges the URL",
40
39
  "// query, route params, and JSON body into one input, runs `validate` at the",
41
40
  "// boundary (a { success:false, fieldErrors } return is a 422, no action call),",
@@ -76,7 +75,7 @@ export async function writeApiGallery(appDir) {
76
75
  "",
77
76
  ].join('\n'));
78
77
  await writeFile(feat('rate-limit', 'route.ts'), [
79
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/rate-limit route), then delete this marker line. webjs check fails while the marker remains.",
78
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/rate-limit route).",
80
79
  "// The middleware.ts beside this file stamps X-RateLimit-* headers and returns",
81
80
  "// a 429 with Retry-After once the window is exhausted, so this handler never",
82
81
  "// runs on a limited request. Call it six times in ten seconds to see the 429.",
@@ -89,7 +88,7 @@ export async function writeApiGallery(appDir) {
89
88
  // 3) Streaming response (chunks flushed as produced, no buffering).
90
89
  await mkdir(feat('stream'), { recursive: true });
91
90
  await writeFile(feat('stream', 'route.ts'), [
92
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/stream route), then delete this marker line. webjs check fails while the marker remains.",
91
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/stream route).",
93
92
  "// Streams JSON-per-line chunks as they are produced, so a slow or large",
94
93
  "// result is delivered incrementally instead of buffered whole. A hand-written",
95
94
  "// route.ts returns a ReadableStream for full control. Served as text/plain so",
@@ -115,7 +114,7 @@ export async function writeApiGallery(appDir) {
115
114
  // 4) File storage (upload + serve), both hand-written route.ts.
116
115
  await mkdir(feat('files', '[key]'), { recursive: true });
117
116
  await writeFile(feat('files', 'route.ts'), [
118
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/files route), then delete this marker line. webjs check fails while the marker remains.",
117
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/files route).",
119
118
  "// POST a multipart file: the bytes stream into the FileStore (a local",
120
119
  "// .webjs/uploads dir by default, gitignored; swap for S3/R2 with one",
121
120
  "// setFileStore() call). Returns the key + a URL served by [key]/route.ts.",
@@ -166,7 +165,7 @@ export async function writeApiGallery(appDir) {
166
165
  // 5) WebSocket endpoint with broadcast fan-out.
167
166
  await mkdir(feat('ws'), { recursive: true });
168
167
  await writeFile(feat('ws', 'route.ts'), [
169
- "// webjs-scaffold-placeholder. API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/ws route), then delete this marker line. webjs check fails while the marker remains.",
168
+ "// API backend-features demo. Keep and adapt it, or prune it (delete this app/api/features/ws route).",
170
169
  "// A WebSocket endpoint: exporting WS(ws, req) upgrades this route to a socket.",
171
170
  "// The framework auto-registers each connection to its path, so broadcast()",
172
171
  "// fans a message out to every connected client. Connect two clients to",
@@ -0,0 +1,208 @@
1
+ /**
2
+ * App-name validation for `webjs create <name>` (issue #1066).
3
+ *
4
+ * The name a user passes to `webjs create` is not just a directory name. It is
5
+ * ALSO interpolated into generated source as a template-literal value (the
6
+ * `metadata.title` in `app/page.ts`, the `name` field of the api template's
7
+ * root route handler, the `{{APP_NAME}}` substitution in every copied template
8
+ * file) and written verbatim into the generated `package.json` `name` field. A
9
+ * name carrying a quote, a backtick, a `${`, or a backslash therefore breaks
10
+ * the emitted file at the JS/TS syntax level, and the failure surfaces as a
11
+ * parse error on the very first `npm run dev` of the fresh app, far from its
12
+ * actual cause.
13
+ *
14
+ * Rather than escape at each interpolation site (an open-ended list that grows
15
+ * every time the scaffold emits a new file), the name is validated ONCE at the
16
+ * boundary, before any file is written. The rule is npm's package-name rules
17
+ * MINUS the lowercase-only clause (see `ALLOWED_CHAR`), which is still strictly
18
+ * narrower than every interpolation site needs, so a name that passes is safe
19
+ * everywhere the scaffold puts it. `npm init` and `create-next-app` do refuse
20
+ * uppercase; this deliberately does not, because the scaffold's manifest is
21
+ * private and a capital letter breaks nothing.
22
+ *
23
+ * This module is pure and imports nothing, so both the CLI entry
24
+ * (`bin/webjs.js`) and the programmatic entry (`scaffoldApp` in `lib/create.js`)
25
+ * can share one rule.
26
+ */
27
+
28
+ /** npm's hard cap on a package name. */
29
+ export const APP_NAME_MAX_LENGTH = 214;
30
+
31
+ /** One-line description of the allowed shape, reused by both error surfaces. */
32
+ export const APP_NAME_SHAPE =
33
+ 'letters, digits, and the separators "-", "." and "_", starting with a letter or a digit, at most 214 characters, and not a name npm reserves';
34
+
35
+ /**
36
+ * npm rejects these outright, whatever else the name looks like. Matched
37
+ * case-insensitively, since the name is also a directory and `Node_Modules`
38
+ * collides with `node_modules` on a case-insensitive filesystem.
39
+ * @type {string[]}
40
+ */
41
+ const RESERVED_NAMES = ['node_modules', 'favicon.ico'];
42
+
43
+ /**
44
+ * Every character allowed in an app name.
45
+ *
46
+ * npm's own rule for a PUBLISHED package name is lowercase-only, and this guard
47
+ * followed it at first. Uppercase is allowed back deliberately: it never broke
48
+ * anything. The scaffold's `package.json` is `private: true`, so the name is
49
+ * never published, and npm installs a capitalized private package without
50
+ * complaint (checked, not assumed). Every OTHER character this rule refuses
51
+ * corrupts generated source or the manifest, which is what the guard is for, so
52
+ * refusing a capital letter alongside them would have been a naming convention
53
+ * wearing a correctness costume, and `webjs create MyApp` worked before.
54
+ */
55
+ const ALLOWED_CHAR = /[A-Za-z0-9._-]/;
56
+
57
+ /** What a name may start with, per the shape both error surfaces state. */
58
+ const ALLOWED_FIRST_CHAR = /[A-Za-z0-9]/;
59
+
60
+ /**
61
+ * How much of the rejected name to echo back. The name is attacker-shaped by
62
+ * definition here (it is the thing that just failed validation), and it is
63
+ * echoed into a terminal, so it is capped rather than printed whole.
64
+ */
65
+ const DISPLAY_MAX_LENGTH = 48;
66
+
67
+ /**
68
+ * Render the rejected name for an error message. Two things it must not do:
69
+ * break the message onto a second line (a thrown Error is read programmatically
70
+ * and documented as single-line, and a newline is a name `checkAppName`
71
+ * rejects), and replay a control sequence into the terminal it is printed to (a
72
+ * name of `\x1b[31m...` would otherwise repaint the user's shell). So every
73
+ * character with no safe visible form is named by code point, and the result is
74
+ * capped so a 214-character name cannot produce a 240-column line.
75
+ * @param {unknown} name
76
+ * @returns {string}
77
+ */
78
+ function describeName(name) {
79
+ const raw = typeof name === 'string' ? name : String(name);
80
+ let out = '';
81
+ for (const ch of raw) {
82
+ const code = ch.codePointAt(0) ?? 0;
83
+ out += isUnprintable(code)
84
+ ? `<U+${code.toString(16).toUpperCase().padStart(4, '0')}>`
85
+ : ch;
86
+ }
87
+ return out.length > DISPLAY_MAX_LENGTH ? `${out.slice(0, DISPLAY_MAX_LENGTH)}...` : out;
88
+ }
89
+
90
+ /**
91
+ * Whether a code point has no safe visible form in an error message. C0 and
92
+ * DEL are the obvious set. Two additions matter here: the C1 range, which is
93
+ * where an 8-bit terminal reads control functions, and U+2028 / U+2029, which
94
+ * are JS LineTerminators, so they break the single-line contract exactly the
95
+ * way `\n` does while sailing past any `split('\n')` that claims to check it.
96
+ * @param {number} code
97
+ * @returns {boolean}
98
+ */
99
+ function isUnprintable(code) {
100
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f) || code === 0x2028 || code === 0x2029;
101
+ }
102
+
103
+ /**
104
+ * Render a character for an error message. A control character has no visible
105
+ * form, so name it by code point instead of printing it. The quote delimiter is
106
+ * picked to avoid the character itself, so a rejected apostrophe does not print
107
+ * as the unreadable `'''`.
108
+ * @param {string} ch
109
+ * @returns {string}
110
+ */
111
+ function describeChar(ch) {
112
+ const code = ch.codePointAt(0) ?? 0;
113
+ if (isUnprintable(code)) {
114
+ return `the control character U+${code.toString(16).toUpperCase().padStart(4, '0')}`;
115
+ }
116
+ if (ch === ' ') return 'a space';
117
+ const q = ch === "'" ? '"' : "'";
118
+ return `${q}${ch}${q}`;
119
+ }
120
+
121
+ /**
122
+ * Pure validator. Returns the first problem it finds, phrased as a sentence
123
+ * fragment that reads after "app name ... because".
124
+ *
125
+ * @param {unknown} name the raw name as typed
126
+ * @returns {{ ok: true } | { ok: false, reason: string }}
127
+ */
128
+ export function checkAppName(name) {
129
+ // A non-string reaches here only from a programmatic caller, and calling it
130
+ // "empty" states something false about the input (a `123` is not empty), which
131
+ // is worse than no message at all when the reader is debugging their own call.
132
+ if (typeof name !== 'string') {
133
+ return { ok: false, reason: `an app name must be a string (this one is a ${typeof name})` };
134
+ }
135
+ if (name.length === 0) {
136
+ return { ok: false, reason: 'an app name cannot be empty' };
137
+ }
138
+ if (name.trim() !== name) {
139
+ return { ok: false, reason: 'an app name cannot start or end with whitespace' };
140
+ }
141
+ if (name.length > APP_NAME_MAX_LENGTH) {
142
+ return {
143
+ ok: false,
144
+ reason: `an app name cannot be longer than ${APP_NAME_MAX_LENGTH} characters (this one is ${name.length})`,
145
+ };
146
+ }
147
+ if (RESERVED_NAMES.includes(name.toLowerCase())) {
148
+ return { ok: false, reason: `'${name}' is reserved by npm and cannot be a package name` };
149
+ }
150
+ // Report the first offending character, since that is what the user has to
151
+ // change.
152
+ for (const ch of name) {
153
+ if (ALLOWED_CHAR.test(ch)) continue;
154
+ return { ok: false, reason: `${describeChar(ch)} is not allowed in an app name` };
155
+ }
156
+ // Checked after the character scan so a leading quote is reported as the bad
157
+ // character rather than as a leading-separator problem. Every separator is
158
+ // refused here, not just the leading dot and underscore npm itself refuses:
159
+ // the shape both error surfaces state is "starting with a letter or a digit",
160
+ // and a rule the message claims but does not enforce is worse than either
161
+ // rule on its own. A leading hyphen also reads as a flag everywhere the name
162
+ // is later used as a directory.
163
+ if (!ALLOWED_FIRST_CHAR.test(name[0])) {
164
+ return { ok: false, reason: `an app name cannot start with ${describeChar(name[0])}` };
165
+ }
166
+ return { ok: true };
167
+ }
168
+
169
+ /**
170
+ * The multi-line message the CLI prints. Kept here so the programmatic throw
171
+ * and the CLI output agree on the rule they state.
172
+ * @param {unknown} name
173
+ * @param {string} reason
174
+ * @returns {string}
175
+ */
176
+ export function appNameErrorMessage(name, reason) {
177
+ return `Error: invalid app name "${describeName(name)}".
178
+
179
+ ${reason[0].toUpperCase()}${reason.slice(1)}.
180
+
181
+ The name becomes the app's directory, its package.json name, AND a value the
182
+ scaffold writes into generated source, so it is restricted to:
183
+
184
+ letters and digits
185
+ the separators "-", "." and "_"
186
+ starting with a letter or a digit
187
+ at most ${APP_NAME_MAX_LENGTH} characters
188
+ not ${RESERVED_NAMES.map((n) => `"${n}"`).join(' or ')} (npm reserves both)
189
+
190
+ Example: webjs create my-app`;
191
+ }
192
+
193
+ /**
194
+ * Convenience wrapper for callers that want an exception. Throws with a
195
+ * single-line message (a thrown Error is read programmatically, so it stays
196
+ * compact) and returns the validated name otherwise.
197
+ * @param {unknown} name
198
+ * @returns {string}
199
+ */
200
+ export function assertValidAppName(name) {
201
+ const result = checkAppName(name);
202
+ if (!result.ok) {
203
+ throw new Error(
204
+ `Invalid app name '${describeName(name)}'. ${result.reason[0].toUpperCase()}${result.reason.slice(1)}. Allowed: ${APP_NAME_SHAPE}.`,
205
+ );
206
+ }
207
+ return /** @type {string} */ (name);
208
+ }
package/lib/create.js CHANGED
@@ -18,6 +18,7 @@ import { existsSync } from 'node:fs';
18
18
  import { createRequire } from 'node:module';
19
19
  import { spawnSync } from 'node:child_process';
20
20
  import { bunifyProse, bunifyDockerfile, bunifyCompose, bunifyCi } from './runtime-rewrite.js';
21
+ import { assertValidAppName } from './app-name.js';
21
22
 
22
23
  /**
23
24
  * Detect which package manager invoked us. Reads `npm_config_user_agent`,
@@ -166,8 +167,11 @@ async function writeUiBootstrap(appDir) {
166
167
  );
167
168
  await writeFile(join(appDir, 'lib', 'utils', 'dom.ts'), domContent);
168
169
 
169
- // 2) components.json: the same shape `webjsui init` writes for webjs
170
- // projects (see packages/ui/src/utils/detect-project.js). The utils alias
170
+ // 2) components.json: byte for byte what `webjsui init` writes (see the
171
+ // DEFAULT_ALIASES / DEFAULT_TAILWIND_CSS constants in
172
+ // packages/ui/src/commands/init.js, which #1129 made the single source of
173
+ // these values). Keep the two in step: an app that scaffolds and one that
174
+ // runs `webjs ui init` must end up with the same config. The utils alias
171
175
  // is lib/utils/cn so get-config.js's `+ '.ts'` resolves to lib/utils/cn.ts.
172
176
  // The theme CSS lives at styles/globals.css, NOT app/globals.css: app/ is
173
177
  // routing-only, so a non-routing stylesheet does not belong there.
@@ -255,9 +259,15 @@ function assertUiRegistryAvailable() {
255
259
  * @param {string} cwd Current working directory
256
260
  */
257
261
  export async function scaffoldApp(name, cwd, opts = {}) {
262
+ // Defence in depth, same as the template check below. `webjs create` already
263
+ // validates the name, but a programmatic caller can pass anything, and the
264
+ // name is interpolated into generated source as a template-literal value
265
+ // (#1066), so an unvalidated quote / backtick / `${` would emit a file that
266
+ // fails to parse. Throwing here happens before any directory is created.
267
+ assertValidAppName(name);
258
268
  const template = opts.template || 'full-stack';
259
- // A human-friendly display title for the example home page. The npm `name`
260
- // stays the raw slug (lowercase, hyphenated), but showing a hyphenated slug as
269
+ // A human-friendly display title for the example home page. The package
270
+ // `name` stays the raw slug as typed, but showing a hyphenated slug as
261
271
  // a hero title looks unpolished, so title-case it for display ("my-app" ->
262
272
  // "My App"). Replace this with your real brand anyway.
263
273
  const displayName = name.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
@@ -388,10 +398,12 @@ export async function scaffoldApp(name, cwd, opts = {}) {
388
398
  'test:browser': 'webjs test --browser',
389
399
  check: 'webjs check',
390
400
  typecheck: 'webjs typecheck',
391
- // Onboarding/setup-verify: a contributor runs `npm run doctor` after
392
- // cloning to assert the toolchain (Node floor, tsconfig flag, env drift,
393
- // vendor pins, @webjsdev versions, git hook). Local tool, NOT a CI gate
394
- // (its env-drift + network pin-freshness checks would make CI flaky).
401
+ // Project health: a contributor runs `npm run doctor` after cloning to
402
+ // assert the toolchain (Node floor, tsconfig flag, env drift, vendor
403
+ // pins, @webjsdev versions, git hook), and CI runs the same script. Which
404
+ // findings are FATAL comes from the `webjs.doctor.gate` block below, so
405
+ // the environment-shaped checks (env drift, pin freshness over the
406
+ // network, the git hook) stay warns and cannot make CI flaky.
395
407
  doctor: 'webjs doctor',
396
408
  'db:generate': 'webjs db generate',
397
409
  'db:migrate': 'webjs db migrate',
@@ -481,6 +493,17 @@ export async function scaffoldApp(name, cwd, opts = {}) {
481
493
  }),
482
494
  },
483
495
  start: { before: isApi ? ['webjs db migrate'] : ['webjs db migrate', cssBuildCmd] },
496
+ // Which doctor findings are FATAL is the app's own call (#1257), declared
497
+ // here rather than in the CI workflow so `npm run doctor` locally and the
498
+ // workflow step agree about what fails. UNMARKED_ASSET_LINKS starts at
499
+ // error because an un-versioned /public url is a real deploy-staleness
500
+ // bug (it shipped a visible regression on webjs.dev) and the generated
501
+ // layout already writes asset(), so a fresh app is green on day one.
502
+ // Two checks are fatal with no entry here at all, NODE_VERSION and
503
+ // TSCONFIG_ERASABLE, because either would 500 the app at runtime;
504
+ // everything else keeps its default warn. Add a code with "off" to
505
+ // silence it, or "error" to make it fatal too.
506
+ doctor: { gate: { UNMARKED_ASSET_LINKS: 'error' } },
484
507
  },
485
508
  }, null, 2) + '\n');
486
509
 
@@ -1149,6 +1172,7 @@ ${uiThemeRaw}
1149
1172
  await copyGallery(appDir);
1150
1173
 
1151
1174
  await writeFile(join(appDir, 'app', 'layout.ts'), `import { html, cspNonce, asset } from '@webjsdev/core';
1175
+ import type { LayoutProps } from '@webjsdev/core';
1152
1176
  import '#components/theme-toggle.ts';
1153
1177
 
1154
1178
  /**
@@ -1167,7 +1191,11 @@ import '#components/theme-toggle.ts';
1167
1191
  // lives at public/favicon.svg and serves at /public/favicon.svg.
1168
1192
  export const metadata = { icons: '/public/favicon.svg' };
1169
1193
 
1170
- export default function RootLayout({ children }: { children: unknown }) {
1194
+ // LayoutProps types every layout argument (children, params, searchParams,
1195
+ // url) from the framework, so children is a TemplateResult rather than an
1196
+ // untyped value. Derive types like this everywhere instead of widening to
1197
+ // unknown; see .agents/skills/webjs/references/typescript.md.
1198
+ export default function RootLayout({ children }: LayoutProps) {
1171
1199
  // Read the in-flight request's CSP nonce so the theme-detection inline script
1172
1200
  // passes strict CSP. Returns '' when no CSP nonce is set.
1173
1201
  const nonce = cspNonce();