@jxsuite/compiler 5.1.2 → 5.1.3

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.
Files changed (40) hide show
  1. package/dist/cli.js +220 -351
  2. package/dist/compiler.js +126 -257
  3. package/package.json +4 -4
  4. package/src/cli.ts +12 -14
  5. package/src/compiler.ts +45 -23
  6. package/src/shadow.ts +9 -8
  7. package/src/shared.ts +350 -296
  8. package/src/site/asset-mounts.ts +22 -12
  9. package/src/site/bundler.ts +38 -12
  10. package/src/site/client-runtime.ts +31 -17
  11. package/src/site/csp.ts +20 -11
  12. package/src/site/db-push.ts +38 -24
  13. package/src/site/dev-command.ts +3 -3
  14. package/src/site/format-host.ts +56 -45
  15. package/src/site/headers-emitter.ts +7 -4
  16. package/src/site/i18n.ts +20 -19
  17. package/src/site/image-cache.ts +16 -3
  18. package/src/site/image-optimizer.ts +19 -5
  19. package/src/site/image-transform.ts +56 -60
  20. package/src/site/img-loading.ts +11 -5
  21. package/src/site/layout-resolver.ts +7 -6
  22. package/src/site/link-relations.ts +10 -6
  23. package/src/site/locale-negotiation.ts +1 -1
  24. package/src/site/pages-discovery.ts +65 -47
  25. package/src/site/preview-server.ts +22 -19
  26. package/src/site/project-sections.ts +12 -3
  27. package/src/site/prototype-resolver.ts +94 -42
  28. package/src/site/schema-command.ts +26 -13
  29. package/src/site/service-worker.ts +2 -2
  30. package/src/site/site-build.ts +379 -262
  31. package/src/site/site-loader.ts +6 -3
  32. package/src/site/static-host.ts +3 -3
  33. package/src/site/validate-command.ts +64 -34
  34. package/src/site/well-known.ts +57 -12
  35. package/src/targets/compile-class.ts +17 -5
  36. package/src/targets/compile-client.ts +141 -106
  37. package/src/targets/compile-element.ts +157 -140
  38. package/src/targets/compile-server.ts +11 -5
  39. package/src/targets/compile-static.ts +57 -63
  40. package/src/types.ts +5 -6
@@ -12,8 +12,14 @@ import { pathToFileURL } from "node:url";
12
12
  import { createRequire } from "node:module";
13
13
  import { buildExtensionRegistry, EXTENSION_MANIFEST } from "@jxsuite/schema/extension-registry";
14
14
  import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
15
- import type { FormatHostIO, FormatRegistry, ProjectBlock } from "@jxsuite/schema/format-registry";
16
- import type { ProjectConfig } from "@jxsuite/schema/types";
15
+ import type {
16
+ FormatHostIO,
17
+ ImplementationExport,
18
+ ProjectBlock,
19
+ StudioHints,
20
+ } from "@jxsuite/schema/format-registry";
21
+ import { isJsonObject } from "@jxsuite/schema/guards";
22
+ import type { JsonObject, JsonValue, ProjectConfig } from "@jxsuite/schema/types";
17
23
 
18
24
  export type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
19
25
  export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registry";
@@ -27,9 +33,10 @@ export type { FormatRegistry, FormatEntry } from "@jxsuite/schema/format-registr
27
33
  * built module is JavaScript), and .js → .ts.
28
34
  *
29
35
  * @param {string} path - Absolute path to the implementation module
30
- * @returns {Promise<Record<string, unknown>>}
36
+ * @returns {Promise<ImplementationExport>} The module's namespace object, whose named exports (and
37
+ * `default`) are read by name
31
38
  */
32
- export async function importImplementation(path: string): Promise<Record<string, unknown>> {
39
+ export async function importImplementation(path: string): Promise<ImplementationExport> {
33
40
  const candidates = [path];
34
41
  const srcSeg = `${sep}src${sep}`;
35
42
  const distSeg = `${sep}dist${sep}`;
@@ -131,7 +138,11 @@ export function probeExtension(projectRoot: string, name: string): ExtensionReso
131
138
  // Not shipped by this host either.
132
139
  }
133
140
 
134
- return { host, project, ...(manifestPath === undefined ? {} : { manifestPath }) };
141
+ const resolution: ExtensionResolution = { host, project };
142
+ if (manifestPath !== undefined) {
143
+ resolution.manifestPath = manifestPath;
144
+ }
145
+ return resolution;
135
146
  }
