@jxsuite/compiler 3.1.0 → 4.0.1
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 +263 -260
- package/dist/compiler.js +127 -125
- package/package.json +9 -9
- package/src/cli.ts +10 -4
- package/src/shared.ts +502 -336
- package/src/site/dev-command.ts +4 -2
- package/src/site/format-host.ts +54 -1
- package/src/site/prototype-resolver.ts +2 -1
- package/src/site/site-build.ts +54 -40
- package/src/site/validate-command.ts +100 -3
- package/src/targets/compile-client.ts +32 -6
- package/src/targets/compile-element.ts +39 -2
package/src/site/dev-command.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Dev command — `jx dev` resolves the project's @jxsuite/server dev entry and spawns it under Bun.
|
|
3
3
|
*
|
|
4
4
|
* The compiler cannot depend on @jxsuite/server (the server already depends on the compiler), and
|
|
5
|
-
* the dev server is Bun-native while the jx bin runs under Node — so the command is pure
|
|
6
|
-
*
|
|
5
|
+
* the dev server is Bun-native while the jx bin runs under Node — so the command is pure
|
|
6
|
+
* resolution
|
|
7
|
+
*
|
|
8
|
+
* - Spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
|
|
7
9
|
*
|
|
8
10
|
* @module dev-command
|
|
9
11
|
*/
|
package/src/site/format-host.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync } from "node:fs";
|
|
|
10
10
|
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
11
11
|
import { pathToFileURL } from "node:url";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
-
import { buildExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
13
|
+
import { buildExtensionRegistry, EXTENSION_MANIFEST } from "@jxsuite/schema/extension-registry";
|
|
14
14
|
import type { ExtensionRegistry } from "@jxsuite/schema/extension-registry";
|
|
15
15
|
import type { FormatHostIO, FormatRegistry, ProjectBlock } from "@jxsuite/schema/format-registry";
|
|
16
16
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
@@ -75,6 +75,59 @@ export function createNodeFormatIO(projectRoot: string): FormatHostIO {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Where an extension package's manifest resolves from — the two answers `createNodeFormatIO`
|
|
80
|
+
* merges.
|
|
81
|
+
*/
|
|
82
|
+
export interface ExtensionResolution {
|
|
83
|
+
/** Resolvable from the PROJECT's own node_modules. */
|
|
84
|
+
project: boolean;
|
|
85
|
+
/** Resolvable from the HOST's module graph — `createNodeFormatIO`'s fallback branch. */
|
|
86
|
+
host: boolean;
|
|
87
|
+
/** The manifest path from whichever probe answered; the project wins, matching resolution order. */
|
|
88
|
+
manifestPath?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Split `createNodeFormatIO`'s two-branch resolution into its two ANSWERS.
|
|
93
|
+
*
|
|
94
|
+
* The IO deliberately collapses them (project first, then host) because a caller loading a class
|
|
95
|
+
* only needs the file. A catalogue needs them apart: "the project installed this" and "this host
|
|
96
|
+
* ships it" are different affordances — install-then-enable versus enable — and a surface that
|
|
97
|
+
* cannot tell them apart offers the wrong one.
|
|
98
|
+
*
|
|
99
|
+
* Probes the MANIFEST rather than the package directory, because the manifest is what the registry
|
|
100
|
+
* resolves (see `loadExtension`): a package present in node_modules whose `exports` map omits
|
|
101
|
+
* `./jx-extension.json` is one whose enablement would throw, so it must not read as resolvable.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} projectRoot - The project whose node_modules is tried first
|
|
104
|
+
* @param {string} name - Bare package specifier, e.g. "@jxsuite/parser"
|
|
105
|
+
* @returns {ExtensionResolution}
|
|
106
|
+
*/
|
|
107
|
+
export function probeExtension(projectRoot: string, name: string): ExtensionResolution {
|
|
108
|
+
const ref = `${name}/${EXTENSION_MANIFEST}`;
|
|
109
|
+
let manifestPath: string | undefined;
|
|
110
|
+
|
|
111
|
+
let project = false;
|
|
112
|
+
try {
|
|
113
|
+
manifestPath = createRequire(resolve(projectRoot, "package.json")).resolve(ref);
|
|
114
|
+
project = true;
|
|
115
|
+
} catch {
|
|
116
|
+
// Not installed in the project. The host probe below decides whether it resolves at all.
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let host = false;
|
|
120
|
+
try {
|
|
121
|
+
const fromHost = createRequire(import.meta.url).resolve(ref);
|
|
122
|
+
host = true;
|
|
123
|
+
manifestPath ??= fromHost;
|
|
124
|
+
} catch {
|
|
125
|
+
// Not shipped by this host either.
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { host, project, ...(manifestPath === undefined ? {} : { manifestPath }) };
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
/** Build the extension registry for a project from its project.json `extensions` array. */
|
|
79
132
|
export async function buildProjectExtensionRegistry(
|
|
80
133
|
projectRoot: string,
|
|
@@ -56,8 +56,9 @@ const RESERVED_KEYS = new Set([
|
|
|
56
56
|
* to a .class.json path via doc.imports
|
|
57
57
|
*
|
|
58
58
|
* The resolver: - Reads the .class.json from disk - Follows $implementation to import the JS module
|
|
59
|
+
*
|
|
59
60
|
* - Instantiates the class with the config - Calls .resolve() and replaces the state entry with the
|
|
60
|
-
*
|
|
61
|
+
* result
|
|
61
62
|
*
|
|
62
63
|
* @param {JxMutableNode | JxDocument} doc - The page document (mutated in place)
|
|
63
64
|
* @param {{ sourcePath?: string; _pathParams?: Record<string, string> }} route - Route info
|
package/src/site/site-build.ts
CHANGED
|
@@ -68,6 +68,7 @@ import type { SiteConnectorSpec, SiteMountSpec } from "../targets/compile-server
|
|
|
68
68
|
import {
|
|
69
69
|
buildComponentCSS,
|
|
70
70
|
buildInitialScope,
|
|
71
|
+
buildInstanceScope,
|
|
71
72
|
collectServerEntries,
|
|
72
73
|
collectStyles,
|
|
73
74
|
colorSchemePrePaintScript,
|
|
@@ -76,12 +77,14 @@ import {
|
|
|
76
77
|
isComponentFullyStatic,
|
|
77
78
|
isSingleExpression,
|
|
78
79
|
isTemplateString,
|
|
80
|
+
liftPropsAttributes,
|
|
79
81
|
preRenderComponentHtml,
|
|
80
82
|
pureSchemeOf,
|
|
81
83
|
renderStaticNode,
|
|
84
|
+
resolveHostStyle,
|
|
82
85
|
resolveRefValue,
|
|
83
|
-
resolveStaticValue,
|
|
84
86
|
} from "../shared.ts";
|
|
87
|
+
import type { ComponentPrerenderContext } from "../shared.ts";
|
|
85
88
|
import { resolvePrototypes } from "./prototype-resolver.ts";
|
|
86
89
|
import { transformImageNodes } from "./image-transform.ts";
|
|
87
90
|
import { collectCspSources, emptyCspSources } from "./csp.ts";
|
|
@@ -453,11 +456,24 @@ export async function buildSite(
|
|
|
453
456
|
// The full drive path on Windows, so resolve() then wrote the sidecar back into the
|
|
454
457
|
// Source tree instead of dist.
|
|
455
458
|
const outName = f.tagName ? `${f.tagName}.js` : basename(f.path);
|
|
456
|
-
|
|
457
|
-
|
|
459
|
+
/*
|
|
460
|
+
* Written and recorded ONCE per tag, in first-seen order. `compileElement` returns a
|
|
461
|
+
* component's `$elements` dependencies as extra files, so a dependency two parents name
|
|
462
|
+
* arrives here once per parent and once more for its own compile — and
|
|
463
|
+
* `injectComponentScripts` inlines a tag's stylesheet and loads its module once per
|
|
464
|
+
* record: three copies of `lcb-icon { display: inline-block }` in one head on a
|
|
465
|
+
* card → button → icon page (#330). The FIRST position is the one kept, because
|
|
466
|
+
* equal-specificity component rules cascade by source order and a later duplicate must
|
|
467
|
+
* not move a sheet that already landed. The write is skipped with the record: every
|
|
468
|
+
* arrival is the same file compiled with the same options (a fresh `visited` set each
|
|
469
|
+
* call), so the bytes are identical and a second write only inflates `fileCount`, which
|
|
470
|
+
* reports files produced rather than writes attempted.
|
|
471
|
+
*/
|
|
472
|
+
if (!compiledComponentTags.includes(f.tagName)) {
|
|
473
|
+
writeFileSync(resolve(componentOutDir, outName), f.content, "utf8");
|
|
458
474
|
compiledComponentTags.push(f.tagName);
|
|
475
|
+
fileCount += 1;
|
|
459
476
|
}
|
|
460
|
-
fileCount += 1;
|
|
461
477
|
}
|
|
462
478
|
|
|
463
479
|
// Pre-render component HTML scaffold and CSS sidecar
|
|
@@ -2041,27 +2057,23 @@ function expandComponents(
|
|
|
2041
2057
|
|
|
2042
2058
|
const def = componentDefs.get(node.tagName as string);
|
|
2043
2059
|
if (def) {
|
|
2044
|
-
//
|
|
2045
|
-
//
|
|
2046
|
-
// Lift them into $props so the pre-render sees them, and strip them from attributes so they
|
|
2047
|
-
// Don't leak into the emitted HTML. Values stay raw strings — no coercion, matching the
|
|
2048
|
-
// Markdown path and the runtime's $props semantics. Explicit $props wins on key conflicts.
|
|
2060
|
+
// Literal `props.*` attribute keys become $props (`liftPropsAttributes` says why) and leave the
|
|
2061
|
+
// Attributes, so they do not reach the emitted HTML. Explicit $props wins on key conflicts.
|
|
2049
2062
|
if (node.attributes) {
|
|
2050
|
-
|
|
2051
|
-
for (const [key, value] of Object.entries(node.attributes)) {
|
|
2052
|
-
if (key.startsWith("props.") && key.length > "props.".length) {
|
|
2053
|
-
lifted ??= {};
|
|
2054
|
-
// JxAttributeValue is JSON-representable (primitives or a $ref object), so the
|
|
2055
|
-
// Narrowing to JsonValue is sound.
|
|
2056
|
-
lifted[key.slice("props.".length)] = value as JsonValue;
|
|
2057
|
-
delete node.attributes[key];
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2063
|
+
const { lifted, rest } = liftPropsAttributes(node.attributes);
|
|
2060
2064
|
if (lifted) {
|
|
2065
|
+
node.attributes = rest;
|
|
2061
2066
|
node.$props = { ...lifted, ...node.$props };
|
|
2062
2067
|
}
|
|
2063
2068
|
}
|
|
2064
2069
|
|
|
2070
|
+
/*
|
|
2071
|
+
* Slotted children are rendered with NO component registry: this walk is bottom-up, so any
|
|
2072
|
+
* instance among them was expanded a moment ago and now carries its markup as `innerHTML` and
|
|
2073
|
+
* no `$props`. Expanding it again would render the definition's defaults over the props it
|
|
2074
|
+
* was given. Instances written inside `def` itself are another matter — those are reached
|
|
2075
|
+
* below, through `preRenderComponentHtml`, with `node` as the first frame of the path.
|
|
2076
|
+
*/
|
|
2065
2077
|
const slotContent =
|
|
2066
2078
|
Array.isArray(node.children) && node.children.length > 0
|
|
2067
2079
|
? node.children
|
|
@@ -2081,7 +2093,15 @@ function expandComponents(
|
|
|
2081
2093
|
* render them twice.
|
|
2082
2094
|
*/
|
|
2083
2095
|
const shadow = resolveShadowMode(def, defaults);
|
|
2084
|
-
const
|
|
2096
|
+
const props = node.$props || null;
|
|
2097
|
+
// This instance is the first frame of the path, so a definition that names itself is reported
|
|
2098
|
+
// At the first nesting rather than after the stack overflows.
|
|
2099
|
+
const context: ComponentPrerenderContext = {
|
|
2100
|
+
componentDefs,
|
|
2101
|
+
defaults,
|
|
2102
|
+
path: [{ key: JSON.stringify(props ?? {}), tag: node.tagName as string }],
|
|
2103
|
+
};
|
|
2104
|
+
const innerHTML = preRenderComponentHtml(def, props, shadow ? null : slotContent, context);
|
|
2085
2105
|
const isStatic = isComponentFullyStatic(def);
|
|
2086
2106
|
|
|
2087
2107
|
/*
|
|
@@ -2097,26 +2117,20 @@ function expandComponents(
|
|
|
2097
2117
|
: innerHTML;
|
|
2098
2118
|
delete node.children;
|
|
2099
2119
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
|
-
}
|
|
2117
|
-
if (Object.keys(resolvedStyle).length > 0) {
|
|
2118
|
-
node.style = { ...node.style, ...resolvedStyle } as JxStyle;
|
|
2119
|
-
}
|
|
2120
|
+
/*
|
|
2121
|
+
* Resolve template-string host styles against the instance (per-instance values like
|
|
2122
|
+
* background-image). The page's style pass gives the result a class rule; a nested instance
|
|
2123
|
+
* gets the same values inline, from the same resolver (`renderComponentInstance` in shared.ts).
|
|
2124
|
+
*
|
|
2125
|
+
* Resolved whether or not the instance passes props: a template declaration is a reactive one
|
|
2126
|
+
* and `pushStyleRules` drops it from the component stylesheet, so the value resolved here is
|
|
2127
|
+
* the ONLY place it reaches a static page. This used to be guarded on `$props`, which left an
|
|
2128
|
+
* instance that took the definition's defaults with no host style at all — no size, no mask —
|
|
2129
|
+
* while the same instance one level down had them.
|
|
2130
|
+
*/
|
|
2131
|
+
const resolvedStyle = resolveHostStyle(def.style, buildInstanceScope(def, props));
|
|
2132
|
+
if (Object.keys(resolvedStyle).length > 0) {
|
|
2133
|
+
node.style = { ...node.style, ...resolvedStyle } as JxStyle;
|
|
2120
2134
|
}
|
|
2121
2135
|
|
|
2122
2136
|
/*
|
|
@@ -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
|
/**
|
|
@@ -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].
|