@jxsuite/compiler 8.4.0 → 8.5.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "8.4.0",
3
+ "version": "8.5.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -52,10 +52,11 @@
52
52
  "upgrade": "bun update --latest && bun install"
53
53
  },
54
54
  "dependencies": {
55
- "@jxsuite/create": "^1.6.0",
56
- "@jxsuite/runtime": "^6.2.1",
57
- "@jxsuite/schema": "^5.4.0",
58
- "@jxsuite/site": "^2.5.0",
55
+ "@jxsuite/create": "^1.7.0",
56
+ "@jxsuite/erase": "^0.1.0",
57
+ "@jxsuite/runtime": "^6.2.2",
58
+ "@jxsuite/schema": "^5.5.0",
59
+ "@jxsuite/site": "^2.5.1",
59
60
  "@vue/reactivity": "3.5.44",
60
61
  "esbuild": "^0.28.2",
61
62
  "lit-html": "^3.3.3",
@@ -63,8 +64,8 @@
63
64
  },
64
65
  "devDependencies": {
65
66
  "@happy-dom/global-registrator": "^21.0.4",
66
- "@jxsuite/connector": "^0.8.6",
67
- "@jxsuite/parser": "^4.3.2",
67
+ "@jxsuite/connector": "^0.8.7",
68
+ "@jxsuite/parser": "^4.3.3",
68
69
  "@jxsuite/test-support": "^0.0.0",
69
70
  "@types/mdast": "^4.0.4"
70
71
  }
package/src/compiler.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * @version 3.0.0
14
14
  */
15
15
 
16
+ import { erasedDocument } from "./erased.ts";
16
17
  import { readFileSync } from "node:fs";
17
18
  import {
18
19
  DEFAULT_LIT_HTML_SRC,
@@ -109,6 +110,8 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
109
110
  } else {
110
111
  raw = sourcePath;
111
112
  }
113
+ // Function bodies as JavaScript before anything analyses them (spec.md §23.1).
114
+ raw = erasedDocument(raw, isString(sourcePath) ? sourcePath : "the document");
112
115
 
113
116
  // Route 0: .class.json schema-defined class → JS class module
114
117
  if (isClassDef(raw)) {
package/src/erased.ts ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Documents as the compiler reads them: with the TypeScript in their function bodies erased, once,
3
+ * as each is read (`spec.md` §23.1, `compiler.md` §2.3). Everything after this, the analysis that
4
+ * classifies a body and every emitter, sees JavaScript, so none of it changes.
5
+ *
6
+ * A body that was already JavaScript and reads differently as TypeScript (`a < b > (c)`) is said
7
+ * once per place, on stderr, and the build goes on with the TypeScript reading.
8
+ *
9
+ * @docs framework/concepts/functions
10
+ */
11
+ import { eraseClass, eraseDocument } from "@jxsuite/erase";
12
+ import type { EraseWarning } from "@jxsuite/erase";
13
+ import type { JsonValue, JxClassDef, JxDocument } from "@jxsuite/schema/types";
14
+
15
+ /** Every warning already printed, so a document read twice says each thing once. */
16
+ const said = new Set<string>();
17
+
18
+ /** Print what erasing found that did not stop it. */
19
+ function report(warnings: readonly EraseWarning[]): void {
20
+ for (const { path, pointer, problem } of warnings) {
21
+ const line = `${path}${pointer} (line ${problem.line}, column ${problem.column}): ${problem.message}`;
22
+ if (!said.has(line)) {
23
+ said.add(line);
24
+ console.warn(`Warning: ${line}`);
25
+ }
26
+ }
27
+ }
28
+
29
+ /**
30
+ * A document with its function bodies as JavaScript.
31
+ *
32
+ * @param document The document as read.
33
+ * @param path Where it was read from, which names it in a message.
34
+ * @throws {import("@jxsuite/erase").EraseError} When a body uses TypeScript that cannot be erased.
35
+ */
36
+ export function erasedDocument<T extends JsonValue | JxDocument | JxClassDef>(
37
+ document: T,
38
+ path: string,
39
+ ): T {
40
+ const warnings: EraseWarning[] = [];
41
+ const erased = eraseDocument(document, path, warnings);
42
+ report(warnings);
43
+ return erased;
44
+ }
45
+
46
+ /**
47
+ * A class definition with its members' bodies as JavaScript.
48
+ *
49
+ * @param definition The class definition as read.
50
+ * @param path Where it was read from.
51
+ */
52
+ export function erasedClass<T extends JsonValue | JxDocument | JxClassDef>(
53
+ definition: T,
54
+ path: string,
55
+ ): T {
56
+ const warnings: EraseWarning[] = [];
57
+ const erased = eraseClass(definition, path, warnings);
58
+ report(warnings);
59
+ return erased;
60
+ }
package/src/shared.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  isTemplateString,
50
50
  paramNames,
51
51
  tagNameCandidates,
52
+ maskCode,
52
53
  } from "@jxsuite/schema/guards";