136
147
 
137
148
  /** Build the extension registry for a project from its project.json `extensions` array. */
@@ -146,20 +157,6 @@ export async function buildProjectExtensionRegistry(
146
157
  );
147
158
  }
148
159
 
149
- /**
150
- * Format-dispatch view of the project's extension registry.
151
- *
152
- * @deprecated Part-3 cleanup: the desktop app still imports this; use
153
- * `buildProjectExtensionRegistry(...).then((r) => r.formats)` instead.
154
- */
155
- export async function buildProjectFormatRegistry(
156
- projectRoot: string,
157
- projectConfig?: ProjectConfig,
158
- ): Promise<FormatRegistry> {
159
- const registry = await buildProjectExtensionRegistry(projectRoot, projectConfig);
160
- return registry.formats;
161
- }
162
-
163
160
  // ─── Studio extensions payload ────────────────────────────────────────────────
164
161
 
165
162
  /** One project-section contribution on the studio wire (specs/extensions.md §9/§9.1). */
@@ -169,12 +166,24 @@ export interface ExtensionsPayloadContribution {
169
166
  /** The class descriptor's `project` admission block. */
170
167
  project: ProjectBlock;
171
168
  /** The class descriptor's `$studio` block (settings section vocabulary), when declared. */
172
- studio: Record<string, unknown> | null;
169
+ studio: StudioHints | null;
173
170
  /**
174
- * The section's value schema — `properties[<key>]` of the extension's project fragment — or null
175
- * when the extension ships no project fragment (or it lacks the key).
171
+ * The section's value schema — `properties[<key>]` of the extension's project fragment, which
172
+ * JSON Schema lets be a boolean as well as an object — or null when the extension ships no
173
+ * project fragment (or it lacks the key).
176
174
  */
177
- entrySchema: Record<string, unknown> | null;
175
+ entrySchema: JsonValue;
176
+ }
177
+
178
+ /** One manifest class of an extension on the studio wire. */
179
+ export interface ExtensionsPayloadClass {
180
+ name: string;
181
+ /** The class's resolved descriptor path. */
182
+ path: string;
183
+ /** Present on a plain state class: one with no admission blocks. */
184
+ state?: boolean;
185
+ /** The descriptor's `$studio.stateDefaults` hint (specs/extensions.md §10), when it is an object. */
186
+ stateDefaults?: JsonObject;
178
187
  }
179
188
 
180
189
  /** One extension package on the studio wire (the formats route's `extensions` sibling array). */
@@ -191,12 +200,7 @@ export interface ExtensionsPayloadEntry {
191
200
  * carry `state: true` plus their `$studio.stateDefaults` hint (specs/extensions.md §10), so the
192
201
  * studio can offer them in its add-state picker and seed new defs (e.g. `timing: "client"`).
193
202
  */
194
- classes: {
195
- name: string;
196
- path: string;
197
- state?: boolean;
198
- stateDefaults?: Record<string, unknown>;
199
- }[];
203
+ classes: ExtensionsPayloadClass[];
200
204
  }
201
205
 
