@jxsuite/compiler 1.5.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",
@@ -133,7 +133,7 @@ export async function validateProjectTree(
133
133
  const { addFormats, Ajv } = await loadAjv();
134
134
 
135
135
  // 3. Documents against the bundled document schema (the same schema the studio consumes).
136
- const { document: documentSchema } = await readBundledProjectSchemas(root);
136
+ const { document: documentSchema } = await readBundledProjectSchemas(root, { write: false });
137
137
  const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
138
138
  addFormats(docAjv);
139
139
  const validateDoc = docAjv.compile(documentSchema);
@@ -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
+ }
@@ -43,9 +43,18 @@ import {
43
43
  isMappedArray,
44
44
  isNamedFormulaDef,
45
45
  isPrototypeDef,
46
+ isTagExpression,
46
47
  paramNames,
48
+ tagNameCandidates,
47
49
  } from "@jxsuite/schema/guards";
48
50
  import type { CompileContext, ExpressionNode } from "../shared.ts";
51
+ import {
52
+ escapeToken,
53
+ objectKey,
54
+ refAccessor,
55
+ refBindingKey,
56
+ refSegments,
57
+ } from "@jxsuite/runtime/pointer";
49
58
  import type {
50
59
  JsonObject,
51
60
  JsonValue,
@@ -338,6 +347,19 @@ function buildClientNode(
338
347
  raw?.$media ?? context.media,
339
348
  );
340
349
 
350
+ /* REFUSED, LOUDLY. A chosen tag has no implementation in this target: the element's identity is
351
+ fixed in the prerendered markup, so there is nothing here to re-choose at hydration the way the
352
+ `$switch` binding below re-chooses a subtree. Emitting a silently wrong element would be an
353
+ instance of the class the tagName pattern was added to end, so a dynamic page that needs a
354
+ chosen tag is told to put the element in a component (which compiles through `compile-element`,
355
+ where the construct is implemented) rather than shipping markup with the wrong tag. */
356
+ if (isTagExpression(def.tagName)) {
357
+ throw new Error(
358
+ "A tag chosen at creation is not supported on a dynamic page yet (candidates: " +
359
+ `${tagNameCandidates(def.tagName).join(", ")}). Move the element into a component, or ` +
360
+ "give it a literal tagName.",
361
+ );
362
+ }
341
363
  const tag = def.tagName ?? "div";
342
364
  // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
343
365
  nextContext.preformatted = context.preformatted === true || PREFORMATTED_TAGS.has(tag);
@@ -484,6 +506,33 @@ function buildClientNode(
484
506
  // Inner content
485
507
  let inner = "";
486
508
  const source = raw ?? def;
509
+ if (source.$switch) {
510
+ // ─── $switch → lit-html render binding ───
511
+ // Without this branch the node fell through to the generic element path, which emitted a
512
+ // Container and then looked for `children` to recurse into — `cases` is not `children`, so the
513
+ // Subtree was never visited and the page shipped with the content missing, no error (#127).
514
+ // The container is emitted empty for the same reason the mapped-array branch below is: lit's
515
+ // `render` appends into the container rather than replacing its existing children, so any
516
+ // Prerendered branch would survive alongside the rendered one.
517
+ counter.needsLit = true;
518
+ const swKey = `_sw${counter.sw}`;
519
+ counter.sw += 1;
520
+ const discriminant = isRefObject(source.$switch)
521
+ ? `state.${refToBindingKey((source.$switch as { $ref: string }).$ref)}`
522
+ : `\`${String(source.$switch)}\``;
523
+ const cases = (source.cases ?? {}) as Record<string, JxMutableNode>;
524
+ const caseEntries = Object.entries(cases)
525
+ // An external `$ref` case cannot be fetched at compile time, exactly as in the static target.
526
+ .filter(([, caseDef]) => caseDef && !isRefObject(caseDef))
527
+ .map(
528
+ ([key, caseDef]) =>
529
+ `${JSON.stringify(key)}: html\`${emitChildLit(caseDef, nextContext.preformatted)}\``,
530
+ );
531
+ bindings.set(swKey, `() => ({${caseEntries.join(", ")}})[String(${discriminant})] ?? html\`\``);
532
+ bindAttrs.push(`:render="${swKey}"`);
533
+ const bindAttrStrSwitch = ` ${bindAttrs.join(" ")}`;
534
+ return `<${tag}${staticAttrs} data-bind${bindAttrStrSwitch}></${tag}>`;
535
+ }
487
536
  if (source.textContent !== undefined && !needsBind) {
488
537
  const value = resolveStaticValue(source.textContent, nextContext.scope);
489
538
  inner = value == null ? "" : escapeHtml(String(value));
@@ -588,6 +637,15 @@ function emitLitMapTemplate(def: JxMutableNode | undefined, preformatted = false
588
637
  if (!def) {
589
638
  return "";
590
639
  }
640
+ // Same refusal as `buildClientNode` above, for the repeater's item template — this target has no
641
+ // Branch construct, and a silently wrong element is what the tagName pattern exists to prevent.
642
+ if (isTagExpression(def.tagName)) {
643
+ throw new Error(
644
+ "A tag chosen at creation is not supported inside a dynamic page's repeater yet " +
645
+ `(candidates: ${tagNameCandidates(def.tagName).join(", ")}). Move the item into a ` +
646
+ "component, or give it a literal tagName.",
647
+ );
648
+ }
591
649
  const tag = def.tagName ?? "div";
592
650
  // `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
593
651
  const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
@@ -828,7 +886,11 @@ function emitClientModule(
828
886
  // State — reactive state
829
887
  lines.push("", "const state = reactive({");
830
888
  for (const [key, val] of stateEntries) {
831
- lines.push(` ${key}: ${JSON.stringify(val)},`);
889
+ /*
890
+ * State keys come from the document, so `{"user.name": 1}` is legal input. Pasting one raw as
891
+ * an identifier emitted `user.name: 1,` — a SyntaxError the build reported as a success.
892
+ */
893
+ lines.push(` ${objectKey(key)}: ${JSON.stringify(val)},`);
832
894
  }
833
895
  lines.push("});", "");
834
896
 
@@ -843,7 +905,7 @@ function emitClientModule(
843
905
  // Computed signals on state
844
906
  if (computedEntries.length > 0) {
845
907
  for (const [key, expr] of computedEntries) {
846
- lines.push(`state.${key} = computed(${expr});`);
908
+ lines.push(`${refAccessor("state", escapeToken(key))} = computed(${expr});`);
847
909
  }
848
910
  lines.push("");
849
911
  }
@@ -852,7 +914,7 @@ function emitClientModule(
852
914
  if (bindEntries.length > 0) {
853
915
  lines.push("const bind = {");
854
916
  for (const [key, expr] of bindEntries) {
855
- lines.push(` ${key}: ${expr},`);
917
+ lines.push(` ${objectKey(key)}: ${expr},`);
856
918
  }
857
919
  lines.push("};");
858
920
  } else {
@@ -867,7 +929,7 @@ function emitClientModule(
867
929
  const argNames = def.args ?? ["state"];
868
930
  const callArgs = argNames.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
869
931
  lines.push(
870
- ` ${key}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
932
+ ` ${objectKey(key)}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
871
933
  callArgs
872
934
  }); },`,
873
935
  );
@@ -983,19 +1045,27 @@ function emitCookieInit(key: string, cookieName: string, defaultVal: unknown) {
983
1045
  */
984
1046
  function mapRefToClientExpr(ref: string) {
985
1047
  if (ref.startsWith("$map/")) {
986
- return ref.slice("$map/".length).replaceAll("/", ".");
1048
+ // The loop variable is an emitted identifier, so its first segment is the base, not a member.
1049
+ const [head, ...rest] = refSegments(ref.slice("$map/".length));
1050
+ return refAccessor(head ?? "", rest.map((seg) => escapeToken(seg)).join("/"));
987
1051
  }
988
1052
  if (ref.startsWith("#/state/")) {
989
- return `state.${ref.slice("#/state/".length).replaceAll("/", ".")}`;
1053
+ return refAccessor("state", ref.slice("#/state/".length));
990
1054
  }
991
- return `state.${ref.replaceAll("/", ".")}`;
1055
+ return refAccessor("state", ref);
992
1056
  }
993
1057
 
1058
+ /**
1059
+ * The `bind`/`on` map key for a ref. It is emitted both as an object-literal key and as a
1060
+ * `data-bind` attribute value, so it has to survive being a JavaScript identifier and an HTML
1061
+ * attribute at once. Stripping the `#/state/` prefix first keeps every pure-slash ref on the key it
1062
+ * already had — `#/state/user/name` is still `user_name` — so this fix changes no shipped output.
1063
+ *
1064
+ * @param {string} ref
1065
+ * @returns {string}
1066
+ */
994
1067
  function refToBindingKey(ref: string) {
995
- if (ref.startsWith("#/state/")) {
996
- return ref.slice("#/state/".length).replaceAll("/", "_");
997
- }
998
- return ref.replaceAll("/", "_");
1068
+ return refBindingKey(ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref);
999
1069
  }
1000
1070
 
1001
1071
  /**
@@ -1007,11 +1077,8 @@ function addRefBinding(bindings: Map<string, string>, key: string, ref: string)
1007
1077
  if (bindings.has(key)) {
1008
1078
  return;
1009
1079
  }
1010
- if (ref.startsWith("#/state/")) {
1011
- const path = ref.slice("#/state/".length);
1012
- const parts = path.split("/");
1013
- bindings.set(key, `() => state.${parts.join(".")}`);
1014
- } else {
1015
- bindings.set(key, `() => state.${ref}`);
1016
- }
1080
+ bindings.set(
1081
+ key,
1082
+ `() => ${refAccessor("state", ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref)}`,
1083
+ );
1017
1084
  }