@flow-like/widget-bundler 0.1.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/src/html.ts ADDED
@@ -0,0 +1,60 @@
1
+ export function escapeHtmlAttr(value: string): string {
2
+ return value
3
+ .replace(/&/g, "&")
4
+ .replace(/"/g, """)
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;");
7
+ }
8
+
9
+ const WORD_CHAR = /\w/;
10
+
11
+ export interface TagSpan {
12
+ start: number;
13
+ /** Offset just past the tag's `>` */
14
+ end: number;
15
+ }
16
+
17
+ /**
18
+ * Linear-time equivalent of `/<name\b[^>]*>/i`: the next start tag for the
19
+ * (lowercase) `name` at or after `from`. The regex form is quadratic in the
20
+ * document length because every `<` is a candidate start for an `[^>]*` scan.
21
+ */
22
+ export function findTag(html: string, name: string, from = 0): TagSpan | null {
23
+ let cursor = from;
24
+ for (;;) {
25
+ const start = html.indexOf("<", cursor);
26
+ if (start === -1) return null;
27
+ const nameEnd = start + 1 + name.length;
28
+ if (
29
+ html.slice(start + 1, nameEnd).toLowerCase() !== name ||
30
+ WORD_CHAR.test(html[nameEnd] ?? "")
31
+ ) {
32
+ cursor = start + 1;
33
+ continue;
34
+ }
35
+ const close = html.indexOf(">", nameEnd);
36
+ return close === -1 ? null : { start, end: close + 1 };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Insert a fragment at the very start of `<head>`, creating the element when
42
+ * the document has none.
43
+ */
44
+ export function insertAtHeadStart(html: string, fragment: string): string {
45
+ const head = findTag(html, "head");
46
+ if (head) {
47
+ return `${html.slice(0, head.end)}${fragment}${html.slice(head.end)}`;
48
+ }
49
+ const htmlTag = findTag(html, "html");
50
+ if (htmlTag) {
51
+ const { end } = htmlTag;
52
+ return `${html.slice(0, end)}<head>${fragment}</head>${html.slice(end)}`;
53
+ }
54
+ const doctype = /^\s*<!doctype\b[^>]*>/i.exec(html);
55
+ if (doctype) {
56
+ const end = doctype.index + doctype[0].length;
57
+ return `${html.slice(0, end)}<head>${fragment}</head>${html.slice(end)}`;
58
+ }
59
+ return `<head>${fragment}</head>${html}`;
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from "./contract-types";
2
+ export * from "./bundle-format";
3
+ export * from "./extract";
4
+ export * from "./csp";
5
+ export * from "./csp-source";
6
+ export * from "./csp-reason";
7
+ export * from "./psl";
8
+ export * from "./html";
9
+ export * from "./inline";
10
+ export * from "./pack";
11
+ export * from "./validate-cmd";
12
+ export * from "./add";
13
+ export * from "./vite";
14
+ export * from "./dev/form-model";
15
+ export * from "./dev/contract-endpoint";
16
+ export * from "./dev/harness-html";
17
+ export * from "./dev/server";
package/src/inline.ts ADDED
@@ -0,0 +1,247 @@
1
+ import { type WidgetContract, canonicalizeContract } from "./contract-types";
2
+ import { type TagSpan, findTag, insertAtHeadStart } from "./html";
3
+
4
+ export type AssetResolver = (relPath: string) => Uint8Array | null;
5
+
6
+ export interface InlineResult {
7
+ html: string;
8
+ /** Local asset paths that were inlined into the document */
9
+ inlined: string[];
10
+ /** Bundle-relative shared chunk paths (`shared/...`) the document references */
11
+ external: string[];
12
+ }
13
+
14
+ type RefKind =
15
+ | { kind: "url" }
16
+ | { kind: "shared"; file: string }
17
+ | { kind: "local"; path: string };
18
+
19
+ const SHARED_REF = /^(?:\.\/|\/)?(?:\.\.\/)*shared\/(.+)$/;
20
+
21
+ function classifyRef(ref: string): RefKind {
22
+ if (/^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith("//")) {
23
+ return { kind: "url" };
24
+ }
25
+ const shared = SHARED_REF.exec(ref);
26
+ if (shared?.[1]) {
27
+ return { kind: "shared", file: shared[1] };
28
+ }
29
+ return { kind: "local", path: ref.replace(/^(?:\.\/|\/)+/, "") };
30
+ }
31
+
32
+ function attrValue(tag: string, name: string): string | null {
33
+ const match = new RegExp(
34
+ `\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
35
+ "i",
36
+ ).exec(tag);
37
+ if (!match) return null;
38
+ return match[1] ?? match[2] ?? match[3] ?? null;
39
+ }
40
+
41
+ function replaceAttrValue(tag: string, name: string, value: string): string {
42
+ return tag.replace(
43
+ new RegExp(`(\\b${name}\\s*=\\s*)(?:"[^"]*"|'[^']*'|[^\\s>]+)`, "i"),
44
+ `$1"${value}"`,
45
+ );
46
+ }
47
+
48
+ function escapeScriptContent(js: string): string {
49
+ return js.replace(/<\/script/gi, "<\\/script");
50
+ }
51
+
52
+ function escapeStyleContent(css: string): string {
53
+ return css.replace(/<\/style/gi, "<\\/style");
54
+ }
55
+
56
+ const DECODER = new TextDecoder();
57
+
58
+ const WHITESPACE = /\s/;
59
+
60
+ interface ScriptElement extends TagSpan {
61
+ attrs: string;
62
+ body: string;
63
+ }
64
+
65
+ /** `String.replace` over hand-scanned spans; replacements are never rescanned. */
66
+ function rewriteSpans<T extends TagSpan>(
67
+ html: string,
68
+ find: (from: number) => T | null,
69
+ replace: (span: T) => string,
70
+ ): string {
71
+ let out = "";
72
+ let cursor = 0;
73
+ for (;;) {
74
+ const span = find(cursor);
75
+ if (span === null) break;
76
+ out += html.slice(cursor, span.start) + replace(span);
77
+ cursor = span.end;
78
+ }
79
+ return out + html.slice(cursor);
80
+ }
81
+
82
+ function findScriptClose(
83
+ html: string,
84
+ from: number,
85
+ ): { start: number; end: number } | null {
86
+ let cursor = from;
87
+ for (;;) {
88
+ const start = html.indexOf("<", cursor);
89
+ if (start === -1) return null;
90
+ if (html.slice(start + 1, start + 8).toLowerCase() !== "/script") {
91
+ cursor = start + 1;
92
+ continue;
93
+ }
94
+ const tail = start + 8;
95
+ const next = html[tail];
96
+ if (next === ">") return { start, end: tail + 1 };
97
+ if (next !== undefined && WHITESPACE.test(next)) {
98
+ const gt = html.indexOf(">", tail + 1);
99
+ return gt === -1 ? null : { start, end: gt + 1 };
100
+ }
101
+ cursor = start + 1;
102
+ }
103
+ }
104
+
105
+ /** Linear-time scan for `<script …>…</script …>`; a regex here is quadratic. */
106
+ function findScriptElement(html: string, from: number): ScriptElement | null {
107
+ const open = findTag(html, "script", from);
108
+ if (open === null) return null;
109
+ const close = findScriptClose(html, open.end);
110
+ if (close === null) return null;
111
+ return {
112
+ start: open.start,
113
+ end: close.end,
114
+ attrs: html.slice(open.start + 7, open.end - 1),
115
+ body: html.slice(open.end, close.start),
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Inline local `<script src>` and stylesheet `<link>` references into the
121
+ * document; rewrite references into `shared/` to bundle-relative
122
+ * `../../shared/<file>` (the widget document lives at
123
+ * `widgets/{id}/index.html`).
124
+ */
125
+ export function inlineHtml(
126
+ html: string,
127
+ resolveAsset: AssetResolver,
128
+ ): InlineResult {
129
+ const inlined: string[] = [];
130
+ const external = new Set<string>();
131
+
132
+ const replaceScript = (element: ScriptElement): string => {
133
+ const { attrs, body } = element;
134
+ const full = html.slice(element.start, element.end);
135
+ const src = attrValue(`<script${attrs}>`, "src");
136
+ if (src === null) return full;
137
+ const ref = classifyRef(src);
138
+ if (ref.kind === "url") return full;
139
+ if (ref.kind === "shared") {
140
+ external.add(`shared/${ref.file}`);
141
+ const openTag = replaceAttrValue(
142
+ `<script${attrs}>`,
143
+ "src",
144
+ `../../shared/${ref.file}`,
145
+ );
146
+ return `${openTag}${body}</script>`;
147
+ }
148
+ const asset = resolveAsset(ref.path);
149
+ if (asset === null) {
150
+ throw new Error(
151
+ `Cannot inline script '${src}': file not found next to the built document`,
152
+ );
153
+ }
154
+ inlined.push(ref.path);
155
+ const type = attrValue(`<script${attrs}>`, "type");
156
+ const typeAttr = type ? ` type="${type}"` : "";
157
+ return `<script${typeAttr}>${escapeScriptContent(DECODER.decode(asset))}</script>`;
158
+ };
159
+
160
+ const replaceLink = (full: string): string => {
161
+ const rel = attrValue(full, "rel")?.toLowerCase();
162
+ const href = attrValue(full, "href");
163
+ if (!href || (rel !== "stylesheet" && rel !== "modulepreload")) {
164
+ return full;
165
+ }
166
+ const ref = classifyRef(href);
167
+ if (ref.kind === "url") return full;
168
+ if (ref.kind === "shared") {
169
+ external.add(`shared/${ref.file}`);
170
+ return replaceAttrValue(full, "href", `../../shared/${ref.file}`);
171
+ }
172
+ if (rel === "modulepreload") {
173
+ return "";
174
+ }
175
+ const asset = resolveAsset(ref.path);
176
+ if (asset === null) {
177
+ throw new Error(
178
+ `Cannot inline stylesheet '${href}': file not found next to the built document`,
179
+ );
180
+ }
181
+ inlined.push(ref.path);
182
+ return `<style>${escapeStyleContent(DECODER.decode(asset))}</style>`;
183
+ };
184
+
185
+ const withScripts = rewriteSpans(
186
+ html,
187
+ (from) => findScriptElement(html, from),
188
+ replaceScript,
189
+ );
190
+ const out = rewriteSpans(
191
+ withScripts,
192
+ (from) => findTag(withScripts, "link", from),
193
+ (tag) => replaceLink(withScripts.slice(tag.start, tag.end)),
194
+ );
195
+
196
+ return { html: out, inlined, external: [...external].sort() };
197
+ }
198
+
199
+ export function contractScriptContent(contract: WidgetContract): string {
200
+ const json = JSON.stringify(canonicalizeContract(contract)).replace(
201
+ /</g,
202
+ "\\u003c",
203
+ );
204
+ return `globalThis.__FLW_CONTRACT__ = ${json};`;
205
+ }
206
+
207
+ const CONTRACT_OPEN = "<script>";
208
+ const CONTRACT_CLOSE = "</script>";
209
+ const CONTRACT_MARKER = "globalThis.__FLW_CONTRACT__";
210
+
211
+ /** Linear-time removal of a previously injected contract script. */
212
+ function stripContractScript(html: string): string {
213
+ const leadingWhitespace = /\s*/y;
214
+ let from = 0;
215
+ for (;;) {
216
+ const open = html.indexOf(CONTRACT_OPEN, from);
217
+ if (open === -1) return html;
218
+ leadingWhitespace.lastIndex = open + CONTRACT_OPEN.length;
219
+ leadingWhitespace.exec(html);
220
+ const marker = leadingWhitespace.lastIndex;
221
+ if (html.startsWith(CONTRACT_MARKER, marker)) {
222
+ const close = html.indexOf(
223
+ CONTRACT_CLOSE,
224
+ marker + CONTRACT_MARKER.length,
225
+ );
226
+ if (close === -1) return html;
227
+ return html.slice(0, open) + html.slice(close + CONTRACT_CLOSE.length);
228
+ }
229
+ from = open + 1;
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Add `globalThis.__FLW_CONTRACT__` as the first script in `<head>`,
235
+ * replacing a previously injected one (dev builds inject it via the Vite
236
+ * plugin; pack is authoritative).
237
+ */
238
+ export function injectContractScript(
239
+ html: string,
240
+ contract: WidgetContract,
241
+ ): string {
242
+ const stripped = stripContractScript(html);
243
+ return insertAtHeadStart(
244
+ stripped,
245
+ `<script>${contractScriptContent(contract)}</script>`,
246
+ );
247
+ }