202
206
  /**
@@ -211,13 +215,13 @@ export interface ExtensionsPayloadEntry {
211
215
  export function buildExtensionsPayload(registry: ExtensionRegistry): ExtensionsPayloadEntry[] {
212
216
  const payload: ExtensionsPayloadEntry[] = [];
213
217
  for (const ext of registry.extensions) {
214
- let fragmentProperties: Record<string, Record<string, unknown>> = {};
218
+ let fragmentProperties: JsonObject = {};
215
219
  if (ext.schemas.project) {
216
220
  try {
217
- const fragment = JSON.parse(readFileSync(ext.schemas.project, "utf8")) as {
218
- properties?: Record<string, Record<string, unknown>>;
219
- };
220
- fragmentProperties = fragment.properties ?? {};
221
+ // SAFETY: JSON.parse only ever yields JSON values; its `any` return type says nothing more.
222
+ const fragment = JSON.parse(readFileSync(ext.schemas.project, "utf8")) as JsonValue;
223
+ const properties = isJsonObject(fragment) ? fragment.properties : undefined;
224
+ fragmentProperties = isJsonObject(properties) ? properties : {};
221
225
  } catch {
222
226
  fragmentProperties = {};
223
227
  }
@@ -234,27 +238,34 @@ export function buildExtensionsPayload(registry: ExtensionRegistry): ExtensionsP
234
238
  studio: cls.studio,
235
239
  });
236
240
  }
237
- payload.push({
241
+ const entry: ExtensionsPayloadEntry = {
238
242
  classes: ext.classes.map((cls) => {
239
- const stateDefaults = cls.studio?.stateDefaults as Record<string, unknown> | undefined;
243
+ const stateDefaults = cls.studio?.stateDefaults;
240
244
  const isState =
241
245
  cls.project === null &&
242
246
  cls.server === null &&
243
247
  cls.connector === null &&
244
248
  cls.extensions.length === 0;
245
- return {
246
- name: cls.name,
247
- path: cls.classPath,
248
- ...(isState ? { state: true } : {}),
249
- ...(stateDefaults && typeof stateDefaults === "object" ? { stateDefaults } : {}),
250
- };
249
+ const payloadClass: ExtensionsPayloadClass = { name: cls.name, path: cls.classPath };
250
+ if (isState) {
251
+ payloadClass.state = true;
252
+ }
253
+ if (isJsonObject(stateDefaults)) {
254
+ payloadClass.stateDefaults = stateDefaults;
255
+ }
256
+ return payloadClass;
251
257
  }),
252
258
  contributions,
253
259
  name: ext.manifest.name,
254
260
  specifier: ext.specifier,
255
- ...(ext.manifest.title === undefined ? {} : { title: ext.manifest.title }),
256
- ...(ext.manifest.description === undefined ? {} : { description: ext.manifest.description }),
257
- });
261
+ };
262
+ if (ext.manifest.title !== undefined) {
263
+ entry.title = ext.manifest.title;
264
+ }
265
+ if (ext.manifest.description !== undefined) {
266
+ entry.description = ext.manifest.description;
267
+ }
268
+ payload.push(entry);
258
269
  }
259
270
  return payload;
260
271
  }
@@ -24,6 +24,12 @@ export interface HeaderRule {
24
24
  headers: Record<string, string>;
25
25
  }
26
26
 
27
+ /** The generated `_headers` stanzas, and the configuration problems found while deriving them. */
28
+ export interface HeaderRuleSet {
29
+ rules: HeaderRule[];
30
+ errors: string[];
31
+ }
32
+
27
33
  /**
28
34
  * The only content-addressed output the build produces. `variantFilename()` embeds an 8-character
29
35
  * digest of the source bytes, so a changed image is a changed URL and the old one can be cached
@@ -56,10 +62,7 @@ const AUTHOR_BANNER =
56
62
  export function buildHeaderRules(
57
63
  build: ProjectConfig["build"],
58
64
  cspSources?: CspSources,
59
- ): {
60
- rules: HeaderRule[];
61
- errors: string[];
62
- } {
65
+ ): HeaderRuleSet {
63
66
  const cfg: HeadersConfig = build?.headers ?? {};
64
67
  const errors: string[] = [];
65
68
  if (cfg.enabled === false) {
package/src/site/i18n.ts CHANGED
@@ -13,12 +13,9 @@
13
13
  */
14
14
 
15
15
  import { siteAbsoluteUrl } from "@jxsuite/schema/asset-paths";
16
- import {
17
- canonicalizeLocale,
18
- localeDirection,
19
- localeOfRoute as localeForRoute,
20
- } from "@jxsuite/schema/locale";
16
+ import { canonicalizeLocale, localeDirection, localeOfRoute } from "@jxsuite/schema/locale";
21
17
  import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
18
+ import type { TranslationMember } from "@jxsuite/site/context";
22
19
 
23
20
  import { servedRoute } from "./static-host.ts";
24
21
 
@@ -144,14 +141,6 @@ export interface LocaleAlternate {
144
141
  href: string;
145
142
  }
146
143
 
147
- /** One route in a translation set: the locale it serves, and where it lives. */
148
- export interface TranslationMember {
149
- /** Canonical BCP 47 tag. */
150
- locale: string;
151
- /** Site-absolute route pattern, e.g. `/fr-ca/about/`. */
152
- urlPattern: string;
153
- }
154
-
155
144
  /** A route as translation grouping sees it: its URL, and the key its document declared. */
