@jxsuite/compiler 0.25.4 → 0.26.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": "0.25.4",
3
+ "version": "0.26.0",
4
4
  "description": "Jx static HTML compiler, island detector, and site builder",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,9 +41,9 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@apidevtools/json-schema-ref-parser": "^15.3.5",
44
- "@jxsuite/parser": "^0.25.4",
45
- "@jxsuite/runtime": "^0.25.4",
46
- "@jxsuite/schema": "^0.25.4",
44
+ "@jxsuite/parser": "^0.26.0",
45
+ "@jxsuite/runtime": "^0.26.0",
46
+ "@jxsuite/schema": "^0.26.0",
47
47
  "remark-gfm": "^4.0.1",
48
48
  "remark-stringify": "^11.0.0",
49
49
  "sharp": "^0.34.5",
@@ -2,7 +2,7 @@
2
2
  * Content-loader.js — Content type loader
3
3
  *
4
4
  * Loads content types defined in project.json's `contentTypes` key. Supports Markdown (.md), JSON
5
- * (.json), and CSV (.csv) source files.
5
+ * (.json), and CSV (.csv) source files or remote URLs.
6
6
  *
7
7
  * Phase 2 implementation of site-architecture spec §6.
8
8
  *
@@ -180,26 +180,53 @@ function loadJSONEntries(filePath: string) {
180
180
  * @param {ContentTypeSchema} [schema] - Content type schema (for type coercion)
181
181
  * @returns {ContentLoaderEntry[]} Array of ContentEntry shapes
182
182
  */
183
- function loadCSVEntries(filePath: string, schema?: ContentTypeSchema) {
184
- const csv = readFileSync(filePath, "utf-8");
185
- const rows = parseCSV(csv);
186
- return rows.map((row: Record<string, string>, i: number) => {
187
- // Apply type coercion based on schema if available
183
+ /**
184
+ * Coerce raw CSV row strings to typed values per the schema. - number: strips currency
185
+ * symbols/commas, returns null for empty cells - boolean: "true" → true, everything else → false -
186
+ * string: trimmed (already done by parseCSV, but defensively applied)
187
+ */
188
+ function coerceCSVRows(
189
+ rows: Record<string, string>[],
190
+ schema?: ContentTypeSchema,
191
+ ): ContentLoaderEntry[] {
192
+ return rows.map((row, i) => {
188
193
  const data: Record<string, unknown> = { ...row };
189
194
  if (schema?.properties) {
190
195
  for (const [key, def] of Object.entries(schema.properties)) {
191
- if (key in data) {
192
- if (def.type === "number") data[key] = Number(data[key]);
193
- else if (def.type === "boolean") data[key] = data[key] === "true";
196
+ if (!(key in data)) continue;
197
+ if (def.type === "number") {
198
+ const raw = String(data[key] ?? "").trim();
199
+ if (raw === "") {
200
+ data[key] = null;
201
+ } else {
202
+ const cleaned = raw.replace(/[$€£¥,\s]/g, "");
203
+ const n = Number(cleaned);
204
+ data[key] = isNaN(n) ? null : n;
205
+ }
206
+ } else if (def.type === "boolean") {
207
+ data[key] = data[key] === "true";
194
208
  }
195
209
  }
196
210
  }
197
- // Use `id` column, `sku` column, or row index as the entry ID
198
211
  const id = (data.id as string | undefined) ?? (data.sku as string | undefined) ?? String(i);
199
212
  return { id, data, body: null };
200
213
  });
201
214
  }
202
215
 
216
+ function loadCSVEntries(filePath: string, schema?: ContentTypeSchema) {
217
+ const rows = parseCSV(readFileSync(filePath, "utf-8"));
218
+ return coerceCSVRows(rows, schema);
219
+ }
220
+
221
+ /** Fetch a remote CSV URL and return ContentLoaderEntry[]. */
222
+ async function loadRemoteCSV(url: string, schema?: ContentTypeSchema) {
223
+ const response = await fetch(url);
224
+ if (!response.ok) {
225
+ throw new Error(`Failed to fetch CSV from ${url}: ${response.status} ${response.statusText}`);
226
+ }
227
+ return coerceCSVRows(parseCSV(await response.text()), schema);
228
+ }
229
+
203
230
  // ─── Content Config ───────────────────────────────────────────────────────────
204
231
 
205
232
  /**
@@ -289,6 +316,18 @@ async function loadContentType(name: string, contentTypeDef: ContentTypeDef, pro
289
316
  }
290
317
  : undefined;
291
318
 
319
+ // Remote URL source (e.g. Google Sheets CSV export)
320
+ if (source.startsWith("http://") || source.startsWith("https://")) {
321
+ try {
322
+ const entries = await loadRemoteCSV(source, schema);
323
+ if (schema) validateEntries(entries, schema, name);
324
+ return entries;
325
+ } catch (e) {
326
+ console.warn(`Content type "${name}": ${(e as Error).message}`);
327
+ return [];
328
+ }
329
+ }
330
+
292
331
  // Resolve source path and discover files
293
332
  const resolvedSource = resolve(projectRoot, source).split("\\").join("/");
294
333
  /** @type {string[]} */
@@ -2,11 +2,14 @@
2
2
  * Image-cache.js — Content-hash based cache for processed image variants.
3
3
  *
4
4
  * Stores a manifest of previously processed images so that unchanged sources can skip re-encoding
5
- * on subsequent builds. Cache lives in .cache/images/.
5
+ * on subsequent builds. Cache lives in the npm global cache directory under jxsuite-images/ so that
6
+ * CI environments (e.g. Cloudflare Pages) can persist it via their npm cache layer.
6
7
  */
7
8
 
8
9
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
9
- import { resolve } from "node:path";
10
+ import { resolve, basename } from "node:path";
11
+ import { execSync } from "node:child_process";
12
+ import { tmpdir } from "node:os";
10
13
  import { contentHash, configHash } from "./image-optimizer.ts";
11
14
 
12
15
  import type { ImageManifest } from "./image-optimizer.ts";
@@ -23,6 +26,56 @@ export interface CacheManifest {
23
26
  entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
24
27
  }
25
28
 
29
+ // ─── Cache directory resolution ──────────────────────────────────────────────
30
+
31
+ let _npmCacheBase: string | null | undefined = undefined; // undefined = not yet resolved
32
+
33
+ /** Reset the memoized npm cache base so the next call re-evaluates. Tests only. */
34
+ export function _testResetNpmCacheBase() {
35
+ _npmCacheBase = undefined;
36
+ }
37
+
38
+ /**
39
+ * Directly set the memoized npm cache base. Pass null to force the project-local fallback. Tests
40
+ * only.
41
+ */
42
+ export function _testSetNpmCacheBase(value: string | null) {
43
+ _npmCacheBase = value;
44
+ }
45
+
46
+ /**
47
+ * Return the image cache directory for a project.
48
+ *
49
+ * Prefers the npm global cache (`npm config get cache`) so CI environments that cache ~/.npm (e.g.
50
+ * Cloudflare Pages) automatically persist optimized images across builds. Falls back to
51
+ * .cache/images inside the project root when npm is unavailable.
52
+ *
53
+ * Result is memoized — `npm config get cache` is only spawned once per process.
54
+ *
55
+ * @param {string} projectRoot
56
+ * @returns {string}
57
+ */
58
+ export function getImageCacheDir(projectRoot: string): string {
59
+ if (_npmCacheBase === undefined) {
60
+ try {
61
+ const result = execSync("npm config get cache", {
62
+ encoding: "utf-8",
63
+ stdio: ["ignore", "pipe", "ignore"],
64
+ cwd: tmpdir(), // avoid ENOWORKSPACES when cwd is inside an npm workspace
65
+ }).trim();
66
+ _npmCacheBase = result && result !== "undefined" ? result : null;
67
+ } catch {
68
+ _npmCacheBase = null;
69
+ }
70
+ }
71
+ if (_npmCacheBase) {
72
+ return resolve(_npmCacheBase, "jxsuite-images", basename(projectRoot));
73
+ }
74
+ return resolve(projectRoot, ".cache/images");
75
+ }
76
+
77
+ // ─── Cache key ───────────────────────────────────────────────────────────────
78
+
26
79
  /**
27
80
  * Build a cache key from source file content and config.
28
81
  *
@@ -34,6 +87,8 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
34
87
  return `${contentHash(srcPath)}:${configHash(config)}`;
35
88
  }
36
89
 
90
+ // ─── Load / save ─────────────────────────────────────────────────────────────
91
+
37
92
  /**
38
93
  * Load the cache manifest from disk, or return an empty one.
39
94
  *
@@ -41,7 +96,7 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
41
96
  * @returns {CacheManifest}
42
97
  */
43
98
  export function loadCache(projectRoot: string) {
44
- const manifestPath = resolve(projectRoot, ".cache/images/manifest.json");
99
+ const manifestPath = resolve(getImageCacheDir(projectRoot), "manifest.json");
45
100
  if (!existsSync(manifestPath)) {
46
101
  return { version: 1, entries: {} };
47
102
  }
@@ -59,11 +114,13 @@ export function loadCache(projectRoot: string) {
59
114
  * @param {CacheManifest} cache
60
115
  */
61
116
  export function saveCache(projectRoot: string, cache: CacheManifest) {
62
- const cacheDir = resolve(projectRoot, ".cache/images");
117
+ const cacheDir = getImageCacheDir(projectRoot);
63
118
  mkdirSync(cacheDir, { recursive: true });
64
119
  writeFileSync(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
65
120
  }
66
121
 
122
+ // ─── Entry access ─────────────────────────────────────────────────────────────
123
+
67
124
  /**
68
125
  * Check if a cache entry exists and its output files are still present.
69
126
  *
@@ -118,20 +118,21 @@ export function variantFilename(stem: string, width: number, hash8: string, form
118
118
  }
119
119
 
120
120
  /**
121
- * Process a single source image: resize to each configured width, encode to each format.
121
+ * Process a single source image: resize to each configured width, encode to each format. Writes
122
+ * variants to cacheImgDir/_optimized/ (e.g. .cache/images/_optimized/).
122
123
  *
123
124
  * @param {string} srcPath - Absolute path to source image
124
- * @param {string} outDir - Absolute path to the build output directory (dist/)
125
+ * @param {string} cacheImgDir - Absolute path to the image cache directory (.cache/images/)
125
126
  * @param {ImageConfig} config
126
127
  * @returns {Promise<ImageManifest>}
127
128
  */
128
- export async function processImage(srcPath: string, outDir: string, config: ImageConfig) {
129
+ export async function processImage(srcPath: string, cacheImgDir: string, config: ImageConfig) {
129
130
  const sharp = await getSharp();
130
131
  const meta = await getImageMetadata(srcPath);
131
132
  const hash8 = contentHash(srcPath);
132
133
  const stem = basename(srcPath, extname(srcPath));
133
134
 
134
- const optimizedDir = resolve(outDir, OPTIMIZED_DIR);
135
+ const optimizedDir = resolve(cacheImgDir, "_optimized");
135
136
  mkdirSync(optimizedDir, { recursive: true });
136
137
 
137
138
  const variants: ImageVariant[] = [];
@@ -9,7 +9,7 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { resolve, extname, basename } from "node:path";
11
11
  import { processImage, buildSrcset, contentHash, configHash } from "./image-optimizer.ts";
12
- import { getCached, setCached } from "./image-cache.ts";
12
+ import { getCached, setCached, getImageCacheDir } from "./image-cache.ts";
13
13
 
14
14
  import type { ImageConfig } from "./image-optimizer.ts";
15
15
  import type { ImageManifest } from "./image-optimizer.ts";
@@ -24,7 +24,7 @@ const EXTERNAL_PREFIXES = ["http://", "https://", "data:", "//"];
24
24
  * @param {string} absoluteSrc
25
25
  * @param {string} src
26
26
  * @param {ImageConfig} config
27
- * @param {string} outDir
27
+ * @param {string} projectRoot
28
28
  * @param {CacheManifest} cache
29
29
  * @returns {Promise<ImageManifest>}
30
30
  */
@@ -32,7 +32,7 @@ async function resolveManifest(
32
32
  absoluteSrc: string,
33
33
  src: string,
34
34
  config: ImageConfig,
35
- outDir: string,
35
+ projectRoot: string,
36
36
  cache: CacheManifest,
37
37
  ) {
38
38
  const key = `${contentHash(absoluteSrc)}:${configHash(config)}`;
@@ -44,7 +44,7 @@ async function resolveManifest(
44
44
  }
45
45
 
46
46
  if (!cached) console.log(` Optimizing ${basename(absoluteSrc)}...`);
47
- const manifest = await processImage(absoluteSrc, outDir, config);
47
+ const manifest = await processImage(absoluteSrc, getImageCacheDir(projectRoot), config);
48
48
  setCached(cache, key, src, manifest);
49
49
  return manifest;
50
50
  }
@@ -83,12 +83,12 @@ function resolveImagePath(src: string, projectRoot: string) {
83
83
  * Transform image nodes in a Jx document tree.
84
84
  *
85
85
  * Mutates img nodes in place, injecting srcset, sizes, width, height, loading, and decoding.
86
- * Returns a set of absolute source paths that need processing.
86
+ * Optimized variants are written to .cache/images/_optimized/ — the caller is responsible for
87
+ * copying them to the dist directory after all pages are compiled.
87
88
  *
88
89
  * @param {JxMutableNode | JxDocument} doc - The Jx document tree (mutated in place)
89
90
  * @param {ImageConfig} config
90
91
  * @param {string} projectRoot
91
- * @param {string} outDir
92
92
  * @param {CacheManifest} cache
93
93
  * @returns {Promise<{ imageRefs: Map<string, ImageManifest> }>}
94
94
  */
@@ -96,14 +96,13 @@ export async function transformImageNodes(
96
96
  doc: JxMutableNode | JxDocument,
97
97
  config: ImageConfig,
98
98
  projectRoot: string,
99
- outDir: string,
100
99
  cache: CacheManifest,
101
100
  ) {
102
101
  const imageRefs: Map<string, ImageManifest> = new Map();
103
102
 
104
103
  if (!config.optimize) return { imageRefs };
105
104
 
106
- await walkAndTransform(doc, config, projectRoot, outDir, cache, imageRefs);
105
+ await walkAndTransform(doc, config, projectRoot, cache, imageRefs);
107
106
 
108
107
  return { imageRefs };
109
108
  }
@@ -112,7 +111,6 @@ export async function transformImageNodes(
112
111
  * @param {JxMutableNode | JxDocument} node
113
112
  * @param {ImageConfig} config
114
113
  * @param {string} projectRoot
115
- * @param {string} outDir
116
114
  * @param {CacheManifest} cache
117
115
  * @param {Map<string, ImageManifest>} imageRefs
118
116
  */
@@ -120,14 +118,13 @@ async function walkAndTransform(
120
118
  node: JxMutableNode | JxDocument,
121
119
  config: ImageConfig,
122
120
  projectRoot: string,
123
- outDir: string,
124
121
  cache: CacheManifest,
125
122
  imageRefs: Map<string, ImageManifest>,
126
123
  ) {
127
124
  if (!node || typeof node !== "object") return;
128
125
 
129
126
  if (node.tagName === "img") {
130
- await transformImgNode(node, config, projectRoot, outDir, cache, imageRefs);
127
+ await transformImgNode(node, config, projectRoot, cache, imageRefs);
131
128
  }
132
129
 
133
130
  if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
@@ -135,7 +132,6 @@ async function walkAndTransform(
135
132
  node.innerHTML,
136
133
  config,
137
134
  projectRoot,
138
- outDir,
139
135
  cache,
140
136
  imageRefs,
141
137
  );
@@ -144,7 +140,7 @@ async function walkAndTransform(
144
140
  if (Array.isArray(node.children)) {
145
141
  for (const child of node.children) {
146
142
  if (typeof child === "string") continue;
147
- await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
143
+ await walkAndTransform(child, config, projectRoot, cache, imageRefs);
148
144
  }
149
145
  }
150
146
  }
@@ -153,7 +149,6 @@ async function walkAndTransform(
153
149
  * @param {JxMutableNode | JxDocument} node
154
150
  * @param {ImageConfig} config
155
151
  * @param {string} projectRoot
156
- * @param {string} outDir
157
152
  * @param {CacheManifest} cache
158
153
  * @param {Map<string, ImageManifest>} imageRefs
159
154
  */
@@ -161,7 +156,6 @@ async function transformImgNode(
161
156
  node: JxMutableNode | JxDocument,
162
157
  config: ImageConfig,
163
158
  projectRoot: string,
164
- outDir: string,
165
159
  cache: CacheManifest,
166
160
  imageRefs: Map<string, ImageManifest>,
167
161
  ) {
@@ -177,7 +171,7 @@ async function transformImgNode(
177
171
  let manifest = imageRefs.get(absoluteSrc);
178
172
 
179
173
  if (!manifest) {
180
- manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
174
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
181
175
  imageRefs.set(absoluteSrc, manifest);
182
176
  }
183
177
 
@@ -213,7 +207,6 @@ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
213
207
  * @param {string} html
214
208
  * @param {ImageConfig} config
215
209
  * @param {string} projectRoot
216
- * @param {string} outDir
217
210
  * @param {CacheManifest} cache
218
211
  * @param {Map<string, ImageManifest>} imageRefs
219
212
  * @returns {Promise<string>}
@@ -222,7 +215,6 @@ async function transformInnerHtmlImages(
222
215
  html: string,
223
216
  config: ImageConfig,
224
217
  projectRoot: string,
225
- outDir: string,
226
218
  cache: CacheManifest,
227
219
  imageRefs: Map<string, ImageManifest>,
228
220
  ) {
@@ -247,7 +239,7 @@ async function transformInnerHtmlImages(
247
239
 
248
240
  let manifest = imageRefs.get(absoluteSrc);
249
241
  if (!manifest) {
250
- manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
242
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
251
243
  imageRefs.set(absoluteSrc, manifest);
252
244
  }
253
245
 
@@ -14,7 +14,6 @@ import {
14
14
  copyFileSync,
15
15
  mkdirSync,
16
16
  existsSync,
17
- renameSync,
18
17
  rmSync,
19
18
  cpSync,
20
19
  readdirSync,
@@ -45,7 +44,7 @@ import { loadContentTypes, loadContentConfig, resolveContentTypeRefs } from "./c
45
44
  import { resolvePrototypes } from "./prototype-resolver.ts";
46
45
  import { compileMarkdown } from "../targets/compile-markdown.ts";
47
46
  import { transformImageNodes } from "./image-transform.ts";
48
- import { loadCache, saveCache } from "./image-cache.ts";
47
+ import { loadCache, saveCache, getImageCacheDir } from "./image-cache.ts";
49
48
  import type {
50
49
  JxElement,
51
50
  JxMutableNode,
@@ -88,20 +87,7 @@ export async function buildSite(
88
87
 
89
88
  // ── 2. Clean output directory ───────────────────────────────────────────
90
89
  if (clean && existsSync(outDir)) {
91
- // Preserve optimized images across builds — sharp re-encoding is expensive
92
- const optimizedDir = resolve(outDir, "images/_optimized");
93
- const hasOptimized = existsSync(optimizedDir);
94
- let tmpOptimized = "";
95
- if (hasOptimized) {
96
- tmpOptimized = resolve(projectRoot, ".cache/_optimized_tmp");
97
- mkdirSync(resolve(projectRoot, ".cache"), { recursive: true });
98
- renameSync(optimizedDir, tmpOptimized);
99
- }
100
90
  rmSync(outDir, { recursive: true, force: true });
101
- if (hasOptimized) {
102
- mkdirSync(resolve(outDir, "images"), { recursive: true });
103
- renameSync(tmpOptimized, optimizedDir);
104
- }
105
91
  }
106
92
  mkdirSync(outDir, { recursive: true });
107
93
 
@@ -208,7 +194,6 @@ export async function buildSite(
208
194
  projectRoot,
209
195
  contentTypes,
210
196
  imageCache,
211
- outDir,
212
197
  componentDefs,
213
198
  );
214
199
 
@@ -269,9 +254,15 @@ export async function buildSite(
269
254
  }
270
255
  }
271
256
 
272
- // ── 6b. Save image cache ─────────────────────────────────────────────
257
+ // ── 6b. Save image cache and copy variants to dist ──────────────────────
273
258
  if (imageCache && projectConfig.images.optimize) {
274
259
  saveCache(projectRoot, imageCache);
260
+ const cacheOptimizedDir = resolve(getImageCacheDir(projectRoot), "_optimized");
261
+ if (existsSync(cacheOptimizedDir)) {
262
+ const distOptimizedDir = resolve(outDir, "images/_optimized");
263
+ mkdirSync(distOptimizedDir, { recursive: true });
264
+ cpSync(cacheOptimizedDir, distOptimizedDir, { recursive: true });
265
+ }
275
266
  const totalImages = Object.keys(imageCache.entries).length;
276
267
  if (totalImages > 0) {
277
268
  log(` Optimized ${totalImages} image(s)`);
@@ -377,7 +368,6 @@ export async function buildSite(
377
368
  * @param {string} projectRoot
378
369
  * @param {Map<string, ContentLoaderEntry[]>} [contentTypes]
379
370
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
380
- * @param {string} [outDir]
381
371
  * @param {Map<string, JxElement>} [componentDefs]
382
372
  * @returns {Promise<{
383
373
  * html: string;
@@ -392,7 +382,6 @@ async function compilePage(
392
382
  projectRoot: string,
393
383
  contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
394
384
  imageCache: import("./image-cache.js").CacheManifest | null = null,
395
- outDir: string = "",
396
385
  componentDefs: Map<string, JxElement> = new Map(),
397
386
  ) {
398
387
  // Load the raw page document
@@ -483,12 +472,11 @@ async function compilePage(
483
472
  }
484
473
 
485
474
  // Transform <img> nodes for responsive image optimization
486
- if (imageCache && projectConfig.images?.optimize && outDir) {
475
+ if (imageCache && projectConfig.images?.optimize) {
487
476
  await transformImageNodes(
488
477
  layoutDoc,
489
478
  projectConfig.images as import("./image-optimizer").ImageConfig,
490
479
  projectRoot,
491
- outDir,
492
480
  imageCache,
493
481
  );
494
482
  }