@jxsuite/compiler 8.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/compiler",
3
- "version": "8.3.0",
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.5.5",
56
- "@jxsuite/runtime": "^6.2.0",
57
- "@jxsuite/schema": "^5.3.0",
58
- "@jxsuite/site": "^2.4.0",
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.5",
67
- "@jxsuite/parser": "^4.3.1",
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,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:
@@ -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
  /**
@@ -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
@@ -33,7 +33,6 @@ import type { JsonObject, JsonValue, JxElement } from "@jxsuite/schema/types";
33
33
  import { buildProjectExtensionRegistry } from "./format-host.ts";
34
34
  import { loadProjectConfig } from "./site-loader.ts";
35
35
  import { readBundledProjectSchemas } from "./schema-command.ts";
36
- import { PROJECT_FILE_NAME } from "@jxsuite/schema/project-file";
37
36
 
38
37
  /** Directories that hold Jx documents validated against the document schema. */
39
38
  const DOCUMENT_DIRS = ["components", "pages", "layouts"] as const;
@@ -174,11 +173,11 @@ export async function validateProjectTree(
174
173
  return { checked, issues, lint, valid: false };
175
174
  }
176
175
 
177
- // 2. project.json against the generated entry schema.
176
+ // 2. The project file against the generated entry schema.
178
177
  const projectResult = await validateProjectFile(root);
179
178
  checked += 1;
180
179
  if (!projectResult.valid) {
181
- issues.push({ errors: projectResult.errors ?? [], file: PROJECT_FILE_NAME });
180
+ issues.push({ errors: projectResult.errors ?? [], file: projectResult.file });
182
181
  }
183
182
 
184
183
  const { addFormats, Ajv } = await loadAjv();