@jxsuite/compiler 3.0.0 → 4.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.
@@ -21,7 +21,15 @@ import {
21
21
  import { createHash } from "node:crypto";
22
22
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
23
23
  import { isMappedArray, isRef } from "@jxsuite/schema/guards";
24
- import { isNpmSpecifier, npmAssetPath, sidecarAssetPath } from "@jxsuite/schema/asset-paths";
24
+ import {
25
+ isNpmSpecifier,
26
+ npmAssetPath,
27
+ sidecarAssetPath,
28
+ siteAbsoluteUrl,
29
+ siteBasePath,
30
+ withBase,
31
+ } from "@jxsuite/schema/asset-paths";
32
+ import { rewriteHtmlBase } from "./base-path.ts";
25
33
  import {
26
34
  CLIENT_EXTERNALS,
27
35
  bundleEntry,
@@ -40,6 +48,7 @@ import {
40
48
  import {
41
49
  buildHeaderRules,
42
50
  contentTypeRules,
51
+ rebaseHeaderRules,
43
52
  writeHeaders,
44
53
  writeNoJekyll,
45
54
  } from "./headers-emitter.ts";
@@ -59,6 +68,7 @@ import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server
59
68
  import {
60
69
  buildComponentCSS,
61
70
  buildInitialScope,
71
+ buildInstanceScope,
62
72
  collectServerEntries,
63
73
  collectStyles,
64
74
  colorSchemePrePaintScript,
@@ -67,12 +77,14 @@ import {
67
77
  isComponentFullyStatic,
68
78
  isSingleExpression,
69
79
  isTemplateString,
80
+ liftPropsAttributes,
70
81
  preRenderComponentHtml,
71
82
  pureSchemeOf,
72
83
  renderStaticNode,
84
+ resolveHostStyle,
73
85
  resolveRefValue,
74
- resolveStaticValue,
75
86
  } from "../shared.ts";
87
+ import type { ComponentPrerenderContext } from "../shared.ts";
76
88
  import { resolvePrototypes } from "./prototype-resolver.ts";
77
89
  import { transformImageNodes } from "./image-transform.ts";
78
90
  import { collectCspSources, emptyCspSources } from "./csp.ts";
@@ -519,6 +531,12 @@ export async function buildSite(
519
531
  // Sitemap is generated from the route table when a production `url` is configured
520
532
  // (absolute <loc> URLs require it) and not explicitly disabled via build.sitemap: false.
521
533
  const siteUrl = projectConfig.url;
534
+ /*
535
+ * The path the site is deployed under, read back off `url` (RFC 3986 §5 — see `base-path.ts`).
536
+ * `""` for a site at an origin root, which is every deployment Jx documents, so every use below
537
+ * is a no-op there rather than a branch.
538
+ */
539
+ const basePath = siteBasePath(siteUrl);
522
540
  const sitemapEnabled = Boolean(siteUrl) && projectConfig.build.sitemap !== false;
523
541
  const sitemapEntries: SitemapEntry[] = [];
524
542
 
@@ -714,7 +732,9 @@ export async function buildSite(
714
732
  // Determine output path
715
733
  const outPath = routeToOutputPath(route.urlPattern, outDir, trailingSlash);
716
734
  mkdirSync(dirname(outPath), { recursive: true });
717
- writeFileSync(outPath, result.html, "utf8");
735
+ /* Last, after the scans above: they ask what this page REFERENCES, and the answer is a
736
+ build-output path, not a deployed URL. Re-rooting first would make every one of them miss. */
737
+ writeFileSync(outPath, rewriteHtmlBase(result.html, basePath), "utf8");
718
738
  fileCount += 1;
719
739
 
720
740
  // Record a sitemap entry for this concrete page (skip unexpanded dynamic routes and
@@ -735,7 +755,7 @@ export async function buildSite(
735
755
  typeof route.sourceMtime === "string" && route.sourceMtime !== ""
736
756
  ? route.sourceMtime
737
757
  : toRfc3339(statSync(route.sourcePath).mtime),
738
- loc: new URL(route.urlPattern, siteUrl).href,
758
+ loc: siteAbsoluteUrl(route.urlPattern, siteUrl),
739
759
  ...(routeAlternates.length > 0 && { alternates: routeAlternates }),
740
760
  });
741
761
  }
@@ -835,7 +855,13 @@ export async function buildSite(
835
855
  const skipWorker = adapter === "cloudflare-pages" && deduped.size === 0 && mounts.length === 0;
836
856
  const workerSource = skipWorker
837
857
  ? null
838
- : compileSiteServer([...deduped.values()], { adapter, connectors, i18n, mounts });
858
+ : compileSiteServer([...deduped.values()], {
859
+ adapter,
860
+ base: basePath,
861
+ connectors,
862
+ i18n,
863
+ mounts,
864
+ });
839
865
 
840
866
  if (workerSource) {
841
867
  // Bundle the worker self-contained for the adapter's runtime (compiler.md §12): mount
@@ -1014,6 +1040,7 @@ export async function buildSite(
1014
1040
  log("Generating redirects...");
1015
1041
  const compiledUrls = new Set(routes.map((r) => r.urlPattern));
1016
1042
  const redirects = generateRedirects(projectConfig.redirects, outDir, {
1043
+ basePath,
1017
1044
  compiledUrls,
1018
1045
  trailingSlash: projectConfig.build.trailingSlash,
1019
1046
  });
@@ -1091,7 +1118,8 @@ export async function buildSite(
1091
1118
  {
1092
1119
  const sw = normalizeServiceWorker(projectConfig.serviceWorker);
1093
1120
  if (sw !== null) {
1094
- const output = sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir);
1121
+ const output =
1122
+ sw === false ? tombstoneServiceWorker() : buildServiceWorker(sw, outDir, basePath);
1095
1123
  for (const warning of output.warnings) {
1096
1124
  console.warn(warning);
1097
1125
  }
@@ -1115,7 +1143,7 @@ export async function buildSite(
1115
1143
  );
1116
1144
  errors.push(...headerErrors);
1117
1145
  // Runs here, after every emitter, so it can see which of these files the build actually wrote.
1118
- const rules = [...securityRules, ...contentTypeRules(outDir)];
1146
+ const rules = rebaseHeaderRules([...securityRules, ...contentTypeRules(outDir)], basePath);
1119
1147
  if (rules.length > 0) {
1120
1148
  log("Writing response headers...");
1121
1149
  fileCount += writeHeaders(outDir, rules);
@@ -1215,6 +1243,10 @@ async function compilePage(
1215
1243
  /** Registers a page's npm `$elements` set as one bundle and returns its URL. */
1216
1244
  registerElementBundle: (specifiers: string[]) => string,
1217
1245
  ) {
1246
+ /* Derived rather than passed as a seventeenth parameter: it is a pure function of
1247
+ `projectConfig.url`, which is already here, so the two cannot disagree. */
1248
+ const basePath = siteBasePath(projectConfig.url);
1249
+
1218
1250
  // Load the raw page document (.json natively, other formats via the registry)
1219
1251
  const pageDoc = await readPageDocument(route.sourcePath as string, formatRegistry);
1220
1252
 
@@ -1364,7 +1396,7 @@ async function compilePage(
1364
1396
  const swHead: JxHeadEntry[] =
1365
1397
  swConfig === null || swConfig === false
1366
1398
  ? []
1367
- : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/") }];
1399
+ : [{ tagName: "script", textContent: registrationScript(swConfig.scope ?? "/", basePath) }];
1368
1400
 
1369
1401
  const resolvedSiteHead = [
1370
1402
  // The manifest link and theme colour are first-party and unconditional once declared, so they
@@ -1528,6 +1560,7 @@ function buildMountSpecs(
1528
1560
  if (activeMounts.length === 0) {
1529
1561
  return { connectors: [], mounts: [] };
1530
1562
  }
1563
+ const base = siteBasePath(projectConfig.url);
1531
1564
 
1532
1565
  const sections: Record<string, unknown> = {};
1533
1566
  for (const contribution of registry.projectContributions()) {
@@ -1546,11 +1579,15 @@ function buildMountSpecs(
1546
1579
  `to generate a deployable worker`,
1547
1580
  );
1548
1581
  }
1582
+ /* Both are request paths in the deployed worker: the route it registers, and the prefix the
1583
+ mount strips to find its own sub-path. They must carry the same base or the handler would
1584
+ answer a URL and then mis-read it. */
1585
+ const mountBase = withBase(base, server.basePath);
1549
1586
  return {
1550
- basePath: server.basePath,
1587
+ basePath: mountBase,
1551
1588
  className: (entry.classDef.title as string | undefined) ?? entry.name,
1552
1589
  module,
1553
- options: { basePath: server.basePath, sections },
1590
+ options: { basePath: mountBase, sections },
1554
1591
  order: server.order ?? 100,
1555
1592
  };
1556
1593
  });
@@ -2007,27 +2044,23 @@ function expandComponents(
2007
2044
 
2008
2045
  const def = componentDefs.get(node.tagName as string);
2009
2046
  if (def) {
2010
- // JSON-authored instances pass props as literal `props.*` attribute keys (markdown directives
2011
- // Are normalized to $props by the parser's expandDotPaths, but JSON is parsed verbatim).
2012
- // Lift them into $props so the pre-render sees them, and strip them from attributes so they
2013
- // Don't leak into the emitted HTML. Values stay raw strings — no coercion, matching the
2014
- // Markdown path and the runtime's $props semantics. Explicit $props wins on key conflicts.
2047
+ // Literal `props.*` attribute keys become $props (`liftPropsAttributes` says why) and leave the
2048
+ // Attributes, so they do not reach the emitted HTML. Explicit $props wins on key conflicts.
2015
2049
  if (node.attributes) {
2016
- let lifted: NonNullable<JxElement["$props"]> | null = null;
2017
- for (const [key, value] of Object.entries(node.attributes)) {
2018
- if (key.startsWith("props.") && key.length > "props.".length) {
2019
- lifted ??= {};
2020
- // JxAttributeValue is JSON-representable (primitives or a $ref object), so the
2021
- // Narrowing to JsonValue is sound.
2022
- lifted[key.slice("props.".length)] = value as JsonValue;
2023
- delete node.attributes[key];
2024
- }
2025
- }
2050
+ const { lifted, rest } = liftPropsAttributes(node.attributes);
2026
2051
  if (lifted) {
2052
+ node.attributes = rest;
2027
2053
  node.$props = { ...lifted, ...node.$props };
2028
2054
  }
2029
2055
  }
2030
2056
 
2057
+ /*
2058
+ * Slotted children are rendered with NO component registry: this walk is bottom-up, so any
2059
+ * instance among them was expanded a moment ago and now carries its markup as `innerHTML` and
2060
+ * no `$props`. Expanding it again would render the definition's defaults over the props it
2061
+ * was given. Instances written inside `def` itself are another matter — those are reached
2062
+ * below, through `preRenderComponentHtml`, with `node` as the first frame of the path.
2063
+ */
2031
2064
  const slotContent =
2032
2065
  Array.isArray(node.children) && node.children.length > 0
2033
2066
  ? node.children
@@ -2047,7 +2080,15 @@ function expandComponents(
2047
2080
  * render them twice.
2048
2081
  */
2049
2082
  const shadow = resolveShadowMode(def, defaults);
2050
- const innerHTML = preRenderComponentHtml(def, node.$props || null, shadow ? null : slotContent);
2083
+ const props = node.$props || null;
2084
+ // This instance is the first frame of the path, so a definition that names itself is reported
2085
+ // At the first nesting rather than after the stack overflows.
2086
+ const context: ComponentPrerenderContext = {
2087
+ componentDefs,
2088
+ defaults,
2089
+ path: [{ key: JSON.stringify(props ?? {}), tag: node.tagName as string }],
2090
+ };
2091
+ const innerHTML = preRenderComponentHtml(def, props, shadow ? null : slotContent, context);
2051
2092
  const isStatic = isComponentFullyStatic(def);
2052
2093
 
2053
2094
  /*
@@ -2063,26 +2104,20 @@ function expandComponents(
2063
2104
  : innerHTML;
2064
2105
  delete node.children;
2065
2106
 
2066
- // Resolve template-string host styles with props (per-instance values like background-image)
2067
- if (def.style && node.$props) {
2068
- const stateDefs: Record<string, JxStateDefinition> = { ...def.state };
2069
- for (const [key, value] of Object.entries(node.$props)) {
2070
- stateDefs[key] =
2071
- key in stateDefs ? (value as JxStateDefinition) : (value as JxStateDefinition);
2072
- }
2073
- const scope = buildInitialScope(stateDefs, null);
2074
- const resolvedStyle: Record<string, unknown> = {};
2075
- for (const [prop, value] of Object.entries(def.style)) {
2076
- if (typeof value === "string" && isTemplateString(value)) {
2077
- const resolved = resolveStaticValue(value, scope);
2078
- if (resolved != null) {
2079
- resolvedStyle[prop] = resolved;
2080
- }
2081
- }
2082
- }
2083
- if (Object.keys(resolvedStyle).length > 0) {
2084
- node.style = { ...node.style, ...resolvedStyle } as JxStyle;
2085
- }
2107
+ /*
2108
+ * Resolve template-string host styles against the instance (per-instance values like
2109
+ * background-image). The page's style pass gives the result a class rule; a nested instance
2110
+ * gets the same values inline, from the same resolver (`renderComponentInstance` in shared.ts).
2111
+ *
2112
+ * Resolved whether or not the instance passes props: a template declaration is a reactive one
2113
+ * and `pushStyleRules` drops it from the component stylesheet, so the value resolved here is
2114
+ * the ONLY place it reaches a static page. This used to be guarded on `$props`, which left an
2115
+ * instance that took the definition's defaults with no host style at all — no size, no mask —
2116
+ * while the same instance one level down had them.
2117
+ */
2118
+ const resolvedStyle = resolveHostStyle(def.style, buildInstanceScope(def, props));
2119
+ if (Object.keys(resolvedStyle).length > 0) {
2120
+ node.style = { ...node.style, ...resolvedStyle } as JxStyle;
2086
2121
  }
2087
2122
 
2088
2123
  /*
@@ -2369,7 +2404,7 @@ function generateRedirects(
2369
2404
  string | { destination: string; status?: number } | { destination: string; rewrite: true }
2370
2405
  >,
2371
2406
  outDir: string,
2372
- opts: { compiledUrls?: Set<string>; trailingSlash?: string } = {},
2407
+ opts: { compiledUrls?: Set<string>; trailingSlash?: string; basePath?: string } = {},
2373
2408
  ) {
2374
2409
  let files = 0;
2375
2410
  const errors: string[] = [];
@@ -2396,7 +2431,11 @@ function generateRedirects(
2396
2431
  continue;
2397
2432
  }
2398
2433
 
2399
- redirectLines.push(`${source} ${dest} ${status}`);
2434
+ /* Both halves are site URLs, and the host matches the source against a REQUEST path — which
2435
+ arrives under the base. A rule left at the old root would never fire, and one that did would
2436
+ send the visitor out of the deployment. */
2437
+ const rebased = opts.basePath ?? "";
2438
+ redirectLines.push(`${withBase(rebased, source)} ${withBase(rebased, dest)} ${status}`);
2400
2439
 
2401
2440
  // A pattern source cannot be a file on disk, so it lives only in `_redirects`.
2402
2441
  if (source.includes(":") || source.includes("*")) {
@@ -2563,7 +2602,7 @@ function ensureRobotsSitemap(outDir: string, siteUrl: string) {
2563
2602
  if (!content.endsWith("\n")) {
2564
2603
  content += "\n";
2565
2604
  }
2566
- content += `\nSitemap: ${new URL("/sitemap.xml", siteUrl).href}\n`;
2605
+ content += `\nSitemap: ${siteAbsoluteUrl("/sitemap.xml", siteUrl)}\n`;
2567
2606
  writeFileSync(robotsPath, content, "utf8");
2568
2607
  return existed ? 0 : 1;
2569
2608
  }
@@ -22,6 +22,11 @@ import { createRequire } from "node:module";
22
22
  import { join, relative, resolve } from "node:path";
23
23
  import { generateClassSchema } from "@jxsuite/schema";
24
24
  import { validateProjectFile } from "@jxsuite/schema/validate-project";
25
+ import { findA11yDefects } from "@jxsuite/schema/a11y";
26
+ import { findDialogDefects } from "@jxsuite/schema/dialogs";
27
+ import { findPopoverDefects, overlayScopeFor } from "@jxsuite/schema/overlays";
28
+ import type { OverlayScope } from "@jxsuite/schema/overlays";
29
+ import type { JxElement } from "@jxsuite/schema/types";
25
30
  import { buildProjectExtensionRegistry } from "./format-host.ts";
26
31
  import { loadProjectConfig } from "./site-loader.ts";
27
32
  import { readBundledProjectSchemas } from "./schema-command.ts";
@@ -46,8 +51,36 @@ export interface ProjectTreeIssue {
46
51
  errors: unknown[];
47
52
  }
48
53
 
54
+ /**
55
+ * One overlay, dialog or accessibility finding in a document — `jx validate`'s rendering of the
56
+ * lints Studio files as Problems (spec §8.7, §8.8). Advisory by default: a lint is a judgement
57
+ * about a document that is well-formed, so it never makes the tree INVALID unless `strict` asks it
58
+ * to.
59
+ */
60
+ export interface ProjectTreeLintFinding {
61
+ /** Project-relative file path. */
62
+ file: string;
63
+ /** The node's path inside the document. */
64
+ path: (string | number)[];
65
+ /** Which lint: `popover`, `dialog` or `accessibility`. */
66
+ source: "popover" | "dialog" | "accessibility";
67
+ rule: string;
68
+ message: string;
69
+ detail: string;
70
+ severity: "error" | "warn";
71
+ /** The WCAG success criterion, for accessibility findings. */
72
+ criterion?: string;
73
+ }
74
+
75
+ export interface ValidateProjectTreeOptions {
76
+ /** Fail the tree on a lint ERROR as well as on a schema error. Warnings never fail it. */
77
+ strict?: boolean;
78
+ }
79
+
49
80
  export interface ProjectTreeValidationResult {
50
81
  valid: boolean;
82
+ /** Every lint finding, whatever `valid` says. */
83
+ lint: ProjectTreeLintFinding[];
51
84
  /** Number of files checked across all five walks. */
52
85
  checked: number;
53
86
  issues: ProjectTreeIssue[];
@@ -91,9 +124,11 @@ async function loadAjv(): Promise<{ Ajv: AjvCtor; addFormats: AddFormatsFn }> {
91
124
  */
92
125
  export async function validateProjectTree(
93
126
  projectRoot: string,
127
+ options: ValidateProjectTreeOptions = {},
94
128
  ): Promise<ProjectTreeValidationResult> {
95
129
  const root = resolve(projectRoot);
96
130
  const issues: ProjectTreeIssue[] = [];
131
+ const lint: ProjectTreeLintFinding[] = [];
97
132
  let checked = 0;
98
133
 
99
134
  /* 1. Committed entry documents must be self-contained (the editor-resolution guarantee). This
@@ -120,7 +155,7 @@ export async function validateProjectTree(
120
155
  }
121
156
  }
122
157
  if (issues.length > 0) {
123
- return { checked, issues, valid: false };
158
+ return { checked, issues, lint, valid: false };
124
159
  }
125
160
 
126
161
  // 2. project.json against the generated entry schema.
@@ -137,6 +172,21 @@ export async function validateProjectTree(
137
172
  const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
138
173
  addFormats(docAjv);
139
174
  const validateDoc = docAjv.compile(documentSchema);
175
+ /* The project's own element definitions, so the overlay rules judge a custom element as what it
176
+ renders. A component IS a popover when its definition declares one, and a component FORWARDS
177
+ invocation when it observes the four invoker attributes — neither fact is visible in the page
178
+ that uses it, so without this the CLI reports a target mismatch on correct markup that the
179
+ studio, which passes the same scope, calls clean. */
180
+ const scope = overlayScopeFor(
181
+ walkClassFiles(root).flatMap((file) => {
182
+ try {
183
+ return [JSON.parse(readFileSync(file, "utf8")) as JxElement];
184
+ } catch {
185
+ // A malformed definition is reported by the class-schema pass below, not here.
186
+ return [];
187
+ }
188
+ }),
189
+ );
140
190
  for (const dir of DOCUMENT_DIRS) {
141
191
  for (const file of walkJsonFiles(resolve(root, dir))) {
142
192
  if (file.endsWith(".class.json")) {
@@ -144,7 +194,10 @@ export async function validateProjectTree(
144
194
  }
145
195
  checked += 1;
146
196
  const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
147
- if (!validateDoc(doc)) {
197
+ if (validateDoc(doc)) {
198
+ // Only a well-formed document is judged; a schema error is the report for the rest.
199
+ lint.push(...lintDocument(doc as JxElement, relative(root, file), scope));
200
+ } else {
148
201
  issues.push({ errors: validateDoc.errors ?? [], file: relative(root, file) });
149
202
  }
150
203
  }
@@ -191,7 +244,51 @@ export async function validateProjectTree(
191
244
  }
192
245
  }
193
246
 
194
- return { checked, issues, valid: issues.length === 0 };
247
+ const fatalLint = options.strict === true && lint.some((finding) => finding.severity === "error");
248
+ return { checked, issues, lint, valid: issues.length === 0 && !fatalLint };
249
+ }
250
+
251
+ /** The three lints over one document, as findings that name their file. */
252
+ function lintDocument(doc: JxElement, file: string, scope: OverlayScope): ProjectTreeLintFinding[] {
253
+ const findings: ProjectTreeLintFinding[] = [];
254
+ for (const defect of findPopoverDefects(doc, scope)) {
255
+ findings.push({ ...pick(defect), file, source: "popover" });
256
+ }
257
+ for (const defect of findDialogDefects(doc, scope)) {
258
+ findings.push({ ...pick(defect), file, source: "dialog" });
259
+ }
260
+ for (const defect of findA11yDefects(doc)) {
261
+ findings.push({ ...pick(defect), criterion: defect.criterion, file, source: "accessibility" });
262
+ }
263
+ return findings;
264
+ }
265
+
266
+ /** The fields every defect shape shares. */
267
+ function pick(defect: {
268
+ rule: string;
269
+ path: (string | number)[];
270
+ message: string;
271
+ detail: string;
272
+ severity: "error" | "warn";
273
+ }): Pick<ProjectTreeLintFinding, "rule" | "path" | "message" | "detail" | "severity"> {
274
+ return {
275
+ detail: defect.detail,
276
+ message: defect.message,
277
+ path: defect.path,
278
+ rule: defect.rule,
279
+ severity: defect.severity,
280
+ };
281
+ }
282
+
283
+ /** One line per lint finding, for the CLI: `file: severity: message [source/rule, WCAG n]`. */
284
+ export function formatProjectTreeLint(result: ProjectTreeValidationResult): string[] {
285
+ return result.lint.map((finding) => {
286
+ const cite =
287
+ finding.criterion === undefined
288
+ ? `${finding.source}/${finding.rule}`
289
+ : `${finding.source}/${finding.rule}, WCAG ${finding.criterion}`;
290
+ return `${finding.file}: ${finding.severity}: ${finding.message} [${cite}]`;
291
+ });
195
292
  }
196
293
 
197
294
  /**
@@ -9,6 +9,7 @@
9
9
  * @docs framework/site/deployment
10
10
  */
11
11
 
12
+ import { siteBasePath, withBase } from "@jxsuite/schema/asset-paths";
12
13
  import { mkdirSync, writeFileSync } from "node:fs";
13
14
  import { dirname, join } from "node:path";
14
15
  import { canonicalizeLocale } from "@jxsuite/schema/locale";
@@ -48,17 +49,27 @@ export function buildManifest(projectConfig: ProjectConfig): WellKnownOutput {
48
49
  }
49
50
 
50
51
  const name = config.name ?? projectConfig.name ?? "Jx Site";
51
- const icons = config.icons ?? [];
52
+ /*
53
+ * Every URL in a manifest is fetched or navigated to by the browser, so all three carry the
54
+ * deployment base (`base-path.ts`). `scope` is the one that fails loudly — a scope the manifest's
55
+ * own URL is not inside makes the manifest invalid and the site uninstallable — while a
56
+ * `start_url` at the old root just opens the wrong page from the home screen.
57
+ */
58
+ const base = siteBasePath(projectConfig.url);
59
+ const icons = [];
60
+ for (const icon of config.icons ?? []) {
61
+ icons.push(typeof icon.src === "string" ? { ...icon, src: withBase(base, icon.src) } : icon);
62
+ }
52
63
  const manifest: Record<string, unknown> = {
53
64
  display: config.display ?? "standalone",
54
65
  icons,
55
66
  name,
56
67
  short_name: config.shortName ?? name,
57
- start_url: config.startUrl ?? "/",
68
+ start_url: withBase(base, config.startUrl ?? "/"),
58
69
  ...(config.description === undefined ? {} : { description: config.description }),
59
70
  ...(config.themeColor === undefined ? {} : { theme_color: config.themeColor }),
60
71
  ...(config.backgroundColor === undefined ? {} : { background_color: config.backgroundColor }),
61
- ...(config.scope === undefined ? {} : { scope: config.scope }),
72
+ ...(config.scope === undefined ? {} : { scope: withBase(base, config.scope) }),
62
73
  ...(config.orientation === undefined ? {} : { orientation: config.orientation }),
63
74
  ...(config.lang === undefined ? {} : { lang: config.lang }),
64
75
  ...(config.dir === undefined ? {} : { dir: config.dir }),
@@ -13,6 +13,9 @@
13
13
 
14
14
  import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
15
15
  import {
16
+ ATTR_HELPER,
17
+ attrHelperSource,
18
+ booleanAttrValue,
16
19
  DEFAULT_LIT_HTML_SRC,
17
20
  DEFAULT_REACTIVITY_SRC,
18
21
  buildAttrs,
@@ -675,10 +678,24 @@ function emitLitMapTemplate(def: JxMutableNode | undefined, preformatted = false
675
678
  // Attributes object
676
679
  if (def.attributes && typeof def.attributes === "object") {
677
680
  for (const [k, v] of Object.entries(def.attributes)) {
678
- attrs +=
679
- typeof v === "string" && isTemplateString(v)
680
- ? ` ${k}="${mapRefsToLit(v)}"`
681
- : ` ${k}="${escapeHtml(String(v))}"`;
681
+ if (typeof v === "string" && isTemplateString(v)) {
682
+ attrs += ` ${k}="${mapRefsToLit(v)}"`;
683
+ continue;
684
+ }
685
+ /* A boolean is the attribute's PRESENCE or its text depending on the family its NAME puts it
686
+ in — `booleanAttrValue` owns that judgement, and the static emitter in `shared.ts` and the
687
+ runtime's `applyAttributes` both defer to it, so a prerendered page cannot change meaning
688
+ as it hydrates (spec.md §8.3). Stringifying here emitted `open="false"`, which HTML reads
689
+ by presence and therefore as an OPEN element: the author wrote "closed" and the page
690
+ rendered open. */
691
+ if (typeof v === "boolean") {
692
+ const text = booleanAttrValue(k, v);
693
+ if (text !== null) {
694
+ attrs += text === "" ? ` ${k}` : ` ${k}="${escapeHtml(text)}"`;
695
+ }
696
+ continue;
697
+ }
698
+ attrs += ` ${k}="${escapeHtml(String(v))}"`;
682
699
  }
683
700
  }
684
701
 
@@ -884,7 +901,9 @@ function emitClientModule(
884
901
  }
885
902
 
886
903
  // State — reactive state
887
- lines.push("", "const state = reactive({");
904
+ /* The boolean-attribute rule, inlined because this module loads with `@vue/reactivity` and
905
+ nothing else — see `attrHelperSource`. The hydration script below is its only caller. */
906
+ lines.push("", attrHelperSource(), "", "const state = reactive({");
888
907
  for (const [key, val] of stateEntries) {
889
908
  /*
890
909
  * State keys come from the document, so `{"user.name": 1}` is legal input. Pasting one raw as
@@ -960,7 +979,14 @@ function emitClientModule(
960
979
  lines.push(
961
980
  " effect(() => { el.style[parts[1]] = bind[key](); });",
962
981
  " } else if (parts[0] === 'attr' && parts.length > 1) {",
963
- " effect(() => { el.setAttribute(parts[1], bind[key]()); });",
982
+ // A binding that flips back has to take the attribute WITH it: the element is being re-used, so
983
+ // Anything left behind is the state the page just said it had left. Same decision as the
984
+ // Runtime's `applyAttributes`, through the same inlined rule — see `attrHelperSource`.
985
+ " effect(() => {",
986
+ ` const _t = ${ATTR_HELPER}(parts[1], bind[key]());`,
987
+ " if (_t === null) { el.removeAttribute(parts[1]); }",
988
+ " else { el.setAttribute(parts[1], _t); }",
989
+ " });",
964
990
  " } else {",
965
991
  " const prop = parts[0].replace(/-([a-z])/g, (_, c) => c.toUpperCase());",
966
992
  " effect(() => { el[prop] = bind[key](); });",
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { RESERVED_KEYS, camelToKebab } from "@jxsuite/runtime";
9
9
  import {
10
+ ATTR_HELPER,
11
+ attrHelperSource,
12
+ booleanAttrValue,
10
13
  PREFORMATTED_TAGS,
11
14
  collectStyles,
12
15
  compileExpression,
@@ -405,7 +408,11 @@ export function emitElementModule(
405
408
 
406
409
  lines.push(
407
410
  `import { reactive, computed, effect, stop } from '@vue/reactivity';`,
408
- `import { render, html } from 'lit-html';`,
411
+ /* `nothing` is what REMOVES an attribute in a lit template, and it is the only way to express a
412
+ false presence attribute: writing `open="false"` produces an OPEN <details>. See
413
+ `attrHelperSource`. */
414
+ `import { render, html, nothing } from 'lit-html';`,
415
+ attrHelperSource(),
409
416
  );
410
417
 
411
418
  /*
@@ -1037,10 +1044,26 @@ ${no}
1037
1044
 
1038
1045
  if (def.attributes) {
1039
1046
  for (const [key, val] of Object.entries(def.attributes)) {
1047
+ /* Every branch here answers the same question the runtime's `applyAttributes` answers, and
1048
+ must answer it the same way or a prerendered page changes meaning as it hydrates (spec.md
1049
+ §8.3). A LITERAL boolean is decided now; a BOUND one cannot be, because its resolved type
1050
+ is not known until the effect runs — so it goes through the emitted helper, whose `null`
1051
+ becomes lit's `nothing` and takes the attribute off. */
1040
1052
  if (val && typeof val === "object" && isRef(val)) {
1041
- parts.push(`${key}="\${${refToExpr(val.$ref)}}"`);
1053
+ parts.push(`${key}="\${${ATTR_HELPER}('${key}', ${refToExpr(val.$ref)}) ?? nothing}"`);
1054
+ } else if (typeof val === "string" && isWholeTemplate(val)) {
1055
+ parts.push(
1056
+ `${key}="\${${ATTR_HELPER}('${key}', ${toLitExpr(val).slice(2, -1)}) ?? nothing}"`,
1057
+ );
1042
1058
  } else if (typeof val === "string" && val.includes("${")) {
1059
+ /* A template with literal text around it — `"page ${n}"` — is a string by construction, so
1060
+ there is no boolean to decide and wrapping it would only add a call. */
1043
1061
  parts.push(`${key}="${toLitExpr(val)}"`);
1062
+ } else if (typeof val === "boolean") {
1063
+ const text = booleanAttrValue(key, val);
1064
+ if (text !== null) {
1065
+ parts.push(text === "" ? key : `${key}="${text}"`);
1066
+ }
1044
1067
  } else {
1045
1068
  parts.push(`${key}="${val}"`);
1046
1069
  }
@@ -1404,6 +1427,20 @@ function toLitExpr(str: string) {
1404
1427
  return str.replaceAll("state.", "s.");
1405
1428
  }
1406
1429
 
1430
+ /**
1431
+ * Whether the whole attribute value is ONE `${…}` and nothing else.
1432
+ *
1433
+ * Only such a value can resolve to a boolean — anything with literal text around the expression is
1434
+ * a string by construction — so this is the test for whether the boolean decision has to be
1435
+ * deferred to runtime.
1436
+ *
1437
+ * @param {string} str
1438
+ * @returns {boolean}
1439
+ */
1440
+ function isWholeTemplate(str: string): boolean {
1441
+ return str.startsWith("${") && str.endsWith("}") && !str.slice(2).includes("${");
1442
+ }
1443
+
1407
1444
  /**
1408
1445
  * Convert textContent value to lit-html text content. Bug fix: handles $ref objects, which
1409
1446
  * previously produced [object Object].
@@ -5,6 +5,7 @@
5
5
  * file.
6
6
  */
7
7
 
8
+ import { withBase } from "@jxsuite/schema/asset-paths";
8
9
  import { dereference } from "@apidevtools/json-schema-ref-parser";
9
10
  import { collectServerEntries } from "../shared.ts";
10
11
  import { localeNegotiationMiddleware } from "../site/locale-negotiation.ts";
@@ -110,15 +111,21 @@ export function compileSiteServer(
110
111
  mounts?: SiteMountSpec[];
111
112
  connectors?: SiteConnectorSpec[];
112
113
  i18n?: ResolvedI18n | null;
114
+ base?: string;
113
115
  } = {},
114
116
  ) {
115
- const {
116
- baseUrl = "/_jx/server",
117
- adapter = null,
118
- mounts = [],
119
- connectors = [],
120
- i18n = null,
121
- } = opts;
117
+ const { adapter = null, mounts = [], connectors = [], i18n = null, base = "" } = opts;
118
+ /*
119
+ * Routes are registered under the deployment base (`site/base-path.ts`), because the worker IS
120
+ * the origin: the request it sees is the one the visitor's browser sent, and on a site served
121
+ * from `/m/my-site/` that path carries the prefix. A route left at the bare path would simply
122
+ * never match — the 404 nobody attributes to a config value.
123
+ *
124
+ * The dev server takes the opposite approach and strips the base at the edge (`server.ts`), so
125
+ * everything behind it routes on the bare path. Each is internally consistent: production serves
126
+ * exactly one URL space, while `jx dev` deliberately answers both.
127
+ */
128
+ const baseUrl = withBase(base, opts.baseUrl ?? "/_jx/server");
122
129
  if (entries.length === 0 && mounts.length === 0 && !adapter) {
123
130
  return null;
124
131
  }