156
145
  export interface TranslationRoute {
157
146
  /** From the document's `$translationKey`; absent when the key is derived from the path. */
@@ -169,6 +158,19 @@ export interface TranslationConflict {
169
158
  urlPatterns: string[];
170
159
  }
171
160
 
161
+ /** Routes grouped into translation sets, and the collisions found while grouping them. */
162
+ export interface TranslationSets {
163
+ conflicts: TranslationConflict[];
164
+ /** Keyed by `urlPattern`; members of one set share one array. */
165
+ sets: Map<string, TranslationMember[]>;
166
+ }
167
+
168
+ /** The `lang` a page ships with, and its `dir` when that is worth writing down. */
169
+ export interface PageLanguage {
170
+ lang: string;
171
+ dir?: string;
172
+ }
173
+
172
174
  /** Everything a page needs to know about its own locale, gathered once per build. */
173
175
  export interface PageLocaleContext {
174
176
  /** Absolute alternates for `<head>` and the sitemap; empty without a site `url`. */
@@ -198,13 +200,12 @@ export interface PageLocaleContext {
198
200
  *
199
201
  * @param {readonly TranslationRoute[]} routes - Concrete routes only
200
202
  * @param {ResolvedI18n | null} i18n
201
- * @returns {{ conflicts: TranslationConflict[]; sets: Map<string, TranslationMember[]> }} Sets are
202
- * keyed by `urlPattern`; members of one set share one array
203
+ * @returns {TranslationSets} Sets are keyed by `urlPattern`; members of one set share one array
203
204
  */
204
205
  export function translationSets(
205
206
  routes: readonly TranslationRoute[],
206
207
  i18n: ResolvedI18n | null,
207
- ): { conflicts: TranslationConflict[]; sets: Map<string, TranslationMember[]> } {
208
+ ): TranslationSets {
208
209
  const out = new Map<string, TranslationMember[]>();
209
210
  const conflicts: TranslationConflict[] = [];
210
211
  if (i18n === null) {
@@ -226,7 +227,7 @@ export function translationSets(
226
227
  if (route.translationKey !== undefined) {
227
228
  declared.add(route.urlPattern);
228
229
  }
229
- const locale = localeForRoute(route.urlPattern, i18n);
230
+ const locale = localeOfRoute(route.urlPattern, i18n);
230
231
  if (locale === null) {
231
232
  continue;
232
233
  }
@@ -332,7 +333,7 @@ export function localeAlternates(
332
333
  * that says nothing.
333
334
  *
334
335
  * @param {object} args
335
- * @returns {{ lang: string; dir?: TextDirection | string }}
336
+ * @returns {PageLanguage}
336
337
  */
337
338
  export function pageLanguage({
338
339
  pageLang,
@@ -344,7 +345,7 @@ export function pageLanguage({
344
345
  pageDir?: string | undefined;
345
346
  routeLocale?: string | null;
346
347
  defaults?: { lang?: string | undefined; dir?: string | undefined } | undefined;
347
- }): { lang: string; dir?: string } {
348
+ }): PageLanguage {
348
349
  const lang = pageLang ?? routeLocale ?? defaults?.lang ?? "en";
349
350
  const explicit = pageDir ?? defaults?.dir;
350
351
  if (explicit !== undefined) {
@@ -12,7 +12,7 @@ import { execSync } from "node:child_process";
12
12
  import { tmpdir } from "node:os";
13
13
  import { configHash, contentHash } from "./image-optimizer.ts";
14
14
 
15
- import type { ImageConfig, ImageManifest } from "./image-optimizer.ts";
15
+ import type { ImageConfig, ImageManifest, ImageVariant } from "./image-optimizer.ts";
16
16
 
17
17
  interface CacheEntry {
18
18
  source: string; // Relative path to source image
@@ -20,6 +20,15 @@ interface CacheEntry {
20
20
  timestamp: number; // When the entry was cached
21
21
  }
22
22
 
23
+ /**
24
+ * A cache entry as `manifest.json` may still hold it. A persisted cache outlives the build that
25
+ * wrote it, and an entry from before manifests carried `variants` has none, so pruning, which reads
26
+ * every entry rather than only the ones this build looked up, does not assume the list is there.
27
+ */
28
+ interface PersistedEntry {
29
+ manifest: { variants?: readonly ImageVariant[] };
30
+ }
31
+
23
32
  export interface CacheManifest {
24
33
  version: number; // Cache format version
25
34
  entries: Record<string, CacheEntry>; // Cached entries keyed by content+config hash
@@ -101,6 +110,9 @@ export function loadCache(projectRoot: string): CacheManifest {
101
110
  return { entries: {}, touched: new Set(), version: 1 };
102
111
  }
103
112
  try {
113
+ /* SAFETY: manifest.json is written only by saveCache, from a CacheManifest minus its
114
+ runtime-only `touched`, which is restored below; bytes that do not parse throw into the
115
+ empty-cache fallback. */
104
116
  const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as CacheManifest;
105
117
  parsed.touched = new Set();
106
118
  return parsed;
@@ -147,14 +159,15 @@ export function saveCache(
147
159
  */
148
160
  function pruneStale(cache: CacheManifest, touched: Set<string>) {
149
161
  const keep = new Set<string>();
150
- for (const [key, entry] of Object.entries(cache.entries)) {
162
+ const entries = Object.entries<PersistedEntry>(cache.entries);
163
+ for (const [key, entry] of entries) {
151
164
  if (touched.has(key)) {
152
165
  for (const variant of entry.manifest.variants ?? []) {
153
166
  keep.add(variant.absolutePath);
154
167
  }
155
168
  }
156
169
  }
157
- for (const [key, entry] of Object.entries(cache.entries)) {
170
+ for (const [key, entry] of entries) {
158
171
  if (touched.has(key)) {
159
172
  continue;
160
173
  }
@@ -68,7 +68,7 @@ async function getSharp() {
68
68
  // Production installs where @img/sharp-* native packages are adjacent to cli.js.
69
69
  try {
70
70
  const sharpMod = await import("sharp");
71
- _sharp = sharpMod.default as SharpModule;
71
+ _sharp = sharpMod.default;
72
72
  return _sharp;
73
73
  } catch {
74
74
  // Fall through to CJS fallback below.
@@ -79,6 +79,7 @@ async function getSharp() {
79
79
  // Unreachable via the primary import path.
80
80
  try {
81
81
  const req = createRequire(resolve(process.cwd(), "package.json"));
82
+ // SAFETY: sharp's CommonJS entry is `module.exports = sharp`, the value its ESM default exports.
82
83
  _sharp = req("sharp") as SharpModule;
83
84
  return _sharp;
84
85
  } catch (error) {
@@ -91,6 +92,17 @@ async function getSharp() {
91
92
 
92
93
  const OPTIMIZED_DIR = "images/_optimized";
93
94
 
95
+ /**
96
+ * The part of sharp's metadata this module reads, with every member optional. The sharp these types
97
+ * describe always reports all three, but `getSharp` may load the project's own copy instead, and
98
+ * sharp before 0.34 declared each of them optional.
99
+ */
100
+ interface ReportedMetadata {
101
+ format?: string;
102
+ height?: number;
103
+ width?: number;
104
+ }
105
+
94
106
  /**
95
107
  * Get image metadata (dimensions and format) via Sharp.
96
108
  *
@@ -99,9 +111,9 @@ const OPTIMIZED_DIR = "images/_optimized";
99
111
  */
100
112
  export async function getImageMetadata(srcPath: string) {
101
113
  const sharp = await getSharp();
102
- const meta = await sharp(srcPath).metadata();
114
+ const meta: ReportedMetadata = await sharp(srcPath).metadata();
103
115
  return {
104
- format: (meta.format ?? "unknown") as string,
116
+ format: meta.format ?? "unknown",
105
117
  height: meta.height ?? 0,
106
118
  width: meta.width ?? 0,
107
119
  };
@@ -216,7 +228,10 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
216
228
  continue;
217
229
  }
218
230
 
231
+ /* SAFETY: `format` is a free string from project config; a name outside the four keyed
232
+ qualities reads `undefined` from the lookup and takes the default of 80. */
219
233
  const quality = config.quality[format as keyof ImageConfig["quality"]] ?? 80;
234
+ // SAFETY: toFormat checks the name itself and throws for a format sharp cannot encode.
220
235
  const task = sharp(srcPath)
221
236
  .resize(width)
222
237
  .toFormat(format as SharpFormat, { quality })
@@ -248,7 +263,6 @@ export async function processImage(srcPath: string, cacheImgDir: string, config:
248
263
  */
249
264
  export function buildSrcset(variants: ImageVariant[], format: string) {
250
265
  return variants
251
- .filter((v) => v.format === format)
252
- .map((v) => `${v.outputPath} ${v.width}w`)
266
+ .flatMap((v) => (v.format === format ? [`${v.outputPath} ${v.width}w`] : []))
253
267
  .join(", ");
254
268
  }