@jxsuite/compiler 8.3.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.3.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.5.5",
56
- "@jxsuite/runtime": "^6.2.0",
57
- "@jxsuite/schema": "^5.3.0",
58
- "@jxsuite/site": "^2.4.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.5",
67
- "@jxsuite/parser": "^4.3.1",
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/cli.ts CHANGED
@@ -29,7 +29,7 @@ Commands:
29
29
  preview [root] Serve an already-built dist/ directory
30
30
  schema [root] Generate project.schema.json + document.schema.json from project.json#/extensions
31
31
  validate [root] Validate the whole project: project.json, documents, classes, and fragments
32
- convert <paths…> Move documents between .json and .jxn, repairing every reference to them
32
+ convert <paths…> Move documents and the project file between .json and .jxn, repairing references
33
33
  db push [root] Sync the data section's tables to their connections (additive-only)
34
34
 
35
35
  Options:
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
  }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
- import { basename, resolve } from "node:path";
10
+ import { basename, extname, resolve } from "node:path";
11
11
  import { execSync } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
13
13
  import { configHash, contentHash } from "./image-optimizer.ts";
@@ -112,14 +112,19 @@ export function getImageCacheDir(projectRoot: string): string {
112
112
  // ─── Cache key ───────────────────────────────────────────────────────────────
113
113
 
114
114
  /**
115
- * Build a cache key from source file content and config.
115
+ * Build a cache key from source file content, config, and the file's name.
116
+ *
117
+ * The name is part of the key because it is part of the output: variants are named after the source
118
+ * file (`variantFilename`). Keyed on content alone, a renamed photo found its old entry and kept
119
+ * the old variant names, so renaming a file to fix its URLs changed nothing until the cache was
120
+ * deleted by hand. The old entry is untouched by the build that renames it, so pruning removes it.
116
121
  *
117
122
  * @param {string} srcPath - Absolute path to source image
118
123
  * @param {ImageConfig} config
119
124
  * @returns {string}
120
125
  */
121
126
  export function cacheKey(srcPath: string, config: ImageConfig) {
122
- return `${contentHash(srcPath)}:${configHash(config)}`;
127
+ return `${contentHash(srcPath)}:${configHash(config)}:${basename(srcPath, extname(srcPath))}`;
123
128
  }
124
129
 
125
130
  // ─── Load / save ─────────────────────────────────────────────────────────────
@@ -263,6 +263,25 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
263
263
  */
264
264
  export function buildSrcset(variants: ImageVariant[], format: string) {
265
265
  return variants
266
- .flatMap((v) => (v.format === format ? [`${v.outputPath} ${v.width}w`] : []))
266
+ .flatMap((v) => (v.format === format ? [`${variantUrl(v.outputPath)} ${v.width}w`] : []))
267
267
  .join(", ");
268
268
  }
269
+
270
+ /**
271
+ * The URL of an emitted variant: its output path with each segment percent-encoded.
272
+ *
273
+ * A variant is named after its source file, and a source file may be named anything. Written raw, a
274
+ * name with a space breaks a `srcset`, which splits candidates on whitespace, so `Designer, White,
275
+ * Black.jpg` became a candidate for `…/Designer,` and a descriptor of `White,`. A comma is legal
276
+ * inside a candidate URL but not at its end, and is encoded too, so no file name can end a
277
+ * candidate early. The file on disk keeps its name: a server decodes the path back to it.
278
+ *
279
+ * @param {string} outputPath - A variant's `outputPath`, as written to disk
280
+ * @returns {string}
281
+ */
282
+ export function variantUrl(outputPath: string) {
283
+ return outputPath
284
+ .split("/")
285
+ .map((segment) => encodeURIComponent(segment))
286
+ .join("/");
287
+ }
@@ -15,12 +15,12 @@ import { existsSync } from "node:fs";
15
15
  import { basename, extname, resolve } from "node:path";
16
16
  import {
17
17
  buildSrcset,
18
- configHash,
19
18
  contentHash,
20
19
  getImageMetadata,
21
20
  processImage,
21
+ variantUrl,
22
22
  } from "./image-optimizer.ts";
23
- import { getCached, getImageCacheDir, setCached } from "./image-cache.ts";
23
+ import { cacheKey, getCached, getImageCacheDir, setCached } from "./image-cache.ts";
24
24
  import { imgLoadingAttrs } from "./img-loading.ts";
25
25
  import { resolveAssetUrl } from "@jxsuite/schema/asset-paths";
26
26
  import { isNodeObject, isNonNullObject, isString } from "@jxsuite/schema/guards";
@@ -96,17 +96,31 @@ function buildCloudflareSrcset(
96
96
  const quality = config.quality.webp ?? 80;
97
97
  const separator = src.startsWith("/") ? "" : "/";
98
98
  const suffix = hash ? `?v=${hash}` : "";
99
+ const path = srcsetSafe(src);
99
100
  return config.widths
100
101
  .filter((w) => w <= originalWidth)
101
102
  .toSorted((a, b) => a - b)
102
103
  .map(
103
104
  (w) =>
104
105
  `/cdn-cgi/image/width=${w},quality=${quality},fit=scale-down,format=auto` +
105
- `${separator}${src}${suffix} ${w}w`,
106
+ `${separator}${path}${suffix} ${w}w`,
106
107
  )
107
108
  .join(", ");
108
109
  }
109
110
 
111
+ /**
112
+ * An authored `src` made safe to stand as a `srcset` candidate: whitespace and commas encoded,
113
+ * everything else as written. A `src` attribute tolerates a space, but a `srcset` splits on it.
114
+ * Only those characters are touched, because an author's `src` may already be percent-encoded and
115
+ * encoding it whole would encode its `%` a second time.
116
+ *
117
+ * @param {string} src
118
+ * @returns {string}
119
+ */
120
+ function srcsetSafe(src: string) {
121
+ return src.replaceAll(/[\s,]/g, (char) => encodeURIComponent(char));
122
+ }
123
+
110
124
  /**
111
125
  * @param {string} absoluteSrc
112
126
  * @param {string} src
@@ -122,7 +136,7 @@ async function resolveManifest(
122
136
  projectRoot: string,
123
137
  cache: CacheManifest,
124
138
  ) {
125
- const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
139
+ const key = cacheKey(absoluteSrc, config);
126
140
  const cached = getCached(cache, key);
127
141
 
128
142
  if (cached) {
@@ -471,7 +485,7 @@ function fallbackVariant(variants: ImageVariant[], formats: readonly string[]):
471
485
  best = variant;
472
486
  }
473
487
  }
474
- return best?.outputPath;
488
+ return best && variantUrl(best.outputPath);
475
489
  }
476
490
 
477
491
  /**
@@ -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
  /**
@@ -7,8 +7,9 @@
7
7
 
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import type { ImageConfig, JxDocumentValue, ProjectConfig } from "@jxsuite/schema/types";
10
- import { errorMessage, parseProjectConfig } from "@jxsuite/schema/parse";
11
- import { resolve } from "node:path";
10
+ import { errorMessage } from "@jxsuite/schema/parse";
11
+ import { parseProjectSource } from "@jxsuite/schema/project-file";
12
+ import { basename, resolve } from "node:path";
12
13
  import { projectFilePath } from "@jxsuite/schema/project-file/node";
13
14
 
14
15
  /**
@@ -65,24 +66,24 @@ const DEFAULTS = {
65
66
  } satisfies Partial<ResolvedProjectConfig>;
66
67
 
67
68
  /**
68
- * Load and validate project.json from a project root.
69
+ * Load and validate the project file, `project.jxn` or `project.json`, from a project root.
69
70
  *
70
71
  * @param {string} projectRoot - Absolute path to the project directory
71
72
  * @returns {{ config: ProjectConfig; configPath: string; projectRoot: string }}
72
- * @throws {Error} If project.json is missing or invalid JSON
73
+ * @throws {Error} If there is no project file, there are two, or it does not read
73
74
  */
74
75
  export function loadProjectConfig(projectRoot: string) {
75
76
  const configPath = projectFilePath(projectRoot);
76
77
 
77
78
  if (!existsSync(configPath)) {
78
- throw new Error(`project.json not found in ${projectRoot}`);
79
+ throw new Error(`No project.jxn or project.json in ${projectRoot}`);
79
80
  }
80
81
 
81
82
  let raw: ProjectConfig;
82
83
  try {
83
- raw = parseProjectConfig(readFileSync(configPath, "utf8"), configPath);
84
+ raw = parseProjectSource(readFileSync(configPath, "utf8"), configPath);
84
85
  } catch (error) {
85
- throw new Error(`Invalid project.json: ${errorMessage(error)}`, { cause: error });
86
+ throw new Error(`Invalid ${basename(configPath)}: ${errorMessage(error)}`, { cause: error });
86
87
  }
87
88
 
88
89
  // Deep merge with defaults. The `...raw` spread carries every author-declared key through
@@ -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";
@@ -33,7 +35,6 @@ import type { JsonObject, JsonValue, JxElement } from "@jxsuite/schema/types";
33
35
  import { buildProjectExtensionRegistry } from "./format-host.ts";
34
36
  import { loadProjectConfig } from "./site-loader.ts";
35
37
  import { readBundledProjectSchemas } from "./schema-command.ts";
36
- import { PROJECT_FILE_NAME } from "@jxsuite/schema/project-file";
37
38
 
38
39
  /** Directories that hold Jx documents validated against the document schema. */
39
40
  const DOCUMENT_DIRS = ["components", "pages", "layouts"] as const;
@@ -66,8 +67,11 @@ export interface ProjectTreeLintFinding {
66
67
  file: string;
67
68
  /** The node's path inside the document. */
68
69
  path: (string | number)[];
69
- /** Which lint: `popover`, `dialog`, `accessibility`, or `notation` for a `.jxn` file's text. */
70
- 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";
71
75
  rule: string;
72
76
  message: string;
73
77
  detail: string;
@@ -174,11 +178,11 @@ export async function validateProjectTree(
174
178
  return { checked, issues, lint, valid: false };
175
179
  }
176
180
 
177
- // 2. project.json against the generated entry schema.
181
+ // 2. The project file against the generated entry schema.
178
182
  const projectResult = await validateProjectFile(root);
179
183
  checked += 1;
180
184
  if (!projectResult.valid) {
181
- issues.push({ errors: projectResult.errors ?? [], file: PROJECT_FILE_NAME });
185
+ issues.push({ errors: projectResult.errors ?? [], file: projectResult.file });
182
186
  }
183
187
 
184
188
  const { addFormats, Ajv } = await loadAjv();
@@ -222,6 +226,12 @@ export async function validateProjectTree(
222
226
  continue;
223
227
  }
224
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
+ }
225
235
  if (validateDoc(doc)) {
226
236
  /*
227
237
  * Only a well-formed document is judged; a schema error is the report for the rest.
@@ -242,7 +252,11 @@ export async function validateProjectTree(
242
252
  for (const file of walkClassFiles(root)) {
243
253
  checked += 1;
244
254
  const doc = readJson(file);
245
- 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)) {
246
260
  issues.push({ errors: validateClass.errors ?? [], file: relative(root, file) });
247
261
  }
248
262
  }
@@ -316,6 +330,36 @@ function readDocumentFile(file: string, name: string): ReadDocumentFile {
316
330
  }
317
331
  }
318
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
+
319
363
  /** A `.jxn` file's document, as the JSON value it is. */
320
364
  function readJxnValue(text: string): JsonValue {
321
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
  /**