@jxsuite/compiler 1.4.0 → 2.0.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.
@@ -48,6 +48,7 @@ const DEFAULTS = {
48
48
  formats: ["webp", "avif"],
49
49
  lazyLoad: true,
50
50
  optimize: true,
51
+ picture: true,
51
52
  quality: { avif: 65, jpeg: 80, png: 80, webp: 80 },
52
53
  service: "build",
53
54
  sizes: "(max-width: 768px) 100vw, 50vw",
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Extends the original project.json check into the full compliance walk:
5
5
  *
6
- * 1. `project.json` against the generated entry schema (validateProjectFile);
7
- * 2. The committed entry documents are SELF-CONTAINED (no residual relative `$ref`s — the
8
- * editor-resolution guarantee of the bundled form, specs/extensions.md §5.4);
6
+ * 1. The committed entry documents are SELF-CONTAINED and SINGLE-RESOURCE: every `$ref` is a
7
+ * root-relative JSON Pointer that resolves inside the file — the editor-resolution guarantee of
8
+ * the committed form (specs/extensions.md §5.4). Checked first and fatal, since walks 2–4
9
+ * compile those documents;
10
+ * 2. `project.json` against the generated entry schema (validateProjectFile);
9
11
  * 3. Every document under `components/`, `pages/`, and `layouts/` against the bundled document schema
10
12
  * (core + extension `$paths` union — the same schema the studio consumes);
11
13
  * 4. Every project-local `*.class.json` against the generated class schema;
@@ -94,38 +96,44 @@ export async function validateProjectTree(
94
96
  const issues: ProjectTreeIssue[] = [];
95
97
  let checked = 0;
96
98
 
97
- // 1. project.json against the generated entry schema.
98
- const projectResult = await validateProjectFile(root);
99
- checked += 1;
100
- if (!projectResult.valid) {
101
- issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
102
- }
103
-
104
- // 2. Committed entry documents must be self-contained (the editor-resolution guarantee).
99
+ /* 1. Committed entry documents must be self-contained (the editor-resolution guarantee). This
100
+ runs FIRST because every later walk compiles one of them: an unresolvable ref surfaces from ajv
101
+ as a thrown MissingRefError, so diagnosing it here turns a stack trace into "regenerate with
102
+ `jx schema`". Nothing downstream is meaningful until it holds, hence the early return. */
105
103
  for (const name of ["project.schema.json", "document.schema.json"]) {
106
104
  const path = resolve(root, name);
107
105
  if (!existsSync(path)) {
108
106
  continue;
109
107
  }
110
108
  checked += 1;
111
- const residual: string[] = [];
112
- collectRelativeRefs(JSON.parse(readFileSync(path, "utf8")), residual);
113
- if (residual.length > 0) {
109
+ const entry = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
110
+ const unresolvable = collectUnresolvableRefs(entry);
111
+ if (unresolvable.length > 0) {
114
112
  issues.push({
115
- errors: residual.map((ref) => ({
113
+ errors: unresolvable.map(({ reason, ref }) => ({
116
114
  message:
117
- `relative $ref "${ref}" — committed entry documents must be self-contained; ` +
118
- `regenerate with \`jx schema\``,
115
+ `$ref "${ref}" ${reason} — committed entry documents must be self-contained ` +
116
+ `single-resource schemas; regenerate with \`jx schema\``,
119
117
  })),
120
118
  file: name,
121
119
  });
122
120
  }
123
121
  }
122
+ if (issues.length > 0) {
123
+ return { checked, issues, valid: false };
124
+ }
125
+
126
+ // 2. project.json against the generated entry schema.
127
+ const projectResult = await validateProjectFile(root);
128
+ checked += 1;
129
+ if (!projectResult.valid) {
130
+ issues.push({ errors: projectResult.errors ?? [], file: "project.json" });
131
+ }
124
132
 
125
133
  const { addFormats, Ajv } = await loadAjv();
126
134
 
127
135
  // 3. Documents against the bundled document schema (the same schema the studio consumes).
128
- const { document: documentSchema } = await readBundledProjectSchemas(root);
136
+ const { document: documentSchema } = await readBundledProjectSchemas(root, { write: false });
129
137
  const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
130
138
  addFormats(docAjv);
131
139
  const validateDoc = docAjv.compile(documentSchema);
@@ -186,30 +194,118 @@ export async function validateProjectTree(
186
194
  return { checked, issues, valid: issues.length === 0 };
187
195
  }
188
196
 
189
- /** Collect relative-path `$ref`s (anything without a URI scheme that isn't a `#` pointer). */
190
- function collectRelativeRefs(node: unknown, out: string[]): void {
191
- if (Array.isArray(node)) {
192
- for (const item of node) {
193
- collectRelativeRefs(item, out);
197
+ /**
198
+ * Every `$ref` a committed entry document may carry must be a root-relative JSON Pointer that
199
+ * resolves inside the same file. Three ways that fails, all of which break editor resolution: a
200
+ * relative path (needs Node module resolution), a URI (VS Code fetches it over the network instead
201
+ * of matching an embedded `$id`), and a pointer with no node at the other end.
202
+ *
203
+ * Walks only subschema keyword positions, since `examples`/`default`/`const`/`enum` hold instance
204
+ * data and Jx document examples legitimately contain `$ref` strings of their own.
205
+ *
206
+ * @param {Record<string, unknown>} entry - Parsed committed entry document
207
+ * @returns {{ ref: string; reason: string }[]} One entry per offending `$ref`
208
+ */
209
+ function collectUnresolvableRefs(
210
+ entry: Record<string, unknown>,
211
+ ): { ref: string; reason: string }[] {
212
+ const out: { ref: string; reason: string }[] = [];
213
+ const seen = new Set<Record<string, unknown>>();
214
+
215
+ const resolvePointer = (pointer: string): boolean => {
216
+ let current: unknown = entry;
217
+ for (const raw of pointer.slice(1).split("/")) {
218
+ if (raw === "") {
219
+ continue;
220
+ }
221
+ const segment = raw.replaceAll("~1", "/").replaceAll("~0", "~");
222
+ if (typeof current !== "object" || current === null) {
223
+ return false;
224
+ }
225
+ current = (current as Record<string, unknown>)[segment];
226
+ if (current === undefined) {
227
+ return false;
228
+ }
229
+ }
230
+ return true;
231
+ };
232
+
233
+ const walk = (node: unknown): void => {
234
+ if (!isRecord(node) || seen.has(node)) {
235
+ return;
194
236
  }
195
- return;
196
- }
197
- if (node === null || typeof node !== "object") {
198
- return;
199
- }
200
- for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
201
- if (
202
- key === "$ref" &&
203
- typeof value === "string" &&
204
- !value.startsWith("#") &&
205
- !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
206
- ) {
207
- out.push(value);
237
+ seen.add(node);
238
+ const ref = node.$ref;
239
+ if (typeof ref === "string") {
240
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(ref)) {
241
+ out.push({ reason: "is an absolute URI editors resolve over the network", ref });
242
+ } else if (!ref.startsWith("#")) {
243
+ out.push({ reason: "is a relative path only Node module resolution can follow", ref });
244
+ } else if (ref.length > 1 && !ref.startsWith("#/")) {
245
+ out.push({ reason: "is an $anchor reference, which has no root-pointer form", ref });
246
+ } else if (ref.length > 1 && !resolvePointer(ref.slice(1))) {
247
+ out.push({ reason: "points at no node in this document", ref });
248
+ }
208
249
  }
209
- collectRelativeRefs(value, out);
210
- }
250
+ for (const [keyword, value] of Object.entries(node)) {
251
+ if (!SUBSCHEMA_KEYWORDS.has(keyword)) {
252
+ continue;
253
+ }
254
+ if (Array.isArray(value)) {
255
+ for (const item of value) {
256
+ walk(item);
257
+ }
258
+ } else if (isRecord(value)) {
259
+ // Map-valued keywords hold subschemas one level down; single-valued ones are the schema.
260
+ walk(value);
261
+ if (MAP_SUBSCHEMA_KEYWORDS.has(keyword)) {
262
+ for (const item of Object.values(value)) {
263
+ walk(item);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ };
269
+ walk(entry);
270
+ return out;
211
271
  }
212
272
 
273
+ /** True for a plain object (a candidate subschema node). */
274
+ function isRecord(value: unknown): value is Record<string, unknown> {
275
+ return typeof value === "object" && value !== null && !Array.isArray(value);
276
+ }
277
+
278
+ /** Keywords whose values map names to subschemas. */
279
+ const MAP_SUBSCHEMA_KEYWORDS = new Set([
280
+ "$defs",
281
+ "definitions",
282
+ "dependencies",
283
+ "dependentSchemas",
284
+ "patternProperties",
285
+ "properties",
286
+ ]);
287
+
288
+ /** Every keyword position a subschema can occupy (see {@link MAP_SUBSCHEMA_KEYWORDS} for maps). */
289
+ const SUBSCHEMA_KEYWORDS = new Set([
290
+ ...MAP_SUBSCHEMA_KEYWORDS,
291
+ "additionalItems",
292
+ "additionalProperties",
293
+ "allOf",
294
+ "anyOf",
295
+ "contains",
296
+ "contentSchema",
297
+ "else",
298
+ "if",
299
+ "items",
300
+ "not",
301
+ "oneOf",
302
+ "prefixItems",
303
+ "propertyNames",
304
+ "then",
305
+ "unevaluatedItems",
306
+ "unevaluatedProperties",
307
+ ]);
308
+
213
309
  /** Recursively list `.json` files under a directory (absent directories yield nothing). */
214
310
  function walkJsonFiles(dir: string): string[] {
215
311
  if (!existsSync(dir)) {
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Two files a site is expected to publish and nothing generates: `manifest.webmanifest` and
3
+ * `.well-known/security.txt`.
4
+ *
5
+ * Both are pure functions of `project.json` plus what the build already knows about the site, which
6
+ * is the reason they belong here rather than in `public/` — an author copying either between
7
+ * projects also copies the values that were right for the other one.
8
+ *
9
+ * @docs framework/site/deployment
10
+ */
11
+
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { canonicalizeLocale } from "@jxsuite/schema/locale";
15
+ import type { ManifestConfig, ProjectConfig, SecurityTxtConfig } from "@jxsuite/schema/types";
16
+
17
+ /** The two sizes a browser wants before it will offer to install a site. */
18
+ const INSTALL_ICON_SIZES = [192, 512];
19
+
20
+ export interface GeneratedFile {
21
+ /** Path relative to `outDir`. */
22
+ path: string;
23
+ content: string;
24
+ }
25
+
26
+ export interface WellKnownOutput {
27
+ files: GeneratedFile[];
28
+ errors: string[];
29
+ warnings: string[];
30
+ }
31
+
32
+ /**
33
+ * Build `manifest.webmanifest` from the `manifest` section, falling back to what the project
34
+ * already says about itself.
35
+ *
36
+ * `name` and `start_url` are not asked for twice: a project has a `name`, and `start_url` is the
37
+ * site root unless the author says otherwise. Everything else is theirs.
38
+ *
39
+ * @param {ProjectConfig} projectConfig
40
+ * @returns {WellKnownOutput}
41
+ */
42
+ export function buildManifest(projectConfig: ProjectConfig): WellKnownOutput {
43
+ const config: ManifestConfig | undefined = projectConfig.manifest;
44
+ const errors: string[] = [];
45
+ const warnings: string[] = [];
46
+ if (config === undefined || config.enabled === false) {
47
+ return { errors, files: [], warnings };
48
+ }
49
+
50
+ const name = config.name ?? projectConfig.name ?? "Jx Site";
51
+ const icons = config.icons ?? [];
52
+ const manifest: Record<string, unknown> = {
53
+ display: config.display ?? "standalone",
54
+ icons,
55
+ name,
56
+ short_name: config.shortName ?? name,
57
+ start_url: config.startUrl ?? "/",
58
+ ...(config.description === undefined ? {} : { description: config.description }),
59
+ ...(config.themeColor === undefined ? {} : { theme_color: config.themeColor }),
60
+ ...(config.backgroundColor === undefined ? {} : { background_color: config.backgroundColor }),
61
+ ...(config.scope === undefined ? {} : { scope: config.scope }),
62
+ ...(config.orientation === undefined ? {} : { orientation: config.orientation }),
63
+ ...(config.lang === undefined ? {} : { lang: config.lang }),
64
+ ...(config.dir === undefined ? {} : { dir: config.dir }),
65
+ ...(config.categories === undefined ? {} : { categories: config.categories }),
66
+ };
67
+
68
+ /*
69
+ * A warning, not an error. The manifest is valid and useful without these — it still supplies
70
+ * the name and theme colour a browser shows — but a site cannot be *installed* without an icon
71
+ * at each size, and that is the whole reason most people add one.
72
+ */
73
+ const declared = new Set(
74
+ icons.flatMap((icon) =>
75
+ (icon.sizes ?? "").split(/\s+/).map((size) => Math.trunc(Number(size.split("x")[0]))),
76
+ ),
77
+ );
78
+ const missing = INSTALL_ICON_SIZES.filter((size) => !declared.has(size));
79
+ if (missing.length > 0) {
80
+ warnings.push(
81
+ `manifest: no ${missing.join("px and no ")}px icon declared — browsers will not offer to ` +
82
+ "install the site without one at each size.",
83
+ );
84
+ }
85
+
86
+ return {
87
+ errors,
88
+ files: [{ content: `${JSON.stringify(manifest, null, 2)}\n`, path: "manifest.webmanifest" }],
89
+ warnings,
90
+ };
91
+ }
92
+
93
+ /** The `<head>` entries a generated manifest needs to be found and to look right when installed. */
94
+ export function manifestHeadEntries(
95
+ projectConfig: ProjectConfig,
96
+ ): { tagName: string; attributes: Record<string, string> }[] {
97
+ const config = projectConfig.manifest;
98
+ if (config === undefined || config.enabled === false) {
99
+ return [];
100
+ }
101
+ const entries: { tagName: string; attributes: Record<string, string> }[] = [
102
+ { attributes: { href: "/manifest.webmanifest", rel: "manifest" }, tagName: "link" },
103
+ ];
104
+ if (config.themeColor !== undefined) {
105
+ entries.push({
106
+ attributes: { content: config.themeColor, name: "theme-color" },
107
+ tagName: "meta",
108
+ });
109
+ }
110
+ return entries;
111
+ }
112
+
113
+ /**
114
+ * Build `.well-known/security.txt` (RFC 9116).
115
+ *
116
+ * `.well-known` only. §3 makes that the canonical location, and a second copy at the root is a
117
+ * second thing to forget to update.
118
+ *
119
+ * @param {ProjectConfig} projectConfig
120
+ * @param {Date} now - Evaluated against `Expires`; injected so the check is testable
121
+ * @returns {WellKnownOutput}
122
+ */
123
+ export function buildSecurityTxt(projectConfig: ProjectConfig, now: Date): WellKnownOutput {
124
+ const config: SecurityTxtConfig | undefined = projectConfig.securityTxt;
125
+ const errors: string[] = [];
126
+ const warnings: string[] = [];
127
+ if (config === undefined || config.enabled === false) {
128
+ return { errors, files: [], warnings };
129
+ }
130
+
131
+ const contact = config.contact ?? [];
132
+ if (contact.length === 0) {
133
+ errors.push("securityTxt: at least one `contact` is required (RFC 9116 §2.5.3).");
134
+ }
135
+
136
+ /*
137
+ * `Expires` is required by §2.5.5, and it is the field everyone forgets. A past date is worse
138
+ * than a missing file: it advertises a reporting channel while telling the reporter the
139
+ * information is stale, so both cases fail the build rather than emitting something misleading.
140
+ */
141
+ if (config.expires === undefined) {
142
+ errors.push(
143
+ "securityTxt: `expires` is required (RFC 9116 §2.5.5) — a security.txt with no expiry " +
144
+ "never goes stale visibly, which is how they end up pointing at people who left.",
145
+ );
146
+ } else {
147
+ const expires = new Date(config.expires);
148
+ if (Number.isNaN(expires.getTime())) {
149
+ errors.push(`securityTxt: \`expires\` is not a valid date: "${config.expires}".`);
150
+ } else if (expires.getTime() <= now.getTime()) {
151
+ errors.push(
152
+ `securityTxt: \`expires\` is in the past (${expires.toISOString()}) — an expired ` +
153
+ "security.txt tells a reporter not to trust what it says.",
154
+ );
155
+ }
156
+ }
157
+
158
+ const languages: string[] = [];
159
+ for (const tag of config.preferredLanguages ?? []) {
160
+ const canonical = canonicalizeLocale(tag);
161
+ if (canonical === null) {
162
+ errors.push(`securityTxt.preferredLanguages: "${tag}" is not a well-formed language tag.`);
163
+ } else {
164
+ languages.push(canonical);
165
+ }
166
+ }
167
+
168
+ if (errors.length > 0) {
169
+ return { errors, files: [], warnings };
170
+ }
171
+
172
+ const lines: string[] = [
173
+ "# Generated by @jxsuite/compiler — configure via securityTxt in project.json.",
174
+ ...contact.map((value) => `Contact: ${value}`),
175
+ `Expires: ${new Date(config.expires as string).toISOString().replace(/\.\d{3}Z$/, "Z")}`,
176
+ ...(config.encryption ?? []).map((value) => `Encryption: ${value}`),
177
+ ...(config.acknowledgments ?? []).map((value) => `Acknowledgments: ${value}`),
178
+ ...(languages.length > 0 ? [`Preferred-Languages: ${languages.join(", ")}`] : []),
179
+ ...(config.canonical === undefined ? [] : [`Canonical: ${config.canonical}`]),
180
+ ...(config.policy ?? []).map((value) => `Policy: ${value}`),
181
+ ...(config.hiring ?? []).map((value) => `Hiring: ${value}`),
182
+ ];
183
+
184
+ return {
185
+ errors,
186
+ files: [{ content: `${lines.join("\n")}\n`, path: ".well-known/security.txt" }],
187
+ warnings,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Write generated files under `outDir`, skipping any the author already supplied.
193
+ *
194
+ * The skip is the point: `public/.well-known/security.txt` is copied before this runs, and a
195
+ * hand-placed file there is how an author ships a **clearsigned** one — which needs a private key
196
+ * at build time and is therefore not something the build can do. Deferring costs zero code.
197
+ *
198
+ * @param {readonly GeneratedFile[]} files
199
+ * @param {string} outDir
200
+ * @param {(path: string) => boolean} exists
201
+ * @returns {{ written: number; skipped: string[] }}
202
+ */
203
+ export function writeWellKnown(
204
+ files: readonly GeneratedFile[],
205
+ outDir: string,
206
+ exists: (path: string) => boolean,
207
+ ): { written: number; skipped: string[] } {
208
+ let written = 0;
209
+ const skipped: string[] = [];
210
+ for (const file of files) {
211
+ const target = join(outDir, file.path);
212
+ if (exists(target)) {
213
+ skipped.push(file.path);
214
+ continue;
215
+ }
216
+ mkdirSync(dirname(target), { recursive: true });
217
+ writeFileSync(target, file.content, "utf8");
218
+ written += 1;
219
+ }
220
+ return { skipped, written };
221
+ }