@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.
- package/dist/cli.js +281 -276
- package/dist/compiler.js +130 -126
- package/package.json +10 -10
- package/src/cli.ts +10 -4
- package/src/shared.ts +514 -299
- package/src/site/base-path.ts +175 -0
- package/src/site/dev-command.ts +4 -2
- package/src/site/format-host.ts +54 -1
- package/src/site/headers-emitter.ts +20 -0
- package/src/site/i18n.ts +3 -2
- package/src/site/prototype-resolver.ts +2 -1
- package/src/site/service-worker.ts +22 -5
- package/src/site/site-build.ts +88 -49
- package/src/site/validate-command.ts +100 -3
- package/src/site/well-known.ts +14 -3
- package/src/targets/compile-client.ts +32 -6
- package/src/targets/compile-element.ts +39 -2
- package/src/targets/compile-server.ts +14 -7
package/src/site/site-build.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
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()], {
|
|
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 =
|
|
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:
|
|
1587
|
+
basePath: mountBase,
|
|
1551
1588
|
className: (entry.classDef.title as string | undefined) ?? entry.name,
|
|
1552
1589
|
module,
|
|
1553
|
-
options: { basePath:
|
|
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
|
-
//
|
|
2011
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
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
|
-
|
|
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: ${
|
|
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 (
|
|
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
|
-
|
|
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
|
/**
|
package/src/site/well-known.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
}
|