@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/dist/cli.js +184 -170
- package/dist/compiler.js +119 -106
- package/package.json +8 -7
- package/src/cli.ts +1 -1
- package/src/compiler.ts +3 -0
- package/src/erased.ts +60 -0
- package/src/shared.ts +64 -2
- package/src/site/image-cache.ts +8 -3
- package/src/site/image-optimizer.ts +20 -1
- package/src/site/image-transform.ts +19 -5
- package/src/site/layout-resolver.ts +5 -1
- package/src/site/pages-discovery.ts +4 -2
- package/src/site/site-loader.ts +8 -7
- package/src/site/validate-command.ts +50 -6
- package/src/targets/compile-class.ts +4 -1
- package/src/targets/compile-client.ts +3 -2
- package/src/targets/compile-element.ts +12 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "8.
|
|
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.
|
|
56
|
-
"@jxsuite/
|
|
57
|
-
"@jxsuite/
|
|
58
|
-
"@jxsuite/
|
|
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.
|
|
67
|
-
"@jxsuite/parser": "^4.3.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/site/image-cache.ts
CHANGED
|
@@ -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
|
|
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}${
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/site/site-loader.ts
CHANGED
|
@@ -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
|
|
11
|
-
import {
|
|
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
|
|
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.
|
|
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 =
|
|
84
|
+
raw = parseProjectSource(readFileSync(configPath, "utf8"), configPath);
|
|
84
85
|
} catch (error) {
|
|
85
|
-
throw new Error(`Invalid
|
|
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
|
-
/**
|
|
70
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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}
|
|
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
|
|
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 : ""
|
|
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
|
|
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
|
|
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
|
-
|
|
1583
|
+
// The newline ends a last-line comment before the brace does.
|
|
1584
|
+
return `((${declared.join(", ")}) => { ${body}\n})(${args});`;
|
|
1580
1585
|
}
|
|
1581
|
-
return body
|
|
1586
|
+
return `${rewriteState(body, "s", { bare: true })}\n`;
|
|
1582
1587
|
}
|
|
1583
1588
|
|
|
1584
1589
|
/**
|