@jxsuite/compiler 0.25.3 → 0.25.5

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.3",
3
+ "version": "0.25.5",
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.3",
45
- "@jxsuite/runtime": "^0.25.3",
46
- "@jxsuite/schema": "^0.25.3",
44
+ "@jxsuite/parser": "^0.25.5",
45
+ "@jxsuite/runtime": "^0.25.5",
46
+ "@jxsuite/schema": "^0.25.5",
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,7 +2,7 @@
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 .jx-cache/images/.
5
+ * on subsequent builds. Cache lives in .cache/images/.
6
6
  */
7
7
 
8
8
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -41,7 +41,7 @@ export function cacheKey(srcPath: string, config: ImageConfig) {
41
41
  * @returns {CacheManifest}
42
42
  */
43
43
  export function loadCache(projectRoot: string) {
44
- const manifestPath = resolve(projectRoot, ".jx-cache/images/manifest.json");
44
+ const manifestPath = resolve(projectRoot, ".cache/images/manifest.json");
45
45
  if (!existsSync(manifestPath)) {
46
46
  return { version: 1, entries: {} };
47
47
  }
@@ -59,7 +59,7 @@ export function loadCache(projectRoot: string) {
59
59
  * @param {CacheManifest} cache
60
60
  */
61
61
  export function saveCache(projectRoot: string, cache: CacheManifest) {
62
- const cacheDir = resolve(projectRoot, ".jx-cache/images");
62
+ const cacheDir = resolve(projectRoot, ".cache/images");
63
63
  mkdirSync(cacheDir, { recursive: true });
64
64
  writeFileSync(resolve(cacheDir, "manifest.json"), JSON.stringify(cache, null, 2), "utf8");
65
65
  }
@@ -8,6 +8,7 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import { readFileSync, mkdirSync, existsSync } from "node:fs";
10
10
  import { resolve, basename, extname } from "node:path";
11
+ import { createRequire } from "node:module";
11
12
 
12
13
  export interface ImageVariant {
13
14
  width: number; // Pixel width of the variant
@@ -35,9 +36,22 @@ let _sharp: typeof import("sharp") | null = null;
35
36
 
36
37
  async function getSharp() {
37
38
  if (_sharp) return _sharp;
39
+ // Primary: dynamic import — works in tests (mock.module intercepts it) and in
40
+ // production installs where @img/sharp-* native packages are adjacent to cli.js.
38
41
  try {
39
42
  const sharpMod = await import("sharp");
40
- _sharp = sharpMod.default;
43
+ _sharp = sharpMod.default as typeof import("sharp");
44
+ return _sharp;
45
+ } catch {
46
+ // Fall through to CJS fallback below.
47
+ }
48
+ // Fallback: resolve from the project being compiled. This covers symlinked
49
+ // monorepo dev environments (e.g. NixOS) where Node.js resolves import.meta.url
50
+ // to the compiler package's real path, making the @img/sharp-* packages
51
+ // unreachable via the primary import path.
52
+ try {
53
+ const req = createRequire(resolve(process.cwd(), "package.json"));
54
+ _sharp = req("sharp") as typeof import("sharp");
41
55
  return _sharp;
42
56
  } catch (e) {
43
57
  throw new Error(
@@ -104,20 +118,21 @@ export function variantFilename(stem: string, width: number, hash8: string, form
104
118
  }
105
119
 
106
120
  /**
107
- * 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/).
108
123
  *
109
124
  * @param {string} srcPath - Absolute path to source image
110
- * @param {string} outDir - Absolute path to the build output directory (dist/)
125
+ * @param {string} cacheImgDir - Absolute path to the image cache directory (.cache/images/)
111
126
  * @param {ImageConfig} config
112
127
  * @returns {Promise<ImageManifest>}
113
128
  */
114
- export async function processImage(srcPath: string, outDir: string, config: ImageConfig) {
129
+ export async function processImage(srcPath: string, cacheImgDir: string, config: ImageConfig) {
115
130
  const sharp = await getSharp();
116
131
  const meta = await getImageMetadata(srcPath);
117
132
  const hash8 = contentHash(srcPath);
118
133
  const stem = basename(srcPath, extname(srcPath));
119
134
 
120
- const optimizedDir = resolve(outDir, OPTIMIZED_DIR);
135
+ const optimizedDir = resolve(cacheImgDir, "_optimized");
121
136
  mkdirSync(optimizedDir, { recursive: true });
122
137
 
123
138
  const variants: ImageVariant[] = [];
@@ -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,8 @@ 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 cacheImgDir = resolve(projectRoot, ".cache/images");
48
+ const manifest = await processImage(absoluteSrc, cacheImgDir, config);
48
49
  setCached(cache, key, src, manifest);
49
50
  return manifest;
50
51
  }
@@ -83,12 +84,12 @@ function resolveImagePath(src: string, projectRoot: string) {
83
84
  * Transform image nodes in a Jx document tree.
84
85
  *
85
86
  * Mutates img nodes in place, injecting srcset, sizes, width, height, loading, and decoding.
86
- * Returns a set of absolute source paths that need processing.
87
+ * Optimized variants are written to .cache/images/_optimized/ — the caller is responsible for
88
+ * copying them to the dist directory after all pages are compiled.
87
89
  *
88
90
  * @param {JxMutableNode | JxDocument} doc - The Jx document tree (mutated in place)
89
91
  * @param {ImageConfig} config
90
92
  * @param {string} projectRoot
91
- * @param {string} outDir
92
93
  * @param {CacheManifest} cache
93
94
  * @returns {Promise<{ imageRefs: Map<string, ImageManifest> }>}
94
95
  */
@@ -96,14 +97,13 @@ export async function transformImageNodes(
96
97
  doc: JxMutableNode | JxDocument,
97
98
  config: ImageConfig,
98
99
  projectRoot: string,
99
- outDir: string,
100
100
  cache: CacheManifest,
101
101
  ) {
102
102
  const imageRefs: Map<string, ImageManifest> = new Map();
103
103
 
104
104
  if (!config.optimize) return { imageRefs };
105
105
 
106
- await walkAndTransform(doc, config, projectRoot, outDir, cache, imageRefs);
106
+ await walkAndTransform(doc, config, projectRoot, cache, imageRefs);
107
107
 
108
108
  return { imageRefs };
109
109
  }
@@ -112,7 +112,6 @@ export async function transformImageNodes(
112
112
  * @param {JxMutableNode | JxDocument} node
113
113
  * @param {ImageConfig} config
114
114
  * @param {string} projectRoot
115
- * @param {string} outDir
116
115
  * @param {CacheManifest} cache
117
116
  * @param {Map<string, ImageManifest>} imageRefs
118
117
  */
@@ -120,14 +119,13 @@ async function walkAndTransform(
120
119
  node: JxMutableNode | JxDocument,
121
120
  config: ImageConfig,
122
121
  projectRoot: string,
123
- outDir: string,
124
122
  cache: CacheManifest,
125
123
  imageRefs: Map<string, ImageManifest>,
126
124
  ) {
127
125
  if (!node || typeof node !== "object") return;
128
126
 
129
127
  if (node.tagName === "img") {
130
- await transformImgNode(node, config, projectRoot, outDir, cache, imageRefs);
128
+ await transformImgNode(node, config, projectRoot, cache, imageRefs);
131
129
  }
132
130
 
133
131
  if (typeof node.innerHTML === "string" && node.innerHTML.includes("<img")) {
@@ -135,7 +133,6 @@ async function walkAndTransform(
135
133
  node.innerHTML,
136
134
  config,
137
135
  projectRoot,
138
- outDir,
139
136
  cache,
140
137
  imageRefs,
141
138
  );
@@ -144,7 +141,7 @@ async function walkAndTransform(
144
141
  if (Array.isArray(node.children)) {
145
142
  for (const child of node.children) {
146
143
  if (typeof child === "string") continue;
147
- await walkAndTransform(child, config, projectRoot, outDir, cache, imageRefs);
144
+ await walkAndTransform(child, config, projectRoot, cache, imageRefs);
148
145
  }
149
146
  }
150
147
  }
@@ -153,7 +150,6 @@ async function walkAndTransform(
153
150
  * @param {JxMutableNode | JxDocument} node
154
151
  * @param {ImageConfig} config
155
152
  * @param {string} projectRoot
156
- * @param {string} outDir
157
153
  * @param {CacheManifest} cache
158
154
  * @param {Map<string, ImageManifest>} imageRefs
159
155
  */
@@ -161,7 +157,6 @@ async function transformImgNode(
161
157
  node: JxMutableNode | JxDocument,
162
158
  config: ImageConfig,
163
159
  projectRoot: string,
164
- outDir: string,
165
160
  cache: CacheManifest,
166
161
  imageRefs: Map<string, ImageManifest>,
167
162
  ) {
@@ -177,7 +172,7 @@ async function transformImgNode(
177
172
  let manifest = imageRefs.get(absoluteSrc);
178
173
 
179
174
  if (!manifest) {
180
- manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
175
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
181
176
  imageRefs.set(absoluteSrc, manifest);
182
177
  }
183
178
 
@@ -213,7 +208,6 @@ const DATA_NO_OPT_RE = /\bdata-no-optimize\b/;
213
208
  * @param {string} html
214
209
  * @param {ImageConfig} config
215
210
  * @param {string} projectRoot
216
- * @param {string} outDir
217
211
  * @param {CacheManifest} cache
218
212
  * @param {Map<string, ImageManifest>} imageRefs
219
213
  * @returns {Promise<string>}
@@ -222,7 +216,6 @@ async function transformInnerHtmlImages(
222
216
  html: string,
223
217
  config: ImageConfig,
224
218
  projectRoot: string,
225
- outDir: string,
226
219
  cache: CacheManifest,
227
220
  imageRefs: Map<string, ImageManifest>,
228
221
  ) {
@@ -247,7 +240,7 @@ async function transformInnerHtmlImages(
247
240
 
248
241
  let manifest = imageRefs.get(absoluteSrc);
249
242
  if (!manifest) {
250
- manifest = await resolveManifest(absoluteSrc, src, config, outDir, cache);
243
+ manifest = await resolveManifest(absoluteSrc, src, config, projectRoot, cache);
251
244
  imageRefs.set(absoluteSrc, manifest);
252
245
  }
253
246
 
@@ -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,
@@ -88,19 +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, ".jx-cache/_optimized_tmp");
97
- renameSync(optimizedDir, tmpOptimized);
98
- }
99
90
  rmSync(outDir, { recursive: true, force: true });
100
- if (hasOptimized) {
101
- mkdirSync(resolve(outDir, "images"), { recursive: true });
102
- renameSync(tmpOptimized, optimizedDir);
103
- }
104
91
  }
105
92
  mkdirSync(outDir, { recursive: true });
106
93
 
@@ -207,7 +194,6 @@ export async function buildSite(
207
194
  projectRoot,
208
195
  contentTypes,
209
196
  imageCache,
210
- outDir,
211
197
  componentDefs,
212
198
  );
213
199
 
@@ -268,9 +254,15 @@ export async function buildSite(
268
254
  }
269
255
  }
270
256
 
271
- // ── 6b. Save image cache ─────────────────────────────────────────────
257
+ // ── 6b. Save image cache and copy variants to dist ──────────────────────
272
258
  if (imageCache && projectConfig.images.optimize) {
273
259
  saveCache(projectRoot, imageCache);
260
+ const cacheOptimizedDir = resolve(projectRoot, ".cache/images/_optimized");
261
+ if (existsSync(cacheOptimizedDir)) {
262
+ const distOptimizedDir = resolve(outDir, "images/_optimized");
263
+ mkdirSync(distOptimizedDir, { recursive: true });
264
+ cpSync(cacheOptimizedDir, distOptimizedDir, { recursive: true });
265
+ }
274
266
  const totalImages = Object.keys(imageCache.entries).length;
275
267
  if (totalImages > 0) {
276
268
  log(` Optimized ${totalImages} image(s)`);
@@ -376,7 +368,6 @@ export async function buildSite(
376
368
  * @param {string} projectRoot
377
369
  * @param {Map<string, ContentLoaderEntry[]>} [contentTypes]
378
370
  * @param {import("./image-cache.js").CacheManifest | null} [imageCache]
379
- * @param {string} [outDir]
380
371
  * @param {Map<string, JxElement>} [componentDefs]
381
372
  * @returns {Promise<{
382
373
  * html: string;
@@ -391,7 +382,6 @@ async function compilePage(
391
382
  projectRoot: string,
392
383
  contentTypes: Map<string, ContentLoaderEntry[]> = new Map(),
393
384
  imageCache: import("./image-cache.js").CacheManifest | null = null,
394
- outDir: string = "",
395
385
  componentDefs: Map<string, JxElement> = new Map(),
396
386
  ) {
397
387
  // Load the raw page document
@@ -482,12 +472,11 @@ async function compilePage(
482
472
  }
483
473
 
484
474
  // Transform <img> nodes for responsive image optimization
485
- if (imageCache && projectConfig.images?.optimize && outDir) {
475
+ if (imageCache && projectConfig.images?.optimize) {
486
476
  await transformImageNodes(
487
477
  layoutDoc,
488
478
  projectConfig.images as import("./image-optimizer").ImageConfig,
489
479
  projectRoot,
490
- outDir,
491
480
  imageCache,
492
481
  );
493
482
  }
@@ -624,6 +613,16 @@ function expandMapTemplate(template: JxMutableNode, scope: Record<string, unknow
624
613
  }
625
614
  }
626
615
  node.attributes = attrs;
616
+ } else if (k === "$props" && v && typeof v === "object") {
617
+ const props = { ...v } as Record<string, unknown>;
618
+ for (const [pk, pv] of Object.entries(props)) {
619
+ if (typeof pv === "string" && isTemplateString(pv)) {
620
+ const resolved = evaluateMapTemplate(pv, scope);
621
+ // null = evaluation error → keep template string; undefined = missing data → use null
622
+ props[pk] = resolved !== null ? (resolved ?? null) : pv;
623
+ }
624
+ }
625
+ node.$props = props;
627
626
  } else if (typeof v === "string" && isTemplateString(v)) {
628
627
  node[k] = evaluateMapTemplate(v, scope) ?? v;
629
628
  } else {
@@ -694,6 +693,13 @@ function resolveDocTemplates(node: JxElement | string, scope: Record<string, unk
694
693
  }
695
694
  }
696
695
  }
696
+ if (node.$props && typeof node.$props === "object") {
697
+ for (const [k, v] of Object.entries(node.$props)) {
698
+ if (typeof v === "string" && isTemplateString(v)) {
699
+ node.$props[k] = evaluateStaticTemplate(v, scope) ?? v;
700
+ }
701
+ }
702
+ }
697
703
  const rawChildren = node.children as unknown;
698
704
  if (
699
705
  rawChildren &&