53
54
  import type { TagNameSlot } from "@jxsuite/schema/guards";
54
55
  import { resolveShadowMode, styleScopePrefix } from "./shadow.ts";
@@ -604,6 +605,62 @@ const STATE_ASSIGNMENT_RE =
604
605
  const STATE_MUTATING_METHOD_RE =
605
606
  /\bstate\.([A-Za-z_$][\w$]*)\s*\.\s*(?:push|pop|shift|unshift|splice|sort|reverse|fill|copyWithin)\s*\(/g;
606
607
 
608
+ /** A character that continues an identifier, or a `.` that makes `state` someone's member. */
609
+ const JOINS_STATE = /[\w$.]/u;
610
+
611
+ /** A character that continues an identifier. */
612
+ const CONTINUES_NAME = /[\w$]/u;
613
+
614
+ /** Whether `state` at `at` in `read` is the one to move: standalone, and a member read unless bare. */
615
+ function movesState(read: string, at: number, bare: boolean): boolean {
616
+ if (at > 0 && JOINS_STATE.test(read.charAt(at - 1))) {
617
+ return false;
618
+ }
619
+ let next = at + "state".length;
620
+ if (bare) {
621
+ return !CONTINUES_NAME.test(read.charAt(next));
622
+ }
623
+ while (/\s/u.test(read.charAt(next))) {
624
+ next += 1;
625
+ }
626
+ return read.charAt(next) === ".";
627
+ }
628
+
629
+ /**
630
+ * Rewrite `state` in emitted code to what holds it there (`this.state`, `s`): the one way every
631
+ * target does it (`spec.md` §23.1). Only a standalone `state` moves, so `prevstate.n` stays itself,
632
+ * and in a body only code moves, so a string or a comment that mentions `state.` is left as
633
+ * written.
634
+ *
635
+ * @param text The body, or an expression.
636
+ * @param to What `state` becomes.
637
+ * @param options `bare` moves `state` alone too, not only `state.member`; `code: false` reads text
638
+ * that is not a body (an attribute's template), where nothing is a string or a comment.
639
+ */
640
+ export function rewriteState(
641
+ text: string,
642
+ to: string,
643
+ options: { bare?: boolean; code?: boolean } = {},
644
+ ): string {
645
+ // Most text never names `state`, and every emitter calls this on every body and template.
646
+ if (!text.includes("state")) {
647
+ return text;
648
+ }
649
+ const bare = options.bare === true;
650
+ const read = options.code === false ? text : maskCode(text);
651
+ /* A scan rather than a lookbehind pattern: every emitter runs this on every body and template,
652
+ and JavaScriptCore runs `(?<!…)` on its slow path, which a compile benchmark measured. */
653
+ let out = "";
654
+ let last = 0;
655
+ for (let at = read.indexOf("state"); at !== -1; at = read.indexOf("state", at + 1)) {
656
+ if (movesState(read, at, bare)) {
657
+ out += text.slice(last, at) + to;
658
+ last = at + "state".length;
659
+ }
660
+ }
661
+ return out + text.slice(last);
662
+ }
663
+
607
664
  /**
608
665
  * Collect every state key a handler writes to, across all the forms a body can take: a string body,
609
666
  * a structured body (spec §20), and an `$expression` node with a mutating operator.
@@ -618,11 +675,16 @@ const STATE_MUTATING_METHOD_RE =
618
675
  */
619
676
  function collectAssignedStateKeys(node: JxDocumentValue | undefined, into: Set<string>) {
620
677
  if (isString(node)) {
621
- for (const m of node.matchAll(STATE_ASSIGNMENT_RE)) {
678
+ if (!node.includes("state.")) {
679
+ return;
680
+ }
681
+ // Code only: an assignment a comment or a string mentions writes nothing (spec.md §23.1).
682
+ const code = maskCode(node);
683
+ for (const m of code.matchAll(STATE_ASSIGNMENT_RE)) {
622
684
  // SAFETY: the pattern's one capture group is outside any optional part, so a match has it.
623
685
  into.add(m[1] as string);
624
686
  }
625
- for (const m of node.matchAll(STATE_MUTATING_METHOD_RE)) {
687
+ for (const m of code.matchAll(STATE_MUTATING_METHOD_RE)) {
626
688
  // SAFETY: the pattern's one capture group is outside any optional part, so a match has it.
627
689
  into.add(m[1] as string);
628
690
  }
@@ -8,6 +8,7 @@
8
8
  * and saying which file was wrong when it is.
9
9
  */
10
10
 
11
+ import { erasedDocument } from "../erased.ts";
11
12
  import { existsSync, readFileSync } from "node:fs";
12
13
  import { parseNativeDocument } from "@jxsuite/schema/jxn";
13
14
  import { errorMessage } from "@jxsuite/schema/parse";
@@ -27,7 +28,10 @@ export function nodeLayoutLoader(projectRoot: string): (layoutRef: string) => Jx
27
28
  throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
28
29
  }
29
30
  try {
30
- return parseNativeDocument(readFileSync(layoutPath, "utf8"), layoutPath);
31
+ return erasedDocument(
32
+ parseNativeDocument(readFileSync(layoutPath, "utf8"), layoutPath),
33
+ layoutPath,
34
+ );
31
35
  } catch (error) {
32
36
  throw new Error(`Invalid layout at ${layoutPath}: ${errorMessage(error)}`, {
33
37
  cause: error,
@@ -16,6 +16,7 @@
16
16
  * genuinely needs a filesystem: the walk, the `$layout` peek, and `$paths` expansion.
17
17
  */
18
18
 
19
+ import { erasedDocument } from "../erased.ts";
19
20
  import { readFileSync, readdirSync, statSync } from "node:fs";
20
21
  import { isJsonObject, isString } from "@jxsuite/schema/guards";
21
22
  import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
@@ -82,7 +83,7 @@ export async function readPageDocument(
82
83
  ): Promise<JxDocument> {
83
84
  const source = readFileSync(filePath, "utf8");
84
85
  if (isNativeDocumentPath(filePath)) {
85
- return parseNativeDocument(source, filePath);
86
+ return erasedDocument(parseNativeDocument(source, filePath), filePath);
86
87
  }
87
88
  const ext = extname(filePath).toLowerCase();
88
89
  const entry = registry?.byExtension(ext, "parse");
@@ -93,7 +94,8 @@ export async function readPageDocument(
93
94
  `e.g. "@jxsuite/parser".`,
94
95
  );
95
96
  }
96
- return await entry.parseDocument(source);
97
+ // A format's page holds function bodies too (spec.md §23.1).
98
+ return erasedDocument(await entry.parseDocument(source), filePath);
97
99
  }
98
100
 
99
101
  /**
@@ -20,6 +20,8 @@
20
20
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
21
21
  import { isJxnPath, isNativeDocumentPath, lintJxn, parseJxn } from "@jxsuite/schema/jxn";
22
22
  import { errorMessage } from "@jxsuite/schema/parse";
23
+ import { eraseDocument } from "@jxsuite/erase";
24
+ import type { EraseWarning } from "@jxsuite/erase";
23
25
  import { createRequire } from "node:module";
24
26
  import { join, relative, resolve } from "node:path";
25
27
  import { generateClassSchema } from "@jxsuite/schema";
@@ -65,8 +67,11 @@ export interface ProjectTreeLintFinding {
65
67
  file: string;
66
68
  /** The node's path inside the document. */
67
69
  path: (string | number)[];
68
- /** Which lint: `popover`, `dialog`, `accessibility`, or `notation` for a `.jxn` file's text. */
69
- source: "popover" | "dialog" | "accessibility" | "notation";
70
+ /**
71
+ * Which lint: `popover`, `dialog`, `accessibility`, `notation` for a `.jxn` file's text, or
72
+ * `function-body` for a body whose meaning reading it as TypeScript changes (`spec.md` §23.1).
73
+ */
74
+ source: "popover" | "dialog" | "accessibility" | "notation" | "function-body";
70
75
  rule: string;
71
76
  message: string;
72
77
  detail: string;
@@ -221,6 +226,12 @@ export async function validateProjectTree(
221
226
  continue;
222
227
  }
223
228
  const doc = read.document;
229
+ const bodies = checkBodies(doc, relative(root, file));
230
+ lint.push(...bodies.warnings);
231
+ if (bodies.failure !== null) {
232
+ issues.push(bodies.failure);
233
+ continue;
234
+ }
224
235
  if (validateDoc(doc)) {
225
236
  /*
226
237
  * Only a well-formed document is judged; a schema error is the report for the rest.
@@ -241,7 +252,11 @@ export async function validateProjectTree(
241
252
  for (const file of walkClassFiles(root)) {
242
253
  checked += 1;
243
254
  const doc = readJson(file);
244
- if (!validateClass(doc)) {
255
+ const bodies = checkBodies(doc, relative(root, file));
256
+ lint.push(...bodies.warnings);
257
+ if (bodies.failure !== null) {
258
+ issues.push(bodies.failure);
259
+ } else if (!validateClass(doc)) {
245
260
  issues.push({ errors: validateClass.errors ?? [], file: relative(root, file) });
246
261
  }
247
262
  }
@@ -315,6 +330,36 @@ function readDocumentFile(file: string, name: string): ReadDocumentFile {
315
330
  }
316
331
  }
317
332
 
333
+ /** What checking a document's function bodies found: an issue that stops it, and warnings. */
334
+ interface BodyCheck {
335
+ failure: ProjectTreeIssue | null;
336
+ warnings: ProjectTreeLintFinding[];
337
+ }
338
+
339
+ /**
340
+ * Whether every function body in a document is TypeScript that can be erased (`spec.md` §23.1): a
341
+ * body that cannot is an issue, as it stops `jx build`, and one that reads differently as
342
+ * TypeScript than as the JavaScript it already was is a warning.
343
+ */
344
+ function checkBodies(document: JsonValue, name: string): BodyCheck {
345
+ const found: EraseWarning[] = [];
346
+ try {
347
+ eraseDocument(document, name, found);
348
+ } catch (error) {
349
+ return { failure: { errors: [{ message: errorMessage(error) }], file: name }, warnings: [] };
350
+ }
351
+ const warnings = found.map(({ pointer, problem }): ProjectTreeLintFinding => ({
352
+ detail: problem.message,
353
+ file: name,
354
+ message: `${pointer} (line ${problem.line}, column ${problem.column}): ${problem.message}`,
355
+ path: pointer.split("/").slice(1),
356
+ rule: "typescript-reading",
357
+ severity: "warn",
358
+ source: "function-body",
359
+ }));
360
+ return { failure: null, warnings };
361
+ }
362
+
318
363
  /** A `.jxn` file's document, as the JSON value it is. */
319
364
  function readJxnValue(text: string): JsonValue {
320
365
  const document = parseJxn(text);
@@ -5,6 +5,7 @@
5
5
  * Schema 2020-12 class definition.
6
6
  */
7
7
 
8
+ import { erasedClass } from "../erased.ts";
8
9
  import type {
9
10
  JxClassCtorDef,
10
11
  JxClassDef,
@@ -20,7 +21,9 @@ import { isString } from "@jxsuite/schema/guards";
20
21
  * @param {JxClassDef} classDef - Parsed .class.json content (must have $prototype: "Class")
21
22
  * @returns {string} JavaScript module source code
22
23
  */
23
- export function compileClassJson(classDef: JxClassDef) {
24
+ export function compileClassJson(authored: JxClassDef) {
25
+ // Member bodies as JavaScript before anything splices them (spec.md §23.1).
26
+ const classDef = erasedClass(authored, authored.title ?? "a class");
24
27
  const className = classDef.title;
25
28
  if (!className) {
26
29
  throw new Error("compileClassJson: missing title (class name)");
@@ -774,7 +774,8 @@ function emitLitMapTemplate(def: JxMutableNode | null | undefined, preformatted
774
774
  * has: a repeater handler is emitted inline and needs inline source.
775
775
  */
776
776
  const body = mapRefsToLit(val.body as string);
777
- attrs += ` @${eventName}=\${(e) => { ${body} }}`;
777
+ // The newline ends a last-line comment before the brace does.
778
+ attrs += ` @${eventName}=\${(e) => { ${body}\n}}`;
778
779
  } else if (isNonNullObject(val) && "$expression" in val) {
779
780
  /*
780
781
  * SAFETY: an `on*` member carrying `$expression` is a Shape 5 expression entry, whose
@@ -1005,7 +1006,7 @@ function emitClientModule(
1005
1006
  const argNames = def.args ?? ["state"];
1006
1007
  const callArgs = argNames.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
1007
1008
  lines.push(
1008
- ` ${objectKey(key)}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
1009
+ ` ${objectKey(key)}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body}\n}; fn(${
1009
1010
  callArgs
1010
1011
  }); },`,
1011
1012
  );
@@ -5,6 +5,7 @@
5
5
  * state and lit-html for rendering.
6
6
  */
7
7
 
8
+ import { erasedDocument } from "../erased.ts";
8
9
  import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
9
10
  import {
10
11
  ATTR_HELPER,
@@ -26,6 +27,7 @@ import {
26
27
  escapeHtml,
27
28
  isMutating,
28
29
  isSchemaOnly,
30
+ rewriteState,
29
31
  serverCallHelperSource,
30
32
  srcExportName,
31
33
  srcImportBinding,
@@ -144,8 +146,10 @@ export async function compileElement(
144
146
  }
145
147
  doc = await entry.parseDocument(readFileSync(filePath, "utf8"));
146
148
  }
149
+ // Function bodies as JavaScript before anything analyses them (spec.md §23.1).
150
+ doc = erasedDocument(doc, filePath);
147
151
  } else {
148
- doc = srcPath;
152
+ doc = erasedDocument(srcPath, srcPath.tagName ?? "a component");
149
153
  filePath = null;
150
154
  if (doc.tagName && visited.has(doc.tagName)) {
151
155
  return;
@@ -665,7 +669,7 @@ export function emitElementModule(
665
669
  for (const [key, template] of templateEntries) {
666
670
  lines.push(
667
671
  "",
668
- ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${templateValueSource(template.replaceAll("state.", "this.state."))});`,
672
+ ` ${refAccessor("this.state", escapeToken(key))} = computed(() => ${templateValueSource(rewriteState(template, "this.state", { code: false }))});`,
669
673
  );
670
674
  }
671
675
 
@@ -686,7 +690,7 @@ export function emitElementModule(
686
690
  );
687
691
  } else {
688
692
  lines.push(` ${refAccessor("this.state", escapeToken(key))} = computed(() => {`);
689
- const body = (isString(def.body) ? def.body : "").replaceAll("state.", "this.state.");
693
+ const body = rewriteState(isString(def.body) ? def.body : "", "this.state");
690
694
  lines.push(` ${body}`, " });");
691
695
  }
692
696
  }
@@ -839,7 +843,7 @@ export function emitElementModule(
839
843
  for (const [cssProp, value] of dynamicStyles) {
840
844
  const expr = value.replaceAll(
841
845
  /\$\{([^}]+)\}/g,
842
- (_: string, e: string) => `\${${e.replaceAll("state.", "this.state.")}}`,
846
+ (_: string, e: string) => `\${${rewriteState(e, "this.state")}}`,
843
847
  );
844
848
  lines.push(` this.style['${cssProp}'] = \`${expr}\`;`);
845
849
  }
@@ -1521,7 +1525,7 @@ function mapRefToExpr(ref: string) {
1521
1525
  * @returns {string}
1522
1526
  */
1523
1527
  function toLitExpr(str: string) {
1524
- return str.replaceAll("state.", "s.");
1528
+ return rewriteState(str, "s", { code: false });
1525
1529
  }
1526
1530
 
1527
1531
  /**
@@ -1576,9 +1580,10 @@ function inlineHandlerBody(def: JxFunctionDef) {
1576
1580
  // Body needs no textual rewriting, and a declared `event` stops silently resolving to the
1577
1581
  // Deprecated `window.event` global (any other declared name threw ReferenceError outright).
1578
1582
  const args = declared.map((name: string) => (name === "state" ? "s" : "e")).join(", ");
1579
- return `((${declared.join(", ")}) => { ${body} })(${args});`;
1583
+ // The newline ends a last-line comment before the brace does.
1584
+ return `((${declared.join(", ")}) => { ${body}\n})(${args});`;
1580
1585
  }
1581
- return body.replaceAll(/(?<!this\.)state\./g, "s.").replaceAll(/(?<!this\.)state(?!\.)/g, "s");
1586
+ return `${rewriteState(body, "s", { bare: true })}\n`;
1582
1587
  }
1583
1588
 
1584
1589
  /**