@jxsuite/compiler 3.1.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.
@@ -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 resolution
6
- * + spawn: the entry module is resolved from the PROJECT's node_modules and executed with Bun.
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
  */
@@ -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
- * result
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
@@ -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";
@@ -2041,27 +2044,23 @@ function expandComponents(
2041
2044
 
2042
2045
  const def = componentDefs.get(node.tagName as string);
2043
2046
  if (def) {
2044
- // JSON-authored instances pass props as literal `props.*` attribute keys (markdown directives
2045
- // Are normalized to $props by the parser's expandDotPaths, but JSON is parsed verbatim).
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.
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.
2049
2049
  if (node.attributes) {
2050
- let lifted: NonNullable<JxElement["$props"]> | null = null;
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
- }
2050
+ const { lifted, rest } = liftPropsAttributes(node.attributes);
2060
2051
  if (lifted) {
2052
+ node.attributes = rest;
2061
2053
  node.$props = { ...lifted, ...node.$props };
2062
2054
  }
2063
2055
  }
2064
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
+ */
2065
2064
  const slotContent =
2066
2065
  Array.isArray(node.children) && node.children.length > 0
2067
2066
  ? node.children
@@ -2081,7 +2080,15 @@ function expandComponents(
2081
2080
  * render them twice.
2082
2081
  */
2083
2082
  const shadow = resolveShadowMode(def, defaults);
2084
- 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);
2085
2092
  const isStatic = isComponentFullyStatic(def);
2086
2093
 
2087
2094
  /*
@@ -2097,26 +2104,20 @@ function expandComponents(
2097
2104
  : innerHTML;
2098
2105
  delete node.children;
2099
2106
 
2100
- // Resolve template-string host styles with props (per-instance values like background-image)
2101
- if (def.style && node.$props) {
2102
- const stateDefs: Record<string, JxStateDefinition> = { ...def.state };
2103
- for (const [key, value] of Object.entries(node.$props)) {
2104
- stateDefs[key] =
2105
- key in stateDefs ? (value as JxStateDefinition) : (value as JxStateDefinition);
2106
- }
2107
- const scope = buildInitialScope(stateDefs, null);
2108
- const resolvedStyle: Record<string, unknown> = {};
2109
- for (const [prop, value] of Object.entries(def.style)) {
2110
- if (typeof value === "string" && isTemplateString(value)) {
2111
- const resolved = resolveStaticValue(value, scope);
2112
- if (resolved != null) {
2113
- resolvedStyle[prop] = resolved;
2114
- }
2115
- }
2116
- }
2117
- if (Object.keys(resolvedStyle).length > 0) {
2118
- node.style = { ...node.style, ...resolvedStyle } as JxStyle;
2119
- }
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;
2120
2121
  }
2121
2122
 
2122
2123
  /*
@@ -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
  /**
@@ -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].