@jxsuite/compiler 8.2.0 → 8.4.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 +203 -155
- package/dist/compiler.js +125 -81
- package/package.json +7 -7
- package/src/cli.ts +16 -1
- package/src/compiler.ts +3 -3
- package/src/site/convert-command.ts +59 -0
- package/src/site/format-host.ts +3 -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 +4 -3
- package/src/site/pages-discovery.ts +9 -4
- package/src/site/schema-command.ts +2 -1
- package/src/site/site-build.ts +9 -5
- package/src/site/site-loader.ts +10 -8
- package/src/site/validate-command.ts +58 -7
- package/src/targets/compile-element.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/compiler",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.4.0",
|
|
4
4
|
"description": "Jx static HTML compiler, island detector, and site builder",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -52,10 +52,10 @@
|
|
|
52
52
|
"upgrade": "bun update --latest && bun install"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@jxsuite/create": "^1.
|
|
56
|
-
"@jxsuite/runtime": "^6.1
|
|
57
|
-
"@jxsuite/schema": "^5.
|
|
58
|
-
"@jxsuite/site": "^2.
|
|
55
|
+
"@jxsuite/create": "^1.6.0",
|
|
56
|
+
"@jxsuite/runtime": "^6.2.1",
|
|
57
|
+
"@jxsuite/schema": "^5.4.0",
|
|
58
|
+
"@jxsuite/site": "^2.5.0",
|
|
59
59
|
"@vue/reactivity": "3.5.44",
|
|
60
60
|
"esbuild": "^0.28.2",
|
|
61
61
|
"lit-html": "^3.3.3",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@happy-dom/global-registrator": "^21.0.4",
|
|
66
|
-
"@jxsuite/connector": "^0.8.
|
|
67
|
-
"@jxsuite/parser": "^4.3.
|
|
66
|
+
"@jxsuite/connector": "^0.8.6",
|
|
67
|
+
"@jxsuite/parser": "^4.3.2",
|
|
68
68
|
"@jxsuite/test-support": "^0.0.0",
|
|
69
69
|
"@types/mdast": "^4.0.4"
|
|
70
70
|
}
|
package/src/cli.ts
CHANGED
|
@@ -29,6 +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 and the project file between .json and .jxn, repairing references
|
|
32
33
|
db push [root] Sync the data section's tables to their connections (additive-only)
|
|
33
34
|
|
|
34
35
|
Options:
|
|
@@ -38,10 +39,24 @@ Options:
|
|
|
38
39
|
--port <n> dev/preview: port to listen on (default 3000 / 4173)
|
|
39
40
|
--dry-run db push: print the statements without executing them
|
|
40
41
|
--connection db push: restrict to one connection name
|
|
41
|
-
--strict validate: fail on an overlay or accessibility lint error, not only a schema error
|
|
42
|
+
--strict validate: fail on an overlay or accessibility lint error, not only a schema error
|
|
43
|
+
--to <format> convert: jxn or json
|
|
44
|
+
--drop-comments convert: convert a .jxn file to .json even though it has comments`);
|
|
42
45
|
process.exit(0);
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
// Its positionals are paths and not a project root, so it does not join the split below.
|
|
49
|
+
if (command === "convert") {
|
|
50
|
+
try {
|
|
51
|
+
const { runConvert } = await import("./site/convert-command.ts");
|
|
52
|
+
runConvert(process.cwd(), args.slice(1));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(errorMessage(error));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
// `db push` is a two-word command; normalize it before the flag/positional split.
|
|
46
61
|
const isDb = command === "db";
|
|
47
62
|
const dbSubcommand = isDb ? args[1] : null;
|
package/src/compiler.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { emitElementModule } from "./targets/compile-element.ts";
|
|
|
27
27
|
import { compileStaticPage } from "./targets/compile-static.ts";
|
|
28
28
|
import { compileClient } from "./targets/compile-client.ts";
|
|
29
29
|
import { isClassDef, isString } from "@jxsuite/schema/guards";
|
|
30
|
-
import {
|
|
30
|
+
import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
|
|
31
31
|
import type { JxDocument, JxStyle } from "@jxsuite/schema/types";
|
|
32
32
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
33
33
|
|
|
@@ -94,8 +94,8 @@ export async function compile(sourcePath: string | JxDocument, opts: CompileOpti
|
|
|
94
94
|
let raw: JxDocument;
|
|
95
95
|
if (isString(sourcePath)) {
|
|
96
96
|
const source = readFileSync(sourcePath, "utf8");
|
|
97
|
-
if (sourcePath
|
|
98
|
-
raw =
|
|
97
|
+
if (isNativeDocumentPath(sourcePath)) {
|
|
98
|
+
raw = parseNativeDocument(source, sourcePath);
|
|
99
99
|
} else {
|
|
100
100
|
const { extname } = await import("node:path");
|
|
101
101
|
const ext = extname(sourcePath).toLowerCase();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert command: `jx convert` resolves the project's `@jxsuite/server/convert` and runs it under
|
|
3
|
+
* Bun, as `jx dev` does and for the same reasons. The compiler cannot depend on the server, and the
|
|
4
|
+
* refactor pass that repairs references ships as TypeScript this Node entry cannot load.
|
|
5
|
+
*
|
|
6
|
+
* @module convert-command
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import type { ChildProcess } from "node:child_process";
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ProcessLike } from "./dev-command.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the project's converter.
|
|
16
|
+
*
|
|
17
|
+
* @param projectRoot Absolute project root.
|
|
18
|
+
* @throws {Error} When the server package is not installed.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveConvertEntry(projectRoot: string): string {
|
|
21
|
+
try {
|
|
22
|
+
return createRequire(join(projectRoot, "package.json")).resolve("@jxsuite/server/convert");
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`@jxsuite/server is not installed in ${projectRoot}. ` +
|
|
26
|
+
`Add it to devDependencies (e.g. \`bun add -d @jxsuite/server\`) and retry.`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Spawn the converter under Bun.
|
|
33
|
+
*
|
|
34
|
+
* @param projectRoot Absolute project root.
|
|
35
|
+
* @param argv The arguments after the command.
|
|
36
|
+
* @param spawnImpl Injectable for tests.
|
|
37
|
+
* @param proc Injectable for tests.
|
|
38
|
+
*/
|
|
39
|
+
export function runConvert(
|
|
40
|
+
projectRoot: string,
|
|
41
|
+
argv: readonly string[],
|
|
42
|
+
spawnImpl: typeof spawn = spawn,
|
|
43
|
+
proc: ProcessLike = process,
|
|
44
|
+
): ChildProcess {
|
|
45
|
+
const entry = resolveConvertEntry(projectRoot);
|
|
46
|
+
const child = spawnImpl("bun", [entry, "--root", projectRoot, ...argv], { stdio: "inherit" });
|
|
47
|
+
child.on("error", (error: NodeJS.ErrnoException) => {
|
|
48
|
+
console.error(
|
|
49
|
+
error.code === "ENOENT"
|
|
50
|
+
? "jx convert runs on Bun, which was not found on your PATH. Install it from https://bun.sh and retry."
|
|
51
|
+
: `Failed to start jx convert: ${error.message}`,
|
|
52
|
+
);
|
|
53
|
+
proc.exitCode = 1;
|
|
54
|
+
});
|
|
55
|
+
child.on("exit", (code) => {
|
|
56
|
+
proc.exitCode = code ?? 0;
|
|
57
|
+
});
|
|
58
|
+
return child;
|
|
59
|
+
}
|
package/src/site/format-host.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from "@jxsuite/schema/format-registry";
|
|
21
21
|
import { isJsonObject } from "@jxsuite/schema/guards";
|
|
22
22
|
import type { JsonObject, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
|
|
23
|
+
import { projectFilePath } from "@jxsuite/schema/project-file/node";
|
|
23
24
|
|
|
24
25
|
export type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
25
26
|
export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
|
|
@@ -133,7 +134,7 @@ export async function buildProjectExtensionRegistry(
|
|
|
133
134
|
return buildExtensionRegistry(
|
|
134
135
|
projectConfig?.extensions,
|
|
135
136
|
createNodeFormatIO(projectRoot),
|
|
136
|
-
|
|
137
|
+
projectFilePath(projectRoot),
|
|
137
138
|
);
|
|
138
139
|
}
|
|
139
140
|
|
|
@@ -176,7 +177,7 @@ export async function buildInstalledExtensionRegistry(
|
|
|
176
177
|
const registry = await buildExtensionRegistry(
|
|
177
178
|
present,
|
|
178
179
|
createNodeFormatIO(projectRoot),
|
|
179
|
-
|
|
180
|
+
projectFilePath(projectRoot),
|
|
180
181
|
);
|
|
181
182
|
return { missing, registry };
|
|
182
183
|
}
|
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
|
/**
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
-
import {
|
|
12
|
+
import { parseNativeDocument } from "@jxsuite/schema/jxn";
|
|
13
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
13
14
|
import { resolve } from "node:path";
|
|
14
15
|
import { resolveLayout as resolveLayoutWith } from "@jxsuite/site/layout";
|
|
15
16
|
import type { JxDocument, ProjectConfig } from "@jxsuite/schema/types";
|
|
@@ -26,9 +27,9 @@ export function nodeLayoutLoader(projectRoot: string): (layoutRef: string) => Jx
|
|
|
26
27
|
throw new Error(`Layout not found: ${layoutRef} (resolved to ${layoutPath})`);
|
|
27
28
|
}
|
|
28
29
|
try {
|
|
29
|
-
return
|
|
30
|
+
return parseNativeDocument(readFileSync(layoutPath, "utf8"), layoutPath);
|
|
30
31
|
} catch (error) {
|
|
31
|
-
throw new Error(`Invalid layout
|
|
32
|
+
throw new Error(`Invalid layout at ${layoutPath}: ${errorMessage(error)}`, {
|
|
32
33
|
cause: error,
|
|
33
34
|
});
|
|
34
35
|
}
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
20
20
|
import { isJsonObject, isString } from "@jxsuite/schema/guards";
|
|
21
|
-
import {
|
|
21
|
+
import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
|
|
22
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
22
23
|
import { compareRoutes, fileToRoute as routeFromFileName } from "@jxsuite/site/routes";
|
|
23
24
|
import { pageCount, pageSlice, pageUrl, pagedSource, pagination } from "@jxsuite/site/pagination";
|
|
24
25
|
import type { PagePagination, PagedSource } from "@jxsuite/site/pagination";
|
|
@@ -80,8 +81,8 @@ export async function readPageDocument(
|
|
|
80
81
|
registry?: FormatRegistry,
|
|
81
82
|
): Promise<JxDocument> {
|
|
82
83
|
const source = readFileSync(filePath, "utf8");
|
|
83
|
-
if (filePath
|
|
84
|
-
return
|
|
84
|
+
if (isNativeDocumentPath(filePath)) {
|
|
85
|
+
return parseNativeDocument(source, filePath);
|
|
85
86
|
}
|
|
86
87
|
const ext = extname(filePath).toLowerCase();
|
|
87
88
|
const entry = registry?.byExtension(ext, "parse");
|
|
@@ -200,7 +201,11 @@ async function declaredTranslationKey(
|
|
|
200
201
|
*/
|
|
201
202
|
export async function discoverPages(pagesDir: string, registry?: FormatRegistry) {
|
|
202
203
|
const routes: Route[] = [];
|
|
203
|
-
const pageExtensions = new Set([
|
|
204
|
+
const pageExtensions = new Set([
|
|
205
|
+
".json",
|
|
206
|
+
".jxn",
|
|
207
|
+
...(registry?.documentExtensions("page") ?? []),
|
|
208
|
+
]);
|
|
204
209
|
await walkDir(pagesDir, pagesDir, routes, pageExtensions, registry);
|
|
205
210
|
|
|
206
211
|
// Static routes first, then dynamic, then catch-all (spec §4.4) — the shared ordering.
|
|
@@ -29,6 +29,7 @@ import { loadProjectConfig } from "./site-loader.ts";
|
|
|
29
29
|
import type { ExtensionInfo } from "@jxsuite/schema/extension-registry";
|
|
30
30
|
import type { SchemaJsonLoader } from "@jxsuite/schema/project-schemas";
|
|
31
31
|
import type { JsonObject, JsonValue } from "@jxsuite/schema/types";
|
|
32
|
+
import { projectFilePath } from "@jxsuite/schema/project-file/node";
|
|
32
33
|
|
|
33
34
|
const CORE_PROJECT_SPECIFIER = "@jxsuite/schema/schemas/project.core.schema.json";
|
|
34
35
|
const CORE_DOCUMENT_SPECIFIER = "@jxsuite/schema/schema.json";
|
|
@@ -95,7 +96,7 @@ export async function readBundledProjectSchemas(
|
|
|
95
96
|
) {
|
|
96
97
|
const { write = true } = options;
|
|
97
98
|
const root = resolve(projectRoot);
|
|
98
|
-
const projectJsonPath =
|
|
99
|
+
const projectJsonPath = projectFilePath(root);
|
|
99
100
|
const projectSchemaPath = resolve(root, "project.schema.json");
|
|
100
101
|
const documentSchemaPath = resolve(root, "document.schema.json");
|
|
101
102
|
|
package/src/site/site-build.ts
CHANGED
|
@@ -160,6 +160,7 @@ import type {
|
|
|
160
160
|
} from "@jxsuite/schema/types";
|
|
161
161
|
import type { JxScope } from "@jxsuite/runtime/types";
|
|
162
162
|
import type { CacheManifest } from "./image-cache.js";
|
|
163
|
+
import { projectFilePath } from "@jxsuite/schema/project-file/node";
|
|
163
164
|
|
|
164
165
|
declare module "@jxsuite/schema/format-registry" {
|
|
165
166
|
/**
|
|
@@ -463,9 +464,12 @@ export async function buildSite(
|
|
|
463
464
|
if (existsSync(componentsDir)) {
|
|
464
465
|
log("Compiling components...");
|
|
465
466
|
const componentExtensions = [".json", ...formatRegistry.documentExtensions("component")];
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
467
|
+
/* Sorted, because this order is the order of the sheets in every page's `<head>`, and readdir
|
|
468
|
+
has none of its own: on ext4 it follows a hash of the name, so the same project built to
|
|
469
|
+
different bytes on different machines. */
|
|
470
|
+
const componentFiles = readdirSync(componentsDir)
|
|
471
|
+
.filter((f: string) => componentExtensions.some((ext) => f.endsWith(ext)))
|
|
472
|
+
.toSorted();
|
|
469
473
|
const componentOutDir = resolve(outDir, "components");
|
|
470
474
|
mkdirSync(componentOutDir, { recursive: true });
|
|
471
475
|
|
|
@@ -505,7 +509,7 @@ export async function buildSite(
|
|
|
505
509
|
resolveElementPath: (refPath: string, currentDir: string | null) => {
|
|
506
510
|
const depPath = currentDir ? resolve(currentDir, refPath) : resolve(refPath);
|
|
507
511
|
const depTag = componentTagByPath.get(depPath);
|
|
508
|
-
return depTag ? `./${depTag}.js` : refPath.replace(/\.json
|
|
512
|
+
return depTag ? `./${depTag}.js` : refPath.replace(/\.(?:json|jxn)$/i, ".js");
|
|
509
513
|
},
|
|
510
514
|
rewriteSrc: (specifier: string, docDir: string | null) =>
|
|
511
515
|
withBase(componentBase, rewriteSidecarSrc(specifier, docDir)),
|
|
@@ -602,7 +606,7 @@ export async function buildSite(
|
|
|
602
606
|
/* SAFETY: `ProjectConfig.state` is a plain record of state entries, and the collector reads
|
|
603
607
|
only a document's `state`, so a document carrying that one member is all it needs. */
|
|
604
608
|
{ state: projectConfig.state } as JxElement,
|
|
605
|
-
|
|
609
|
+
projectFilePath(projectRoot),
|
|
606
610
|
projectRoot,
|
|
607
611
|
),
|
|
608
612
|
);
|
package/src/site/site-loader.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
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";
|
|
13
|
+
import { projectFilePath } from "@jxsuite/schema/project-file/node";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Default project configuration. All properties are optional in project.json; these defaults fill
|
|
@@ -64,24 +66,24 @@ const DEFAULTS = {
|
|
|
64
66
|
} satisfies Partial<ResolvedProjectConfig>;
|
|
65
67
|
|
|
66
68
|
/**
|
|
67
|
-
* 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.
|
|
68
70
|
*
|
|
69
71
|
* @param {string} projectRoot - Absolute path to the project directory
|
|
70
72
|
* @returns {{ config: ProjectConfig; configPath: string; projectRoot: string }}
|
|
71
|
-
* @throws {Error} If
|
|
73
|
+
* @throws {Error} If there is no project file, there are two, or it does not read
|
|
72
74
|
*/
|
|
73
75
|
export function loadProjectConfig(projectRoot: string) {
|
|
74
|
-
const configPath =
|
|
76
|
+
const configPath = projectFilePath(projectRoot);
|
|
75
77
|
|
|
76
78
|
if (!existsSync(configPath)) {
|
|
77
|
-
throw new Error(`project.
|
|
79
|
+
throw new Error(`No project.jxn or project.json in ${projectRoot}`);
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
let raw: ProjectConfig;
|
|
81
83
|
try {
|
|
82
|
-
raw =
|
|
84
|
+
raw = parseProjectSource(readFileSync(configPath, "utf8"), configPath);
|
|
83
85
|
} catch (error) {
|
|
84
|
-
throw new Error(`Invalid
|
|
86
|
+
throw new Error(`Invalid ${basename(configPath)}: ${errorMessage(error)}`, { cause: error });
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
// Deep merge with defaults. The `...raw` spread carries every author-declared key through
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
21
|
+
import { isJxnPath, isNativeDocumentPath, lintJxn, parseJxn } from "@jxsuite/schema/jxn";
|
|
22
|
+
import { errorMessage } from "@jxsuite/schema/parse";
|
|
21
23
|
import { createRequire } from "node:module";
|
|
22
24
|
import { join, relative, resolve } from "node:path";
|
|
23
25
|
import { generateClassSchema } from "@jxsuite/schema";
|
|
@@ -63,8 +65,8 @@ export interface ProjectTreeLintFinding {
|
|
|
63
65
|
file: string;
|
|
64
66
|
/** The node's path inside the document. */
|
|
65
67
|
path: (string | number)[];
|
|
66
|
-
/** Which lint: `popover`, `dialog` or `
|
|
67
|
-
source: "popover" | "dialog" | "accessibility";
|
|
68
|
+
/** Which lint: `popover`, `dialog`, `accessibility`, or `notation` for a `.jxn` file's text. */
|
|
69
|
+
source: "popover" | "dialog" | "accessibility" | "notation";
|
|
68
70
|
rule: string;
|
|
69
71
|
message: string;
|
|
70
72
|
detail: string;
|
|
@@ -171,11 +173,11 @@ export async function validateProjectTree(
|
|
|
171
173
|
return { checked, issues, lint, valid: false };
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
// 2. project
|
|
176
|
+
// 2. The project file against the generated entry schema.
|
|
175
177
|
const projectResult = await validateProjectFile(root);
|
|
176
178
|
checked += 1;
|
|
177
179
|
if (!projectResult.valid) {
|
|
178
|
-
issues.push({ errors: projectResult.errors ?? [], file:
|
|
180
|
+
issues.push({ errors: projectResult.errors ?? [], file: projectResult.file });
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
const { addFormats, Ajv } = await loadAjv();
|
|
@@ -212,7 +214,13 @@ export async function validateProjectTree(
|
|
|
212
214
|
continue; // Validated against the class schema below.
|
|
213
215
|
}
|
|
214
216
|
checked += 1;
|
|
215
|
-
const
|
|
217
|
+
const read = readDocumentFile(file, relative(root, file));
|
|
218
|
+
lint.push(...read.warnings);
|
|
219
|
+
if (read.failure !== null) {
|
|
220
|
+
issues.push(read.failure);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const doc = read.document;
|
|
216
224
|
if (validateDoc(doc)) {
|
|
217
225
|
/*
|
|
218
226
|
* Only a well-formed document is judged; a schema error is the report for the rest.
|
|
@@ -270,6 +278,49 @@ export async function validateProjectTree(
|
|
|
270
278
|
return { checked, issues, lint, valid: issues.length === 0 && !fatalLint };
|
|
271
279
|
}
|
|
272
280
|
|
|
281
|
+
/** A document file as the validator takes it: its value, or why it has none. */
|
|
282
|
+
interface ReadDocumentFile {
|
|
283
|
+
document: JsonValue;
|
|
284
|
+
failure: ProjectTreeIssue | null;
|
|
285
|
+
warnings: ProjectTreeLintFinding[];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Read a document file for checking. A `.jxn` file that does not read is a finding with a line and
|
|
290
|
+
* column, and not a stack trace, and an escape that changes what its code means is reported whether
|
|
291
|
+
* or not the rest of the file reads.
|
|
292
|
+
*/
|
|
293
|
+
function readDocumentFile(file: string, name: string): ReadDocumentFile {
|
|
294
|
+
if (!isJxnPath(file)) {
|
|
295
|
+
return { document: readJson(file), failure: null, warnings: [] };
|
|
296
|
+
}
|
|
297
|
+
const text = readFileSync(file, "utf8");
|
|
298
|
+
const warnings = lintJxn(text).map((warning): ProjectTreeLintFinding => ({
|
|
299
|
+
detail: warning.message,
|
|
300
|
+
file: name,
|
|
301
|
+
message: `line ${warning.line}, column ${warning.column}: ${warning.message}`,
|
|
302
|
+
path: [],
|
|
303
|
+
rule: "escape",
|
|
304
|
+
severity: "warn",
|
|
305
|
+
source: "notation",
|
|
306
|
+
}));
|
|
307
|
+
try {
|
|
308
|
+
return { document: readJxnValue(text), failure: null, warnings };
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return {
|
|
311
|
+
document: null,
|
|
312
|
+
failure: { errors: [{ message: errorMessage(error) }], file: name },
|
|
313
|
+
warnings,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A `.jxn` file's document, as the JSON value it is. */
|
|
319
|
+
function readJxnValue(text: string): JsonValue {
|
|
320
|
+
const document = parseJxn(text);
|
|
321
|
+
return isJsonObject(document) ? document : null;
|
|
322
|
+
}
|
|
323
|
+
|
|
273
324
|
/**
|
|
274
325
|
* Read and parse a JSON file. `JSON.parse` without a reviver only ever produces JSON values, so the
|
|
275
326
|
* declared type is exact; what shape of JSON it is stays for the caller to check.
|
|
@@ -431,7 +482,7 @@ const SUBSCHEMA_KEYWORDS = new Set([
|
|
|
431
482
|
"unevaluatedProperties",
|
|
432
483
|
]);
|
|
433
484
|
|
|
434
|
-
/** Recursively list
|
|
485
|
+
/** Recursively list document files under a directory (absent directories yield nothing). */
|
|
435
486
|
function walkJsonFiles(dir: string): string[] {
|
|
436
487
|
if (!existsSync(dir)) {
|
|
437
488
|
return [];
|
|
@@ -441,7 +492,7 @@ function walkJsonFiles(dir: string): string[] {
|
|
|
441
492
|
const path = join(dir, name);
|
|
442
493
|
if (statSync(path).isDirectory()) {
|
|
443
494
|
out.push(...walkJsonFiles(path));
|
|
444
|
-
} else if (name
|
|
495
|
+
} else if (isNativeDocumentPath(name)) {
|
|
445
496
|
out.push(path);
|
|
446
497
|
}
|
|
447
498
|
}
|
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
isTagExpression,
|
|
50
50
|
paramNames,
|
|
51
51
|
} from "@jxsuite/schema/guards";
|
|
52
|
-
import {
|
|
52
|
+
import { isNativeDocumentPath, parseNativeDocument } from "@jxsuite/schema/jxn";
|
|
53
53
|
import { resolveShadowMode } from "../shadow.ts";
|
|
54
54
|
import type { ShadowMode } from "../shadow.ts";
|
|
55
55
|
import type { FormatRegistry } from "@jxsuite/schema/format-registry";
|
|
@@ -132,8 +132,8 @@ export async function compileElement(
|
|
|
132
132
|
return;
|
|
133
133
|
}
|
|
134
134
|
visited.add(filePath);
|
|
135
|
-
if (filePath
|
|
136
|
-
doc =
|
|
135
|
+
if (isNativeDocumentPath(filePath)) {
|
|
136
|
+
doc = parseNativeDocument(readFileSync(filePath, "utf8"), filePath);
|
|
137
137
|
} else {
|
|
138
138
|
const { extname } = await import("node:path");
|
|
139
139
|
const ext = extname(filePath).toLowerCase();
|
|
@@ -179,7 +179,7 @@ export async function compileElement(
|
|
|
179
179
|
/** @type {string} */
|
|
180
180
|
const importPath = resolveElementPath
|
|
181
181
|
? resolveElementPath(refPath, currentDir)
|
|
182
|
-
: refPath.replace(/\.json
|
|
182
|
+
: refPath.replace(/\.(?:json|jxn)$/i, ".js");
|
|
183
183
|
elementImports.push(importPath);
|
|
184
184
|
}
|
|
185
185
|
}
|
|
@@ -197,7 +197,7 @@ export async function compileElement(
|
|
|
197
197
|
resolveShadowMode(doc, opts.defaults),
|
|
198
198
|
opts.serverBaseUrl,
|
|
199
199
|
);
|
|
200
|
-
const outputPath = filePath ? filePath.replace(/\.json
|
|
200
|
+
const outputPath = filePath ? filePath.replace(/\.(?:json|jxn)$/i, ".js") : `${tagName}.js`;
|
|
201
201
|
files.push({ content: jsContent, path: outputPath, tagName });
|
|
202
202
|
}
|
|
203
203
|
|