@jxsuite/compiler 1.5.0 → 2.0.2
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 +567 -232
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
package/src/site/site-loader.ts
CHANGED
|
@@ -133,7 +133,7 @@ export async function validateProjectTree(
|
|
|
133
133
|
const { addFormats, Ajv } = await loadAjv();
|
|
134
134
|
|
|
135
135
|
// 3. Documents against the bundled document schema (the same schema the studio consumes).
|
|
136
|
-
const { document: documentSchema } = await readBundledProjectSchemas(root);
|
|
136
|
+
const { document: documentSchema } = await readBundledProjectSchemas(root, { write: false });
|
|
137
137
|
const docAjv = new Ajv({ allErrors: true, ownProperties: true, strict: false });
|
|
138
138
|
addFormats(docAjv);
|
|
139
139
|
const validateDoc = docAjv.compile(documentSchema);
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two files a site is expected to publish and nothing generates: `manifest.webmanifest` and
|
|
3
|
+
* `.well-known/security.txt`.
|
|
4
|
+
*
|
|
5
|
+
* Both are pure functions of `project.json` plus what the build already knows about the site, which
|
|
6
|
+
* is the reason they belong here rather than in `public/` — an author copying either between
|
|
7
|
+
* projects also copies the values that were right for the other one.
|
|
8
|
+
*
|
|
9
|
+
* @docs framework/site/deployment
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { canonicalizeLocale } from "@jxsuite/schema/locale";
|
|
15
|
+
import type { ManifestConfig, ProjectConfig, SecurityTxtConfig } from "@jxsuite/schema/types";
|
|
16
|
+
|
|
17
|
+
/** The two sizes a browser wants before it will offer to install a site. */
|
|
18
|
+
const INSTALL_ICON_SIZES = [192, 512];
|
|
19
|
+
|
|
20
|
+
export interface GeneratedFile {
|
|
21
|
+
/** Path relative to `outDir`. */
|
|
22
|
+
path: string;
|
|
23
|
+
content: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface WellKnownOutput {
|
|
27
|
+
files: GeneratedFile[];
|
|
28
|
+
errors: string[];
|
|
29
|
+
warnings: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build `manifest.webmanifest` from the `manifest` section, falling back to what the project
|
|
34
|
+
* already says about itself.
|
|
35
|
+
*
|
|
36
|
+
* `name` and `start_url` are not asked for twice: a project has a `name`, and `start_url` is the
|
|
37
|
+
* site root unless the author says otherwise. Everything else is theirs.
|
|
38
|
+
*
|
|
39
|
+
* @param {ProjectConfig} projectConfig
|
|
40
|
+
* @returns {WellKnownOutput}
|
|
41
|
+
*/
|
|
42
|
+
export function buildManifest(projectConfig: ProjectConfig): WellKnownOutput {
|
|
43
|
+
const config: ManifestConfig | undefined = projectConfig.manifest;
|
|
44
|
+
const errors: string[] = [];
|
|
45
|
+
const warnings: string[] = [];
|
|
46
|
+
if (config === undefined || config.enabled === false) {
|
|
47
|
+
return { errors, files: [], warnings };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const name = config.name ?? projectConfig.name ?? "Jx Site";
|
|
51
|
+
const icons = config.icons ?? [];
|
|
52
|
+
const manifest: Record<string, unknown> = {
|
|
53
|
+
display: config.display ?? "standalone",
|
|
54
|
+
icons,
|
|
55
|
+
name,
|
|
56
|
+
short_name: config.shortName ?? name,
|
|
57
|
+
start_url: config.startUrl ?? "/",
|
|
58
|
+
...(config.description === undefined ? {} : { description: config.description }),
|
|
59
|
+
...(config.themeColor === undefined ? {} : { theme_color: config.themeColor }),
|
|
60
|
+
...(config.backgroundColor === undefined ? {} : { background_color: config.backgroundColor }),
|
|
61
|
+
...(config.scope === undefined ? {} : { scope: config.scope }),
|
|
62
|
+
...(config.orientation === undefined ? {} : { orientation: config.orientation }),
|
|
63
|
+
...(config.lang === undefined ? {} : { lang: config.lang }),
|
|
64
|
+
...(config.dir === undefined ? {} : { dir: config.dir }),
|
|
65
|
+
...(config.categories === undefined ? {} : { categories: config.categories }),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* A warning, not an error. The manifest is valid and useful without these — it still supplies
|
|
70
|
+
* the name and theme colour a browser shows — but a site cannot be *installed* without an icon
|
|
71
|
+
* at each size, and that is the whole reason most people add one.
|
|
72
|
+
*/
|
|
73
|
+
const declared = new Set(
|
|
74
|
+
icons.flatMap((icon) =>
|
|
75
|
+
(icon.sizes ?? "").split(/\s+/).map((size) => Math.trunc(Number(size.split("x")[0]))),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
const missing = INSTALL_ICON_SIZES.filter((size) => !declared.has(size));
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
warnings.push(
|
|
81
|
+
`manifest: no ${missing.join("px and no ")}px icon declared — browsers will not offer to ` +
|
|
82
|
+
"install the site without one at each size.",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
errors,
|
|
88
|
+
files: [{ content: `${JSON.stringify(manifest, null, 2)}\n`, path: "manifest.webmanifest" }],
|
|
89
|
+
warnings,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The `<head>` entries a generated manifest needs to be found and to look right when installed. */
|
|
94
|
+
export function manifestHeadEntries(
|
|
95
|
+
projectConfig: ProjectConfig,
|
|
96
|
+
): { tagName: string; attributes: Record<string, string> }[] {
|
|
97
|
+
const config = projectConfig.manifest;
|
|
98
|
+
if (config === undefined || config.enabled === false) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const entries: { tagName: string; attributes: Record<string, string> }[] = [
|
|
102
|
+
{ attributes: { href: "/manifest.webmanifest", rel: "manifest" }, tagName: "link" },
|
|
103
|
+
];
|
|
104
|
+
if (config.themeColor !== undefined) {
|
|
105
|
+
entries.push({
|
|
106
|
+
attributes: { content: config.themeColor, name: "theme-color" },
|
|
107
|
+
tagName: "meta",
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build `.well-known/security.txt` (RFC 9116).
|
|
115
|
+
*
|
|
116
|
+
* `.well-known` only. §3 makes that the canonical location, and a second copy at the root is a
|
|
117
|
+
* second thing to forget to update.
|
|
118
|
+
*
|
|
119
|
+
* @param {ProjectConfig} projectConfig
|
|
120
|
+
* @param {Date} now - Evaluated against `Expires`; injected so the check is testable
|
|
121
|
+
* @returns {WellKnownOutput}
|
|
122
|
+
*/
|
|
123
|
+
export function buildSecurityTxt(projectConfig: ProjectConfig, now: Date): WellKnownOutput {
|
|
124
|
+
const config: SecurityTxtConfig | undefined = projectConfig.securityTxt;
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
const warnings: string[] = [];
|
|
127
|
+
if (config === undefined || config.enabled === false) {
|
|
128
|
+
return { errors, files: [], warnings };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const contact = config.contact ?? [];
|
|
132
|
+
if (contact.length === 0) {
|
|
133
|
+
errors.push("securityTxt: at least one `contact` is required (RFC 9116 §2.5.3).");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* `Expires` is required by §2.5.5, and it is the field everyone forgets. A past date is worse
|
|
138
|
+
* than a missing file: it advertises a reporting channel while telling the reporter the
|
|
139
|
+
* information is stale, so both cases fail the build rather than emitting something misleading.
|
|
140
|
+
*/
|
|
141
|
+
if (config.expires === undefined) {
|
|
142
|
+
errors.push(
|
|
143
|
+
"securityTxt: `expires` is required (RFC 9116 §2.5.5) — a security.txt with no expiry " +
|
|
144
|
+
"never goes stale visibly, which is how they end up pointing at people who left.",
|
|
145
|
+
);
|
|
146
|
+
} else {
|
|
147
|
+
const expires = new Date(config.expires);
|
|
148
|
+
if (Number.isNaN(expires.getTime())) {
|
|
149
|
+
errors.push(`securityTxt: \`expires\` is not a valid date: "${config.expires}".`);
|
|
150
|
+
} else if (expires.getTime() <= now.getTime()) {
|
|
151
|
+
errors.push(
|
|
152
|
+
`securityTxt: \`expires\` is in the past (${expires.toISOString()}) — an expired ` +
|
|
153
|
+
"security.txt tells a reporter not to trust what it says.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const languages: string[] = [];
|
|
159
|
+
for (const tag of config.preferredLanguages ?? []) {
|
|
160
|
+
const canonical = canonicalizeLocale(tag);
|
|
161
|
+
if (canonical === null) {
|
|
162
|
+
errors.push(`securityTxt.preferredLanguages: "${tag}" is not a well-formed language tag.`);
|
|
163
|
+
} else {
|
|
164
|
+
languages.push(canonical);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (errors.length > 0) {
|
|
169
|
+
return { errors, files: [], warnings };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lines: string[] = [
|
|
173
|
+
"# Generated by @jxsuite/compiler — configure via securityTxt in project.json.",
|
|
174
|
+
...contact.map((value) => `Contact: ${value}`),
|
|
175
|
+
`Expires: ${new Date(config.expires as string).toISOString().replace(/\.\d{3}Z$/, "Z")}`,
|
|
176
|
+
...(config.encryption ?? []).map((value) => `Encryption: ${value}`),
|
|
177
|
+
...(config.acknowledgments ?? []).map((value) => `Acknowledgments: ${value}`),
|
|
178
|
+
...(languages.length > 0 ? [`Preferred-Languages: ${languages.join(", ")}`] : []),
|
|
179
|
+
...(config.canonical === undefined ? [] : [`Canonical: ${config.canonical}`]),
|
|
180
|
+
...(config.policy ?? []).map((value) => `Policy: ${value}`),
|
|
181
|
+
...(config.hiring ?? []).map((value) => `Hiring: ${value}`),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
errors,
|
|
186
|
+
files: [{ content: `${lines.join("\n")}\n`, path: ".well-known/security.txt" }],
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Write generated files under `outDir`, skipping any the author already supplied.
|
|
193
|
+
*
|
|
194
|
+
* The skip is the point: `public/.well-known/security.txt` is copied before this runs, and a
|
|
195
|
+
* hand-placed file there is how an author ships a **clearsigned** one — which needs a private key
|
|
196
|
+
* at build time and is therefore not something the build can do. Deferring costs zero code.
|
|
197
|
+
*
|
|
198
|
+
* @param {readonly GeneratedFile[]} files
|
|
199
|
+
* @param {string} outDir
|
|
200
|
+
* @param {(path: string) => boolean} exists
|
|
201
|
+
* @returns {{ written: number; skipped: string[] }}
|
|
202
|
+
*/
|
|
203
|
+
export function writeWellKnown(
|
|
204
|
+
files: readonly GeneratedFile[],
|
|
205
|
+
outDir: string,
|
|
206
|
+
exists: (path: string) => boolean,
|
|
207
|
+
): { written: number; skipped: string[] } {
|
|
208
|
+
let written = 0;
|
|
209
|
+
const skipped: string[] = [];
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
const target = join(outDir, file.path);
|
|
212
|
+
if (exists(target)) {
|
|
213
|
+
skipped.push(file.path);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
217
|
+
writeFileSync(target, file.content, "utf8");
|
|
218
|
+
written += 1;
|
|
219
|
+
}
|
|
220
|
+
return { skipped, written };
|
|
221
|
+
}
|
|
@@ -43,9 +43,18 @@ import {
|
|
|
43
43
|
isMappedArray,
|
|
44
44
|
isNamedFormulaDef,
|
|
45
45
|
isPrototypeDef,
|
|
46
|
+
isTagExpression,
|
|
46
47
|
paramNames,
|
|
48
|
+
tagNameCandidates,
|
|
47
49
|
} from "@jxsuite/schema/guards";
|
|
48
50
|
import type { CompileContext, ExpressionNode } from "../shared.ts";
|
|
51
|
+
import {
|
|
52
|
+
escapeToken,
|
|
53
|
+
objectKey,
|
|
54
|
+
refAccessor,
|
|
55
|
+
refBindingKey,
|
|
56
|
+
refSegments,
|
|
57
|
+
} from "@jxsuite/runtime/pointer";
|
|
49
58
|
import type {
|
|
50
59
|
JsonObject,
|
|
51
60
|
JsonValue,
|
|
@@ -338,6 +347,19 @@ function buildClientNode(
|
|
|
338
347
|
raw?.$media ?? context.media,
|
|
339
348
|
);
|
|
340
349
|
|
|
350
|
+
/* REFUSED, LOUDLY. A chosen tag has no implementation in this target: the element's identity is
|
|
351
|
+
fixed in the prerendered markup, so there is nothing here to re-choose at hydration the way the
|
|
352
|
+
`$switch` binding below re-chooses a subtree. Emitting a silently wrong element would be an
|
|
353
|
+
instance of the class the tagName pattern was added to end, so a dynamic page that needs a
|
|
354
|
+
chosen tag is told to put the element in a component (which compiles through `compile-element`,
|
|
355
|
+
where the construct is implemented) rather than shipping markup with the wrong tag. */
|
|
356
|
+
if (isTagExpression(def.tagName)) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
"A tag chosen at creation is not supported on a dynamic page yet (candidates: " +
|
|
359
|
+
`${tagNameCandidates(def.tagName).join(", ")}). Move the element into a component, or ` +
|
|
360
|
+
"give it a literal tagName.",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
341
363
|
const tag = def.tagName ?? "div";
|
|
342
364
|
// `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
|
|
343
365
|
nextContext.preformatted = context.preformatted === true || PREFORMATTED_TAGS.has(tag);
|
|
@@ -484,6 +506,33 @@ function buildClientNode(
|
|
|
484
506
|
// Inner content
|
|
485
507
|
let inner = "";
|
|
486
508
|
const source = raw ?? def;
|
|
509
|
+
if (source.$switch) {
|
|
510
|
+
// ─── $switch → lit-html render binding ───
|
|
511
|
+
// Without this branch the node fell through to the generic element path, which emitted a
|
|
512
|
+
// Container and then looked for `children` to recurse into — `cases` is not `children`, so the
|
|
513
|
+
// Subtree was never visited and the page shipped with the content missing, no error (#127).
|
|
514
|
+
// The container is emitted empty for the same reason the mapped-array branch below is: lit's
|
|
515
|
+
// `render` appends into the container rather than replacing its existing children, so any
|
|
516
|
+
// Prerendered branch would survive alongside the rendered one.
|
|
517
|
+
counter.needsLit = true;
|
|
518
|
+
const swKey = `_sw${counter.sw}`;
|
|
519
|
+
counter.sw += 1;
|
|
520
|
+
const discriminant = isRefObject(source.$switch)
|
|
521
|
+
? `state.${refToBindingKey((source.$switch as { $ref: string }).$ref)}`
|
|
522
|
+
: `\`${String(source.$switch)}\``;
|
|
523
|
+
const cases = (source.cases ?? {}) as Record<string, JxMutableNode>;
|
|
524
|
+
const caseEntries = Object.entries(cases)
|
|
525
|
+
// An external `$ref` case cannot be fetched at compile time, exactly as in the static target.
|
|
526
|
+
.filter(([, caseDef]) => caseDef && !isRefObject(caseDef))
|
|
527
|
+
.map(
|
|
528
|
+
([key, caseDef]) =>
|
|
529
|
+
`${JSON.stringify(key)}: html\`${emitChildLit(caseDef, nextContext.preformatted)}\``,
|
|
530
|
+
);
|
|
531
|
+
bindings.set(swKey, `() => ({${caseEntries.join(", ")}})[String(${discriminant})] ?? html\`\``);
|
|
532
|
+
bindAttrs.push(`:render="${swKey}"`);
|
|
533
|
+
const bindAttrStrSwitch = ` ${bindAttrs.join(" ")}`;
|
|
534
|
+
return `<${tag}${staticAttrs} data-bind${bindAttrStrSwitch}></${tag}>`;
|
|
535
|
+
}
|
|
487
536
|
if (source.textContent !== undefined && !needsBind) {
|
|
488
537
|
const value = resolveStaticValue(source.textContent, nextContext.scope);
|
|
489
538
|
inner = value == null ? "" : escapeHtml(String(value));
|
|
@@ -588,6 +637,15 @@ function emitLitMapTemplate(def: JxMutableNode | undefined, preformatted = false
|
|
|
588
637
|
if (!def) {
|
|
589
638
|
return "";
|
|
590
639
|
}
|
|
640
|
+
// Same refusal as `buildClientNode` above, for the repeater's item template — this target has no
|
|
641
|
+
// Branch construct, and a silently wrong element is what the tagName pattern exists to prevent.
|
|
642
|
+
if (isTagExpression(def.tagName)) {
|
|
643
|
+
throw new Error(
|
|
644
|
+
"A tag chosen at creation is not supported inside a dynamic page's repeater yet " +
|
|
645
|
+
`(candidates: ${tagNameCandidates(def.tagName).join(", ")}). Move the item into a ` +
|
|
646
|
+
"component, or give it a literal tagName.",
|
|
647
|
+
);
|
|
648
|
+
}
|
|
591
649
|
const tag = def.tagName ?? "div";
|
|
592
650
|
// `white-space` inherits, so once inside a <pre> the whole subtree stays whitespace-significant.
|
|
593
651
|
const inPre = preformatted || PREFORMATTED_TAGS.has(tag);
|
|
@@ -828,7 +886,11 @@ function emitClientModule(
|
|
|
828
886
|
// State — reactive state
|
|
829
887
|
lines.push("", "const state = reactive({");
|
|
830
888
|
for (const [key, val] of stateEntries) {
|
|
831
|
-
|
|
889
|
+
/*
|
|
890
|
+
* State keys come from the document, so `{"user.name": 1}` is legal input. Pasting one raw as
|
|
891
|
+
* an identifier emitted `user.name: 1,` — a SyntaxError the build reported as a success.
|
|
892
|
+
*/
|
|
893
|
+
lines.push(` ${objectKey(key)}: ${JSON.stringify(val)},`);
|
|
832
894
|
}
|
|
833
895
|
lines.push("});", "");
|
|
834
896
|
|
|
@@ -843,7 +905,7 @@ function emitClientModule(
|
|
|
843
905
|
// Computed signals on state
|
|
844
906
|
if (computedEntries.length > 0) {
|
|
845
907
|
for (const [key, expr] of computedEntries) {
|
|
846
|
-
lines.push(
|
|
908
|
+
lines.push(`${refAccessor("state", escapeToken(key))} = computed(${expr});`);
|
|
847
909
|
}
|
|
848
910
|
lines.push("");
|
|
849
911
|
}
|
|
@@ -852,7 +914,7 @@ function emitClientModule(
|
|
|
852
914
|
if (bindEntries.length > 0) {
|
|
853
915
|
lines.push("const bind = {");
|
|
854
916
|
for (const [key, expr] of bindEntries) {
|
|
855
|
-
lines.push(` ${key}: ${expr},`);
|
|
917
|
+
lines.push(` ${objectKey(key)}: ${expr},`);
|
|
856
918
|
}
|
|
857
919
|
lines.push("};");
|
|
858
920
|
} else {
|
|
@@ -867,7 +929,7 @@ function emitClientModule(
|
|
|
867
929
|
const argNames = def.args ?? ["state"];
|
|
868
930
|
const callArgs = argNames.map((a: string) => (a === "state" ? "state" : "e")).join(", ");
|
|
869
931
|
lines.push(
|
|
870
|
-
` ${key}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
|
|
932
|
+
` ${objectKey(key)}: (e) => { const fn = (${argNames.join(", ")}) => { ${def.body} }; fn(${
|
|
871
933
|
callArgs
|
|
872
934
|
}); },`,
|
|
873
935
|
);
|
|
@@ -983,19 +1045,27 @@ function emitCookieInit(key: string, cookieName: string, defaultVal: unknown) {
|
|
|
983
1045
|
*/
|
|
984
1046
|
function mapRefToClientExpr(ref: string) {
|
|
985
1047
|
if (ref.startsWith("$map/")) {
|
|
986
|
-
|
|
1048
|
+
// The loop variable is an emitted identifier, so its first segment is the base, not a member.
|
|
1049
|
+
const [head, ...rest] = refSegments(ref.slice("$map/".length));
|
|
1050
|
+
return refAccessor(head ?? "", rest.map((seg) => escapeToken(seg)).join("/"));
|
|
987
1051
|
}
|
|
988
1052
|
if (ref.startsWith("#/state/")) {
|
|
989
|
-
return
|
|
1053
|
+
return refAccessor("state", ref.slice("#/state/".length));
|
|
990
1054
|
}
|
|
991
|
-
return
|
|
1055
|
+
return refAccessor("state", ref);
|
|
992
1056
|
}
|
|
993
1057
|
|
|
1058
|
+
/**
|
|
1059
|
+
* The `bind`/`on` map key for a ref. It is emitted both as an object-literal key and as a
|
|
1060
|
+
* `data-bind` attribute value, so it has to survive being a JavaScript identifier and an HTML
|
|
1061
|
+
* attribute at once. Stripping the `#/state/` prefix first keeps every pure-slash ref on the key it
|
|
1062
|
+
* already had — `#/state/user/name` is still `user_name` — so this fix changes no shipped output.
|
|
1063
|
+
*
|
|
1064
|
+
* @param {string} ref
|
|
1065
|
+
* @returns {string}
|
|
1066
|
+
*/
|
|
994
1067
|
function refToBindingKey(ref: string) {
|
|
995
|
-
|
|
996
|
-
return ref.slice("#/state/".length).replaceAll("/", "_");
|
|
997
|
-
}
|
|
998
|
-
return ref.replaceAll("/", "_");
|
|
1068
|
+
return refBindingKey(ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref);
|
|
999
1069
|
}
|
|
1000
1070
|
|
|
1001
1071
|
/**
|
|
@@ -1007,11 +1077,8 @@ function addRefBinding(bindings: Map<string, string>, key: string, ref: string)
|
|
|
1007
1077
|
if (bindings.has(key)) {
|
|
1008
1078
|
return;
|
|
1009
1079
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
} else {
|
|
1015
|
-
bindings.set(key, `() => state.${ref}`);
|
|
1016
|
-
}
|
|
1080
|
+
bindings.set(
|
|
1081
|
+
key,
|
|
1082
|
+
`() => ${refAccessor("state", ref.startsWith("#/state/") ? ref.slice("#/state/".length) : ref)}`,
|
|
1083
|
+
);
|
|
1017
1084
|
}
|