@stacksjs/stx 0.2.174 → 0.2.176

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.
@@ -2,9 +2,9 @@
2
2
  * Pull the script blocks out of a `.stx` source, with the line each body starts on.
3
3
  *
4
4
  * `<script server>` and `<script client>` are recognised by attribute. A bare
5
- * `<script>` is reported as `plain`: it ships to the browser verbatim without
6
- * the auto-import preamble, so it is a different checking context and callers
7
- * may want to skip it.
5
+ * `<script>` is reported as `plain` so a caller can tell which tag the author
6
+ * actually wrote — NOT so it can be skipped. It is a client block; `client` is
7
+ * an explicit alias for the default, not a separate kind (stacksjs/stx#1920).
8
8
  */
9
9
  export declare function extractScriptBlocks(source: string): ScriptBlock[];
10
10
  /**
@@ -45,6 +45,30 @@ export declare function substituteInterpolations(value: string): string;
45
45
  * fit and would shift the line.
46
46
  */
47
47
  export declare function substituteInterpolationsInPlace(code: string): string;
48
+ /**
49
+ * Blank stx directives that a script body writes to generate JavaScript.
50
+ *
51
+ * A template can loop inside a script — building a lookup table from server
52
+ * data is the obvious use — and the directive is expanded away long before the
53
+ * browser sees it:
54
+ *
55
+ * const data = {
56
+ * &#64;foreach(features as f)
57
+ * '{{ f.id }}': { title: '{{ f.title }}' },
58
+ * &#64;endforeach
59
+ * }
60
+ *
61
+ * To TypeScript the directive line is a syntax error, and one of those mutes
62
+ * semantic checking for the WHOLE program, not just the file. So checking bare
63
+ * `<script>` blocks (#1920) made a valid page report 37 invented syntax errors
64
+ * — and a checker that invents errors gets muted, which is the failure mode
65
+ * this file exists to avoid.
66
+ *
67
+ * Blanked rather than removed so every line number after it still points at the
68
+ * line the author wrote. Only a directive ALONE on its line is matched: that is
69
+ * how they are written, and it cannot swallow code sharing the line.
70
+ */
71
+ export declare function blankScriptDirectives(code: string): string;
48
72
  /**
49
73
  * Every checkable expression in the markup, with the position it occupies.
50
74
  *
@@ -105,6 +105,42 @@ export function substituteInterpolationsInPlace(code) {
105
105
  `.repeat(newlines)}`;
106
106
  });
107
107
  }
108
+ const SCRIPT_BODY_DIRECTIVES = [
109
+ "foreach",
110
+ "endforeach",
111
+ "forelse",
112
+ "endforelse",
113
+ "for",
114
+ "endfor",
115
+ "while",
116
+ "endwhile",
117
+ "empty",
118
+ "if",
119
+ "elseif",
120
+ "else",
121
+ "endif",
122
+ "unless",
123
+ "endunless",
124
+ "isset",
125
+ "endisset",
126
+ "switch",
127
+ "case",
128
+ "default",
129
+ "endswitch",
130
+ "auth",
131
+ "endauth",
132
+ "guest",
133
+ "endguest",
134
+ "env",
135
+ "endenv",
136
+ "once",
137
+ "endonce",
138
+ "break",
139
+ "continue"
140
+ ], SCRIPT_BODY_DIRECTIVE_RE = new RegExp(String.raw`^[ \t]*@(?:${SCRIPT_BODY_DIRECTIVES.join("|")})\b[ \t]*(\([^\n)]*\))?[ \t]*$`, "gm");
141
+ export function blankScriptDirectives(code) {
142
+ return code.replace(SCRIPT_BODY_DIRECTIVE_RE, (match) => match.replace(/[^\n]/g, " "));
143
+ }
108
144
  const NON_EXPRESSION_ATTRS = new Set([
109
145
  "x-cloak",
110
146
  "x-ref",
package/dist/typecheck.js CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  clientPayloadDeclarations,
10
10
  scrapedBridgeNames,
11
11
  serverContextDeclarations,
12
+ blankScriptDirectives,
12
13
  substituteInterpolationsInPlace
13
14
  } from "./stx-virtual-ts.js";
14
15
  export {
@@ -17,12 +18,27 @@ export {
17
18
  } from "./stx-virtual-ts.js";
18
19
  export function buildVirtualSource(block, serverCode = "") {
19
20
  const leadingNewlines = block.startLine > 1 ? `
20
- `.repeat(block.startLine - 1) : "", code = substituteInterpolationsInPlace(block.code), payload = block.kind === "client" || block.kind === "plain" ? clientPayloadDeclarations(serverCode) : "";
21
+ `.repeat(block.startLine - 1) : "", code = blankScriptDirectives(substituteInterpolationsInPlace(block.code)), payload = block.kind === "client" || block.kind === "plain" ? clientPayloadDeclarations(serverCode) : "";
21
22
  return `${leadingNewlines + code}
22
23
  ${payload}
23
24
  export {}
24
25
  `;
25
26
  }
27
+ function virtualStxModuleDeclaration(files) {
28
+ const anchor = files.length > 0 ? path.dirname(path.resolve(files[0])) : process.cwd();
29
+ try {
30
+ Bun.resolveSync("@stacksjs/stx", anchor);
31
+ } catch {
32
+ return "";
33
+ }
34
+ return [
35
+ "// Generated \u2014 `stx` is the virtual specifier client-script.ts strips.",
36
+ "declare module 'stx' {",
37
+ " export * from '@stacksjs/stx'",
38
+ "}"
39
+ ].join(`
40
+ `);
41
+ }
26
42
  export function virtualPathFor(filePath, kind, index) {
27
43
  return `${filePath}.__stx_${kind}${index}.ts`;
28
44
  }
@@ -39,6 +55,9 @@ export function findRuntimeTypeDeclarations() {
39
55
  return null;
40
56
  }
41
57
  const TSC_LINE_RE = /^(.*?)\((\d+),(\d+)\):\s+(error|warning)\s+TS(\d+):\s+(.*)$/;
58
+ function isSyntactic(code) {
59
+ return code >= 1000 && code < 2000;
60
+ }
42
61
  function readProjectPathAliases(startDir) {
43
62
  let dir = path.resolve(startDir), configPath;
44
63
  for (;; ) {
@@ -130,15 +149,13 @@ export async function typecheckStxFiles(files, options = {}) {
130
149
  const source = await Bun.file(file).text(), blocks = extractScriptBlocks(source).filter((b) => {
131
150
  if (b.kind === "server")
132
151
  return checkServer;
133
- if (b.kind === "client")
134
- return checkClient;
135
- return !1;
152
+ return checkClient;
136
153
  }), serverCode = extractScriptBlocks(source).filter((block) => block.kind === "server").map((block) => block.code).join(`
137
154
  `);
138
155
  if (checkClient && serverCode) {
139
- const clientBlocks = blocks.filter((block) => block.kind === "client"), names = [...new Set(clientBlocks.flatMap((block) => scrapedBridgeNames(serverCode, block.code)))];
156
+ const clientBlocks = blocks.filter((block) => block.kind !== "server"), names = [...new Set(clientBlocks.flatMap((block) => scrapedBridgeNames(serverCode, block.code)))];
140
157
  if (names.length > 0)
141
- scrapedByFile.set(file, { names, line: clientBlocks[0].startLine });
158
+ scrapedByFile.set(file, { names, line: clientBlocks[0].startLine, kind: clientBlocks[0].kind });
142
159
  }
143
160
  const templateBuffer = checkTemplates ? buildVirtualTypeScript(source, { runtimeGlobals: !runtimeTypes }) : null, expressions = templateBuffer ? [...templateBuffer.lineMap.values()].filter((m) => m.expression).length : 0;
144
161
  if (blocks.length === 0 && expressions === 0)
@@ -169,8 +186,9 @@ export async function typecheckStxFiles(files, options = {}) {
169
186
  const globalsDts = `${stateDir()}/typecheck/__stx_globals.d.ts`, globalDecls = [
170
187
  "// Generated \u2014 the context serve.ts injects into <script server> blocks.",
171
188
  serverContextDeclarations(),
172
- ...runtimeTypes ? [] : ["declare const window: any", ...STX_RUNTIME_GLOBALS.map((name) => `declare const ${name}: any`)]
173
- ].join(`
189
+ ...runtimeTypes ? [] : ["declare const window: any", ...STX_RUNTIME_GLOBALS.map((name) => `declare const ${name}: any`)],
190
+ virtualStxModuleDeclaration(files)
191
+ ].filter(Boolean).join(`
174
192
  `), workDir = `${stateDir()}/typecheck`;
175
193
  await Bun.$`rm -rf ${workDir}`.quiet().nothrow();
176
194
  await Bun.$`mkdir -p ${workDir}`.quiet().nothrow();
@@ -182,64 +200,80 @@ export async function typecheckStxFiles(files, options = {}) {
182
200
  await Bun.write(`${workDir}/${safe}`, meta.source);
183
201
  writtenToOrigin.set(safe, meta);
184
202
  }
185
- const tsconfig = {
186
- compilerOptions: {
187
- target: "ESNext",
188
- module: "ESNext",
189
- moduleResolution: "bundler",
190
- strict: !0,
191
- noEmit: !0,
192
- skipLibCheck: !0,
193
- allowJs: !0,
194
- esModuleInterop: !0,
195
- resolveJsonModule: !0,
196
- lib: ["ESNext", "DOM", "DOM.Iterable"],
197
- types: [],
198
- ...readProjectPathAliases(files.length > 0 ? path.dirname(path.resolve(files[0])) : process.cwd()),
199
- ...options.compilerOptions ?? {}
200
- },
201
- files: [...writtenToOrigin.keys()].map((name) => `${workDir}/${name}`).concat(globalsDts, ...ambient)
202
- }, tsconfigPath = `${workDir}/tsconfig.json`;
203
- await Bun.write(tsconfigPath, JSON.stringify(tsconfig, null, 2));
204
- const proc = Bun.spawnSync(["bun", "x", "tsc", "-p", tsconfigPath, "--pretty", "false"], {
205
- stdout: "pipe",
206
- stderr: "pipe"
207
- }), output = new TextDecoder().decode(proc.stdout) + new TextDecoder().decode(proc.stderr), diagnostics = [], exitCode = proc.exitCode ?? 0;
203
+ const compilerOptions = {
204
+ target: "ESNext",
205
+ module: "ESNext",
206
+ moduleResolution: "bundler",
207
+ strict: !0,
208
+ noEmit: !0,
209
+ skipLibCheck: !0,
210
+ allowJs: !0,
211
+ esModuleInterop: !0,
212
+ resolveJsonModule: !0,
213
+ lib: ["ESNext", "DOM", "DOM.Iterable"],
214
+ types: [],
215
+ ...readProjectPathAliases(files.length > 0 ? path.dirname(path.resolve(files[0])) : process.cwd()),
216
+ ...options.compilerOptions ?? {}
217
+ }, runTsc = async (names, pass) => {
218
+ const tsconfigPath = `${workDir}/tsconfig${pass === 1 ? "" : `.${pass}`}.json`;
219
+ await Bun.write(tsconfigPath, JSON.stringify({
220
+ compilerOptions,
221
+ files: names.map((name) => `${workDir}/${name}`).concat(globalsDts, ...ambient)
222
+ }, null, 2));
223
+ const proc = Bun.spawnSync(["bun", "x", "tsc", "-p", tsconfigPath, "--pretty", "false"], {
224
+ stdout: "pipe",
225
+ stderr: "pipe"
226
+ });
227
+ return {
228
+ output: new TextDecoder().decode(proc.stdout) + new TextDecoder().decode(proc.stderr),
229
+ exitCode: proc.exitCode ?? 0
230
+ };
231
+ }, first = await runTsc([...writtenToOrigin.keys()], 1), output = first.output, diagnostics = [], exitCode = first.exitCode;
208
232
  let printedDiagnostics = 0;
209
233
  const ambientBasenames = new Set(ambient.map((lib) => lib.split("/").pop()));
210
234
  let ambientDiagnostics = 0;
211
- for (const rawLine of output.split(`
235
+ const unparseable = new Set, parseTscOutput = (text) => {
236
+ for (const rawLine of text.split(`
212
237
  `)) {
213
- const m = rawLine.match(TSC_LINE_RE);
214
- if (!m)
215
- continue;
216
- printedDiagnostics++;
217
- if (ambientBasenames.has(m[1].split("/").pop()))
218
- ambientDiagnostics++;
219
- const [, filePart, lineStr, colStr, category, code, message] = m, entry = writtenToOrigin.get(filePart.split("/").pop());
220
- if (!entry)
221
- continue;
222
- let line = Number(lineStr), column = Number(colStr), expression;
223
- if (entry.virtual) {
224
- const at = resolvePosition(entry.virtual, line, column);
225
- if (!at?.expression)
238
+ const m = rawLine.match(TSC_LINE_RE);
239
+ if (!m)
226
240
  continue;
227
- if (Number(code) === 2774)
241
+ printedDiagnostics++;
242
+ if (ambientBasenames.has(m[1].split("/").pop()))
243
+ ambientDiagnostics++;
244
+ const [, filePart, lineStr, colStr, category, code, message] = m, written = filePart.split("/").pop(), entry = writtenToOrigin.get(written);
245
+ if (!entry)
228
246
  continue;
229
- line = at.line;
230
- column = at.column;
231
- expression = at.expression.code.trim();
247
+ if (isSyntactic(Number(code)))
248
+ unparseable.add(written);
249
+ let line = Number(lineStr), column = Number(colStr), expression;
250
+ if (entry.virtual) {
251
+ const at = resolvePosition(entry.virtual, line, column);
252
+ if (!at?.expression)
253
+ continue;
254
+ if (Number(code) === 2774)
255
+ continue;
256
+ line = at.line;
257
+ column = at.column;
258
+ expression = at.expression.code.trim();
259
+ }
260
+ diagnostics.push({
261
+ file: entry.origin,
262
+ line,
263
+ column,
264
+ code: Number(code),
265
+ message,
266
+ category: category === "error" ? "error" : "warning",
267
+ blockKind: entry.kind,
268
+ expression
269
+ });
232
270
  }
233
- diagnostics.push({
234
- file: entry.origin,
235
- line,
236
- column,
237
- code: Number(code),
238
- message,
239
- category: category === "error" ? "error" : "warning",
240
- blockKind: entry.kind,
241
- expression
242
- });
271
+ };
272
+ parseTscOutput(output);
273
+ const firstPassPrinted = printedDiagnostics, firstPassAmbient = ambientDiagnostics;
274
+ if (unparseable.size > 0 && unparseable.size < writtenToOrigin.size) {
275
+ const survivors = [...writtenToOrigin.keys()].filter((name) => !unparseable.has(name));
276
+ parseTscOutput((await runTsc(survivors, 2)).output);
243
277
  }
244
278
  if (checkClient)
245
279
  for (const [file, scraped] of scrapedByFile) {
@@ -252,7 +286,7 @@ export async function typecheckStxFiles(files, options = {}) {
252
286
  code: 0,
253
287
  message: `${scraped.names.length === 1 ? "1 value reaches" : `${scraped.names.length} values reach`} this block on the implicit bridge and ${scraped.names.length === 1 ? "is" : "are"} untyped: ${scraped.names.join(", ")}. Declare them with defineClientPayload({ ${scraped.names.join(", ")} }) in the server block to have them checked.`,
254
288
  category: "warning",
255
- blockKind: "client"
289
+ blockKind: scraped.kind
256
290
  });
257
291
  }
258
292
  const seen = new Set, unique = diagnostics.filter((d) => {
@@ -263,7 +297,7 @@ export async function typecheckStxFiles(files, options = {}) {
263
297
  return !0;
264
298
  });
265
299
  unique.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column);
266
- const brokenLib = ambientDiagnostics > 0 && unique.length === 0, failure = brokenLib || exitCode !== 0 && printedDiagnostics === 0 ? `${brokenLib ? "a file passed with --lib has errors of its own, so nothing in your code was checked" : `tsc exited ${exitCode} without reporting any diagnostic, so nothing was checked`}. ` + "A syntax error in an ambient declaration file does this \u2014 skipLibCheck suppresses " + `SEMANTIC diagnostics only. tsc said:
300
+ const brokenLib = firstPassAmbient > 0 && unique.length === 0, failure = brokenLib || exitCode !== 0 && firstPassPrinted === 0 ? `${brokenLib ? "a file passed with --lib has errors of its own, so nothing in your code was checked" : `tsc exited ${exitCode} without reporting any diagnostic, so nothing was checked`}. ` + "A syntax error in an ambient declaration file does this \u2014 skipLibCheck suppresses " + `SEMANTIC diagnostics only. tsc said:
267
301
  ${output.trim().split(`
268
302
  `).slice(0, 12).join(`
269
303
  `) || "(no output)"}` : void 0;
@@ -271,7 +305,7 @@ ${output.trim().split(`
271
305
  }
272
306
  export function formatTypecheckDiagnostics(diagnostics) {
273
307
  return diagnostics.map((d) => {
274
- const where = d.blockKind === "template" ? `[template: ${d.expression ?? ""}]` : `[<script ${d.blockKind}>]`;
308
+ const where = d.blockKind === "template" ? `[template: ${d.expression ?? ""}]` : `[${d.blockKind === "plain" ? "<script>" : `<script ${d.blockKind}>`}]`;
275
309
  return `${d.file}:${d.line}:${d.column} ${d.category} TS${d.code} ${d.message} ${where}`;
276
310
  }).join(`
277
311
  `);
@@ -594,6 +594,30 @@ export interface StxConfig {
594
594
  enabled: boolean;
595
595
  /** Path to partials directory, defaults to 'partials' in the same directory as the template */
596
596
  partialsDir: string;
597
+ /**
598
+ * Called when an `@include` fails, instead of the failure being silent.
599
+ *
600
+ * A failed include renders the error where the markup should have been. In dev
601
+ * that is the right call — you see it as soon as you look. In a build it
602
+ * produces a file nobody would knowingly deploy, and nothing else can see it:
603
+ * one app shipped pre-rendered pages whose header, footer, hero and CTA were
604
+ * all replaced by an ENOENT banner while the build printed `Failed: 0` and
605
+ * exited 0 (stacksjs/stx#1921).
606
+ *
607
+ * The rendered output is unchanged — this only carries the fact out, so a
608
+ * caller that builds files can decide to fail. `partialsDir` is included
609
+ * because "it looked in the wrong place" is the usual cause and the resolved
610
+ * path is what makes it a ten-second fix.
611
+ */
612
+ onIncludeError?: (failure: {
613
+ /** The path as written in the directive, e.g. `Bench/ConfirmHost`. */
614
+ includePath: string;
615
+ /** The template the directive appeared in. */
616
+ templatePath: string;
617
+ message: string;
618
+ /** The directory the include was resolved against. */
619
+ partialsDir: string;
620
+ }) => void;
597
621
  /** Path to components directory, defaults to 'components' in the same directory as the template */
598
622
  componentsDir: string;
599
623
  /** @internal Configured component root retained while nested components resolve locally. */
package/dist/utils.js CHANGED
@@ -558,7 +558,8 @@ ${triedPaths.map((p) => ` - ${p}`).join(`
558
558
  const internalKeysToPreserve = new Set([
559
559
  "__originalFilePath",
560
560
  "__importedComponents",
561
- COMPONENT_CLIENT_FACTORIES_CONTEXT_KEY
561
+ COMPONENT_CLIENT_FACTORIES_CONTEXT_KEY,
562
+ "__stx_client_signal_names"
562
563
  ]), filteredParentContext = {};
563
564
  for (const [key, val] of Object.entries(parentContext))
564
565
  if (!key.startsWith("__") || internalKeysToPreserve.has(key))
@@ -648,9 +649,9 @@ ${triedPaths.map((p) => ` - ${p}`).join(`
648
649
  templateContent = templateContent.replace(/\{\{\s*text\s*\|\|\s*slot\s*\}\}/g, () => slotContent);
649
650
  const { applySlots } = await importOnce("stx/slots", () => import("./slots.js")), { defaultSlot, namedSlots } = parsedSlotContent;
650
651
  templateContent = await applySlots(templateContent, defaultSlot, namedSlots, componentContext);
651
- const hasSignalScripts = clientScripts.some((s) => /\b(?:state|derived|effect|ref|reactive|computed|watch|watchEffect|useReactiveProp|defineProps|withDefaults|defineEmits|defineExpose|defineSlots)\s*(?:<[^<>()]*>)?\s*\(/.test(s)), clientSignalNames = componentClientSignalNames(clientScripts);
652
- if (clientSignalNames.length > 0)
653
- componentContext.__stx_client_signal_names = clientSignalNames;
652
+ const hasSignalScripts = clientScripts.some((s) => /\b(?:state|derived|effect|ref|reactive|computed|watch|watchEffect|useReactiveProp|defineProps|withDefaults|defineEmits|defineExpose|defineSlots)\s*(?:<[^<>()]*>)?\s*\(/.test(s)), clientSignalNames = componentClientSignalNames(clientScripts), inheritedClientSignalNames = Array.isArray(componentContext.__stx_client_signal_names) ? componentContext.__stx_client_signal_names : [];
653
+ if (clientSignalNames.length > 0 || inheritedClientSignalNames.length > 0)
654
+ componentContext.__stx_client_signal_names = [...new Set([...inheritedClientSignalNames, ...clientSignalNames])];
654
655
  const componentOptions = {
655
656
  ...options,
656
657
  _rootComponentsDir: options._rootComponentsDir || options.componentsDir,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/stx",
3
3
  "type": "module",
4
- "version": "0.2.174",
4
+ "version": "0.2.176",
5
5
  "description": "A performant UI Framework. Powered by Bun.",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",
7
7
  "license": "MIT",
@@ -117,12 +117,12 @@
117
117
  "dependencies": {
118
118
  "@cwcss/crosswind": "^0.2.14",
119
119
  "@stacksjs/clapp": "^0.2.0",
120
- "@stacksjs/desktop": "0.2.174",
121
- "@stacksjs/sanitizer": "0.2.174",
120
+ "@stacksjs/desktop": "0.2.176",
121
+ "@stacksjs/sanitizer": "0.2.176",
122
122
  "@stacksjs/ts-i18n": "^0.1.11",
123
- "bun-plugin-stx": "0.2.174",
123
+ "bun-plugin-stx": "0.2.176",
124
124
  "bunfig": "^0.15.17",
125
- "stx-router": "0.2.174",
125
+ "stx-router": "0.2.176",
126
126
  "ts-broadcasting": "^0.0.9",
127
127
  "ts-images": "^0.1.9",
128
128
  "ts-syntax-highlighter": "^0.2.1"