@loadbare/app 0.4.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.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +95 -0
  3. package/dist/build/assemble.d.ts +31 -0
  4. package/dist/build/assemble.d.ts.map +1 -0
  5. package/dist/build/assemble.js +53 -0
  6. package/dist/build/cli.d.ts +30 -0
  7. package/dist/build/cli.d.ts.map +1 -0
  8. package/dist/build/cli.js +107 -0
  9. package/dist/build/elements.d.ts +61 -0
  10. package/dist/build/elements.d.ts.map +1 -0
  11. package/dist/build/elements.js +158 -0
  12. package/dist/build/expand.d.ts +45 -0
  13. package/dist/build/expand.d.ts.map +1 -0
  14. package/dist/build/expand.js +386 -0
  15. package/dist/build/format.d.ts +28 -0
  16. package/dist/build/format.d.ts.map +1 -0
  17. package/dist/build/format.js +42 -0
  18. package/dist/build/locations.d.ts +87 -0
  19. package/dist/build/locations.d.ts.map +1 -0
  20. package/dist/build/locations.js +173 -0
  21. package/dist/build/package-root.d.ts +9 -0
  22. package/dist/build/package-root.d.ts.map +1 -0
  23. package/dist/build/package-root.js +24 -0
  24. package/dist/build/pages.d.ts +25 -0
  25. package/dist/build/pages.d.ts.map +1 -0
  26. package/dist/build/pages.js +54 -0
  27. package/dist/build/styles.d.ts +13 -0
  28. package/dist/build/styles.d.ts.map +1 -0
  29. package/dist/build/styles.js +18 -0
  30. package/dist/client.js +522 -0
  31. package/dist/core/lb-constants.d.ts +23 -0
  32. package/dist/core/lb-constants.d.ts.map +1 -0
  33. package/dist/core/lb-constants.js +95 -0
  34. package/dist/core/lb-types.d.ts +88 -0
  35. package/dist/core/lb-types.d.ts.map +1 -0
  36. package/dist/core/lb-types.js +5 -0
  37. package/dist/demo-static/src/widgets/app-box.d.ts +15 -0
  38. package/dist/demo-static/src/widgets/app-box.d.ts.map +1 -0
  39. package/dist/demo-static/src/widgets/app-box.js +19 -0
  40. package/dist/hub/lb-apply.d.ts +13 -0
  41. package/dist/hub/lb-apply.d.ts.map +1 -0
  42. package/dist/hub/lb-apply.js +77 -0
  43. package/dist/hub/lb-hub.d.ts +2 -0
  44. package/dist/hub/lb-hub.d.ts.map +1 -0
  45. package/dist/hub/lb-hub.js +242 -0
  46. package/dist/hub/lb-rows.d.ts +18 -0
  47. package/dist/hub/lb-rows.d.ts.map +1 -0
  48. package/dist/hub/lb-rows.js +106 -0
  49. package/dist/server/lb-express.d.ts +28 -0
  50. package/dist/server/lb-express.d.ts.map +1 -0
  51. package/dist/server/lb-express.js +77 -0
  52. package/dist/server/lb-server.d.ts +174 -0
  53. package/dist/server/lb-server.d.ts.map +1 -0
  54. package/dist/server/lb-server.js +79 -0
  55. package/dist/tests/assemble.test.d.ts +8 -0
  56. package/dist/tests/assemble.test.d.ts.map +1 -0
  57. package/dist/tests/assemble.test.js +51 -0
  58. package/dist/tests/elements.test.d.ts +8 -0
  59. package/dist/tests/elements.test.d.ts.map +1 -0
  60. package/dist/tests/elements.test.js +111 -0
  61. package/dist/tests/expand.test.d.ts +10 -0
  62. package/dist/tests/expand.test.d.ts.map +1 -0
  63. package/dist/tests/expand.test.js +226 -0
  64. package/dist/tests/fixtures/elements/collision/elements.d.ts +5 -0
  65. package/dist/tests/fixtures/elements/collision/elements.d.ts.map +1 -0
  66. package/dist/tests/fixtures/elements/collision/elements.js +3 -0
  67. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts +2 -0
  68. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.d.ts.map +1 -0
  69. package/dist/tests/fixtures/elements/collision/widgets/acme-widget.js +1 -0
  70. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts +2 -0
  71. package/dist/tests/fixtures/elements/local/widgets/app-box.d.ts.map +1 -0
  72. package/dist/tests/fixtures/elements/local/widgets/app-box.js +1 -0
  73. package/dist/tests/fixtures/elements/manifest/elements.d.ts +5 -0
  74. package/dist/tests/fixtures/elements/manifest/elements.d.ts.map +1 -0
  75. package/dist/tests/fixtures/elements/manifest/elements.js +3 -0
  76. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts +5 -0
  77. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.d.ts.map +1 -0
  78. package/dist/tests/fixtures/elements/manifest-bad-tag/elements.js +3 -0
  79. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts +5 -0
  80. package/dist/tests/fixtures/elements/manifest-bad-value/elements.d.ts.map +1 -0
  81. package/dist/tests/fixtures/elements/manifest-bad-value/elements.js +3 -0
  82. package/dist/tests/golden.test.d.ts +19 -0
  83. package/dist/tests/golden.test.d.ts.map +1 -0
  84. package/dist/tests/golden.test.js +60 -0
  85. package/dist/tests/helpers/console.d.ts +20 -0
  86. package/dist/tests/helpers/console.d.ts.map +1 -0
  87. package/dist/tests/helpers/console.js +28 -0
  88. package/dist/tests/helpers/dom.d.ts +18 -0
  89. package/dist/tests/helpers/dom.d.ts.map +1 -0
  90. package/dist/tests/helpers/dom.js +22 -0
  91. package/dist/tests/helpers/window.d.ts +43 -0
  92. package/dist/tests/helpers/window.d.ts.map +1 -0
  93. package/dist/tests/helpers/window.js +78 -0
  94. package/dist/tests/lb-apply.test.d.ts +8 -0
  95. package/dist/tests/lb-apply.test.d.ts.map +1 -0
  96. package/dist/tests/lb-apply.test.js +153 -0
  97. package/dist/tests/lb-express.test.d.ts +14 -0
  98. package/dist/tests/lb-express.test.d.ts.map +1 -0
  99. package/dist/tests/lb-express.test.js +238 -0
  100. package/dist/tests/lb-input.test.d.ts +9 -0
  101. package/dist/tests/lb-input.test.d.ts.map +1 -0
  102. package/dist/tests/lb-input.test.js +78 -0
  103. package/dist/tests/lb-list.test.d.ts +12 -0
  104. package/dist/tests/lb-list.test.d.ts.map +1 -0
  105. package/dist/tests/lb-list.test.js +44 -0
  106. package/dist/tests/lb-options.test.d.ts +10 -0
  107. package/dist/tests/lb-options.test.d.ts.map +1 -0
  108. package/dist/tests/lb-options.test.js +121 -0
  109. package/dist/tests/lb-picker.test.d.ts +14 -0
  110. package/dist/tests/lb-picker.test.d.ts.map +1 -0
  111. package/dist/tests/lb-picker.test.js +59 -0
  112. package/dist/tests/lb-rows.test.d.ts +12 -0
  113. package/dist/tests/lb-rows.test.d.ts.map +1 -0
  114. package/dist/tests/lb-rows.test.js +336 -0
  115. package/dist/tests/lb-select.test.d.ts +9 -0
  116. package/dist/tests/lb-select.test.d.ts.map +1 -0
  117. package/dist/tests/lb-select.test.js +71 -0
  118. package/dist/tests/lb-server.test.d.ts +9 -0
  119. package/dist/tests/lb-server.test.d.ts.map +1 -0
  120. package/dist/tests/lb-server.test.js +495 -0
  121. package/dist/tests/lb-table.test.d.ts +15 -0
  122. package/dist/tests/lb-table.test.d.ts.map +1 -0
  123. package/dist/tests/lb-table.test.js +205 -0
  124. package/dist/tests/pages.test.d.ts +6 -0
  125. package/dist/tests/pages.test.d.ts.map +1 -0
  126. package/dist/tests/pages.test.js +98 -0
  127. package/dist/tests/styles.test.d.ts +7 -0
  128. package/dist/tests/styles.test.d.ts.map +1 -0
  129. package/dist/tests/styles.test.js +73 -0
  130. package/dist/widgets/index.d.ts +7 -0
  131. package/dist/widgets/index.d.ts.map +1 -0
  132. package/dist/widgets/index.js +6 -0
  133. package/dist/widgets/lb-input.d.ts +2 -0
  134. package/dist/widgets/lb-input.d.ts.map +1 -0
  135. package/dist/widgets/lb-input.js +48 -0
  136. package/dist/widgets/lb-list.d.ts +2 -0
  137. package/dist/widgets/lb-list.d.ts.map +1 -0
  138. package/dist/widgets/lb-list.js +17 -0
  139. package/dist/widgets/lb-options.d.ts +26 -0
  140. package/dist/widgets/lb-options.d.ts.map +1 -0
  141. package/dist/widgets/lb-options.js +72 -0
  142. package/dist/widgets/lb-picker.d.ts +2 -0
  143. package/dist/widgets/lb-picker.d.ts.map +1 -0
  144. package/dist/widgets/lb-picker.js +25 -0
  145. package/dist/widgets/lb-select.d.ts +2 -0
  146. package/dist/widgets/lb-select.d.ts.map +1 -0
  147. package/dist/widgets/lb-select.js +43 -0
  148. package/dist/widgets/lb-table.d.ts +2 -0
  149. package/dist/widgets/lb-table.d.ts.map +1 -0
  150. package/dist/widgets/lb-table.js +113 -0
  151. package/docs/application-chrome.md +36 -0
  152. package/docs/building-html-pages.md +130 -0
  153. package/docs/getting-started.md +120 -0
  154. package/docs/guide.md +1164 -0
  155. package/docs/hosting.md +218 -0
  156. package/docs/latent-risks.md +20 -0
  157. package/docs/theory.md +226 -0
  158. package/package.json +85 -0
  159. package/widgets/index.ts +6 -0
  160. package/widgets/lb-input.html +1 -0
  161. package/widgets/lb-input.ts +64 -0
  162. package/widgets/lb-list.html +1 -0
  163. package/widgets/lb-list.ts +21 -0
  164. package/widgets/lb-options.html +4 -0
  165. package/widgets/lb-options.ts +88 -0
  166. package/widgets/lb-picker.html +7 -0
  167. package/widgets/lb-picker.ts +27 -0
  168. package/widgets/lb-select.html +4 -0
  169. package/widgets/lb-select.ts +55 -0
  170. package/widgets/lb-table.html +8 -0
  171. package/widgets/lb-table.ts +126 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Validating that a custom element a build actually uses can do something —
3
+ * and finding the script to import for it when it can.
4
+ *
5
+ * A used tag is valid if it has a script, a definition, or both:
6
+ * - a script registers the class that upgrades it. It can come from a
7
+ * built-in source (this package's own widgets/ or hub/ — hub/ is where
8
+ * lb-hub itself lives, discovered exactly the same way as any built-in
9
+ * widget, no special case for it here), an application's own — found
10
+ * anywhere in its source tree by build/locations.ts, not confined to a
11
+ * directory — or a third party's, declared by tag in `elements.ts`.
12
+ * - a definition (an `.html` file, built-in or local) expands the tag's
13
+ * content at build time. A widget with only a definition and no script
14
+ * is markup only: expansion already gave it everything it will ever
15
+ * have, and the tag survives just as an inert wrapper (see expand.ts).
16
+ *
17
+ * A tag with neither — no script anywhere and no definition — would do
18
+ * nothing were it ever to reach the browser, so that is an error.
19
+ *
20
+ * Every directory and file here is a parameter, resolved once by
21
+ * build/locations.ts — this module never reconstructs one of its own, and
22
+ * the application's own widgets are handed over already found, not scanned
23
+ * again here.
24
+ */
25
+ import { readdir } from "node:fs/promises";
26
+ import path from "node:path";
27
+ import { pathToFileURL } from "node:url";
28
+ import { CUSTOM_ELEMENT_TAG } from "./locations.js";
29
+ /** This package's own built-in sources are still a directory scan — that layout is fixed and internal, not discovered from an application's tree. */
30
+ async function scanBuiltinDir(dir) {
31
+ const found = new Map();
32
+ let names;
33
+ try {
34
+ names = await readdir(dir);
35
+ }
36
+ catch {
37
+ return found;
38
+ }
39
+ const entry = (tag) => {
40
+ let w = found.get(tag);
41
+ if (!w) {
42
+ w = {};
43
+ found.set(tag, w);
44
+ }
45
+ return w;
46
+ };
47
+ for (const name of names) {
48
+ if (name.endsWith(".ts")) {
49
+ entry(name.slice(0, -".ts".length).toLowerCase()).script = path.join(dir, name);
50
+ }
51
+ else if (name.endsWith(".html")) {
52
+ entry(name.slice(0, -".html".length).toLowerCase()).html = path.join(dir, name);
53
+ }
54
+ }
55
+ return found;
56
+ }
57
+ /**
58
+ * `elements.ts`, executed rather than parsed — it runs through the same
59
+ * loader as the rest of the build, so it is ordinary TypeScript, not a data
60
+ * format with its own rules.
61
+ */
62
+ export async function loadElementManifest(file) {
63
+ let mod;
64
+ try {
65
+ // pathToFileURL requires an absolute path; the caller's may not be one.
66
+ mod = (await import(pathToFileURL(path.resolve(file)).href));
67
+ }
68
+ catch (err) {
69
+ if (err.code === "ERR_MODULE_NOT_FOUND")
70
+ return {};
71
+ throw new Error(`elements: '${file}' failed to load: ${err.message}`);
72
+ }
73
+ const manifest = (mod.default ?? {});
74
+ for (const [tag, spec] of Object.entries(manifest)) {
75
+ if (!CUSTOM_ELEMENT_TAG.test(tag)) {
76
+ throw new Error(`elements.ts: '${tag}' is not a valid custom element name`);
77
+ }
78
+ if (typeof spec !== "string") {
79
+ throw new Error(`elements.ts: '${tag}' must map to an import specifier string, got ${typeof spec}`);
80
+ }
81
+ }
82
+ return manifest;
83
+ }
84
+ export async function resolveElements({ used, widgets, elementsFile, builtinWidgetsDir, builtinHubDir, packageName, }) {
85
+ // lb-hub is found here the same way as any other built-in: hub/ is just a
86
+ // second built-in source, named specifiers differently because it is the
87
+ // package's main entry point rather than a widgets/ subpath.
88
+ const builtinSources = [
89
+ {
90
+ dir: builtinWidgetsDir,
91
+ specifier: (tag) => `${packageName}/widgets/${tag}`,
92
+ },
93
+ { dir: builtinHubDir, specifier: () => packageName },
94
+ ];
95
+ const builtinDirs = await Promise.all(builtinSources.map((source) => scanBuiltinDir(source.dir)));
96
+ const manifest = elementsFile ? await loadElementManifest(elementsFile) : {};
97
+ const resolved = new Map();
98
+ for (const tag of used) {
99
+ const hits = [];
100
+ let hasDefinition = false;
101
+ builtinSources.forEach((source, i) => {
102
+ const entry = builtinDirs[i].get(tag);
103
+ if (entry?.script) {
104
+ hits.push([
105
+ source.dir,
106
+ { kind: "specifier", value: source.specifier(tag) },
107
+ ]);
108
+ }
109
+ if (entry?.html)
110
+ hasDefinition = true;
111
+ });
112
+ const local = widgets.get(tag);
113
+ if (local?.script) {
114
+ hits.push(["src", { kind: "file", value: local.script }]);
115
+ }
116
+ if (local?.html)
117
+ hasDefinition = true;
118
+ if (tag in manifest) {
119
+ hits.push([elementsFile, { kind: "specifier", value: manifest[tag] }]);
120
+ }
121
+ if (hits.length > 1) {
122
+ throw new Error(`build: <${tag}> is declared in more than one place: ` +
123
+ hits.map(([where]) => where).join(", "));
124
+ }
125
+ if (hits.length === 1) {
126
+ resolved.set(tag, hits[0][1]);
127
+ continue;
128
+ }
129
+ // No script anywhere. Valid only if a definition gave the tag its
130
+ // content — otherwise nothing, ever, would upgrade or fill it, and the
131
+ // tag would do nothing were it to reach the browser.
132
+ if (!hasDefinition) {
133
+ throw new Error(`build: <${tag}> has neither a script nor a definition, so it would ` +
134
+ `do nothing (checked built-in widgets, the src tree, and elements.ts)`);
135
+ }
136
+ }
137
+ return { resolved };
138
+ }
139
+ /**
140
+ * The generated client entry point — every custom element the assembled
141
+ * document actually uses, and nothing else. Written to `outDir` so a
142
+ * relative import to a local widget resolves the ordinary way; a specifier
143
+ * (built-in or third-party) is written as the bare string it is.
144
+ */
145
+ export function clientEntrySource(resolved, outDir) {
146
+ const lines = [];
147
+ for (const tag of [...resolved.keys()].sort()) {
148
+ const entry = resolved.get(tag);
149
+ if (entry.kind === "specifier") {
150
+ lines.push(`import ${JSON.stringify(entry.value)};`);
151
+ }
152
+ else {
153
+ const rel = path.relative(outDir, entry.value).split(path.sep).join("/");
154
+ lines.push(`import ${JSON.stringify(rel.startsWith(".") ? rel : `./${rel}`)};`);
155
+ }
156
+ }
157
+ return lines.join("\n") + "\n";
158
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Expansion — see docs/expansion.md.
3
+ *
4
+ * A page author writes one tag and gets the tree it stands for. Expansion
5
+ * runs at build time, before any request exists, so it has no data. That is
6
+ * why there is no control flow here: nothing to branch on.
7
+ *
8
+ * Substitution is applied to a parsed tree with setAttribute and node values,
9
+ * never to a string. A value applied through the DOM API cannot be reparsed
10
+ * as markup, so injection is impossible by construction.
11
+ */
12
+ export type Definitions = Record<string, string>;
13
+ /**
14
+ * Load one definition per file, named by the tag it defines.
15
+ *
16
+ * Later directories win, so an application can override a built-in widget.
17
+ */
18
+ export declare function loadDefinitions(dirs: string[]): Promise<Definitions>;
19
+ /**
20
+ * Overlay definitions read from explicit files, each already keyed by the
21
+ * tag it defines — for a source, like an application's own `src` tree, that
22
+ * is discovered file by file rather than listed by directory. A tag here
23
+ * overrides the same tag in `defs`, the same override `loadDefinitions`
24
+ * gives a later directory over an earlier one.
25
+ */
26
+ export declare function addDefinitions(defs: Definitions, files: Map<string, string>): Promise<Definitions>;
27
+ /**
28
+ * Every hyphenated tag name left in a document, template content included.
29
+ *
30
+ * Meant to run on already-expanded output: an `LB-*` tag with a definition
31
+ * is gone by then, replaced by the tree it stands for, so what remains is
32
+ * whatever is left for the browser to upgrade itself — a built-in widget
33
+ * class, an application's own custom element, or one from a package. A
34
+ * hyphen in the tag name is the one thing a custom element is guaranteed to
35
+ * have, per the Custom Elements spec, and nothing native has one.
36
+ */
37
+ export declare function customElementTags(html: string): Set<string>;
38
+ /**
39
+ * Expand every widget tag in an authored page. Throws rather than shipping.
40
+ *
41
+ * Definitions must come from `loadDefinitions`, which is where the graph is
42
+ * checked for cycles. Handed a cyclic map directly, this recurses forever.
43
+ */
44
+ export declare function expand(source: string, defs: Definitions): string;
45
+ //# sourceMappingURL=expand.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expand.d.ts","sourceRoot":"","sources":["../../build/expand.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AA0CH,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AA8BjD;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,WAAW,CAAC,CAmB1E;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,IAAI,EAAE,WAAW,EACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,GACzB,OAAO,CAAC,WAAW,CAAC,CAStB;AA4CD;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAS3D;AAkPD;;;;;GAKG;AACH,wBAAgB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,MAAM,CAIhE"}
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Expansion — see docs/expansion.md.
3
+ *
4
+ * A page author writes one tag and gets the tree it stands for. Expansion
5
+ * runs at build time, before any request exists, so it has no data. That is
6
+ * why there is no control flow here: nothing to branch on.
7
+ *
8
+ * Substitution is applied to a parsed tree with setAttribute and node values,
9
+ * never to a string. A value applied through the DOM API cannot be reparsed
10
+ * as markup, so injection is impossible by construction.
11
+ */
12
+ import { readdir, readFile } from "node:fs/promises";
13
+ import { JSDOM } from "jsdom";
14
+ import { ATTR_SLOT, ATTR_TEMPLATE, HUB_TAG_NAME, } from "../core/lb-constants.js";
15
+ /**
16
+ * The framework's reserved namespace. Every one of these must resolve to a
17
+ * definition — a typo here is caught immediately rather than shipped as an
18
+ * inert tag. A tag outside this namespace still expands when a definition
19
+ * exists for it (an application is free to name its own widgets anything),
20
+ * but is not required to have one: with no definition, it is assumed to be
21
+ * a plain custom element, registered by script alone.
22
+ */
23
+ const WIDGET_TAG = /^LB-/;
24
+ /**
25
+ * The one LB-* tag that is not a widget: the hub itself, always present in
26
+ * the chrome, registered by script rather than by definition. Expanding the
27
+ * chrome (see build/assemble.ts) would otherwise demand a definition for it
28
+ * that could never exist.
29
+ */
30
+ const HUB_TAG = HUB_TAG_NAME.toUpperCase();
31
+ /** A placeholder is an entire attribute value or an entire text node. */
32
+ const PLACEHOLDER = /^\{\{([A-Za-z][\w-]*)\}\}$/;
33
+ /**
34
+ * Marks an attribute as input to expansion. Three namespaces share the tag:
35
+ * `lb-` is the hub's, `exp-` is expansion's, and everything unprefixed is
36
+ * HTML's and means what HTML says it means. So an author is free to write
37
+ * `class`, `title` or `hidden` on any widget, whatever its parameters happen
38
+ * to be named. The prefix marks the slot on the tag, where the ambiguity was;
39
+ * inside a definition `{{ }}` already says the same thing, so `exp-label`
40
+ * fills `{{label}}`.
41
+ */
42
+ const EXP_PREFIX = "exp-";
43
+ const dom = new JSDOM("");
44
+ const doc = dom.window.document;
45
+ const { Element, HTMLTemplateElement, Node } = dom.window;
46
+ function parse(source) {
47
+ const holder = doc.createElement("div");
48
+ holder.innerHTML = source;
49
+ return holder;
50
+ }
51
+ /**
52
+ * A tree and the content of every template in it, however deep.
53
+ *
54
+ * A template's children are parsed into a fragment rather than into the tree,
55
+ * so `querySelectorAll` does not reach them. Everything expansion does has to
56
+ * reach them anyway: a definition may supply its own row template, and
57
+ * `lb-picker` does, so `{{key}}` written inside one has to be a
58
+ * placeholder rather than literal text that ships. `walk` already descends
59
+ * into template content to expand widget tags; this is the same rule stated
60
+ * once for every pass that reads a tree.
61
+ */
62
+ function* scopes(root) {
63
+ yield root;
64
+ for (const el of root.querySelectorAll("template")) {
65
+ yield* scopes(el.content);
66
+ }
67
+ }
68
+ /**
69
+ * Load one definition per file, named by the tag it defines.
70
+ *
71
+ * Later directories win, so an application can override a built-in widget.
72
+ */
73
+ export async function loadDefinitions(dirs) {
74
+ const defs = {};
75
+ for (const dir of dirs) {
76
+ let names;
77
+ try {
78
+ names = await readdir(dir);
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ for (const name of names) {
84
+ if (!name.endsWith(".html"))
85
+ continue;
86
+ const file = `${dir}/${name}`;
87
+ const source = await readFile(file, "utf-8");
88
+ checkNames(file, source);
89
+ defs[name.slice(0, -".html".length).toUpperCase()] = source;
90
+ }
91
+ }
92
+ checkAcyclic(defs);
93
+ return defs;
94
+ }
95
+ /**
96
+ * Overlay definitions read from explicit files, each already keyed by the
97
+ * tag it defines — for a source, like an application's own `src` tree, that
98
+ * is discovered file by file rather than listed by directory. A tag here
99
+ * overrides the same tag in `defs`, the same override `loadDefinitions`
100
+ * gives a later directory over an earlier one.
101
+ */
102
+ export async function addDefinitions(defs, files) {
103
+ const merged = { ...defs };
104
+ for (const [tag, file] of files) {
105
+ const source = await readFile(file, "utf-8");
106
+ checkNames(file, source);
107
+ merged[tag.toUpperCase()] = source;
108
+ }
109
+ checkAcyclic(merged);
110
+ return merged;
111
+ }
112
+ /** Every placeholder a definition declares. */
113
+ function placeholdersOf(source) {
114
+ const names = [];
115
+ for (const scope of scopes(parse(source))) {
116
+ for (const el of scope.querySelectorAll("*")) {
117
+ for (const attr of el.attributes) {
118
+ const match = PLACEHOLDER.exec(attr.value.trim());
119
+ if (match)
120
+ names.push(match[1]);
121
+ }
122
+ }
123
+ const walker = doc.createTreeWalker(scope, 4 /* TEXT */);
124
+ while (walker.nextNode()) {
125
+ const match = PLACEHOLDER.exec(walker.currentNode.data.trim());
126
+ if (match)
127
+ names.push(match[1]);
128
+ }
129
+ }
130
+ return names;
131
+ }
132
+ /**
133
+ * A placeholder must be spelled the way a tag can supply it.
134
+ *
135
+ * HTML lowercases attribute names, so `exp-inputClass` reaches expansion as
136
+ * `exp-inputclass` and could never fill `{{inputClass}}`. A definition is the
137
+ * only file that can hold such a name and the only one that can fix it, so it
138
+ * is rejected here, once, rather than at every page that uses the widget.
139
+ *
140
+ * Recognition stays broad on purpose. Narrowing PLACEHOLDER to lowercase would
141
+ * make `{{inputClass}}` not a placeholder at all, and ship it to the browser as
142
+ * literal text — the same silent failure somewhere new.
143
+ */
144
+ function checkNames(file, source) {
145
+ for (const name of placeholdersOf(source)) {
146
+ if (!/[A-Z]/.test(name))
147
+ continue;
148
+ const kebab = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
149
+ throw new Error(`expand: ${file} declares {{${name}}}, but HTML lowercases attribute ` +
150
+ `names, so no tag can supply it. Write {{${kebab}}}.`);
151
+ }
152
+ }
153
+ /**
154
+ * Every hyphenated tag name left in a document, template content included.
155
+ *
156
+ * Meant to run on already-expanded output: an `LB-*` tag with a definition
157
+ * is gone by then, replaced by the tree it stands for, so what remains is
158
+ * whatever is left for the browser to upgrade itself — a built-in widget
159
+ * class, an application's own custom element, or one from a package. A
160
+ * hyphen in the tag name is the one thing a custom element is guaranteed to
161
+ * have, per the Custom Elements spec, and nothing native has one.
162
+ */
163
+ export function customElementTags(html) {
164
+ const tags = new Set();
165
+ for (const scope of scopes(parse(html))) {
166
+ for (const el of scope.querySelectorAll("*")) {
167
+ const tag = el.tagName.toLowerCase();
168
+ if (tag.includes("-"))
169
+ tags.add(tag);
170
+ }
171
+ }
172
+ return tags;
173
+ }
174
+ /** Which widgets a definition uses. Undefined tags are caught during expansion. */
175
+ function referencesOf(source) {
176
+ const refs = new Set();
177
+ for (const scope of scopes(parse(source))) {
178
+ for (const el of scope.querySelectorAll("*")) {
179
+ const tag = el.tagName.toUpperCase();
180
+ if (WIDGET_TAG.test(tag))
181
+ refs.add(tag);
182
+ }
183
+ }
184
+ return [...refs];
185
+ }
186
+ /**
187
+ * Expansion is a fixpoint, so it terminates only on an acyclic graph. A depth
188
+ * cap would hide the bug behind a limit instead of reporting it.
189
+ */
190
+ function checkAcyclic(defs) {
191
+ const refs = new Map();
192
+ for (const [tag, source] of Object.entries(defs)) {
193
+ refs.set(tag, referencesOf(source));
194
+ }
195
+ const done = new Set();
196
+ const path = [];
197
+ const visit = (tag) => {
198
+ if (done.has(tag))
199
+ return;
200
+ const cycle = path.indexOf(tag);
201
+ if (cycle !== -1) {
202
+ throw new Error(`expand: cycle in the definition graph: ` +
203
+ [...path.slice(cycle), tag].map((t) => t.toLowerCase()).join(" → "));
204
+ }
205
+ path.push(tag);
206
+ for (const ref of refs.get(tag) ?? [])
207
+ visit(ref);
208
+ path.pop();
209
+ done.add(tag);
210
+ };
211
+ for (const tag of refs.keys())
212
+ visit(tag);
213
+ }
214
+ /**
215
+ * Resolve a placeholder against what the author wrote. Unset means absent.
216
+ *
217
+ * Every name the definition asks for is recorded, whether or not it was
218
+ * supplied, so expansion can report a parameter the definition never declared.
219
+ */
220
+ function resolve(text, attrs, asked) {
221
+ const match = PLACEHOLDER.exec(text.trim());
222
+ if (!match)
223
+ return text;
224
+ const name = match[1];
225
+ asked.add(name);
226
+ return attrs.get(name) ?? null;
227
+ }
228
+ function substitute(root, attrs, asked) {
229
+ for (const scope of scopes(root)) {
230
+ for (const el of scope.querySelectorAll("*")) {
231
+ for (const attr of [...el.attributes]) {
232
+ const value = resolve(attr.value, attrs, asked);
233
+ // Presence propagates: a placeholder with nothing behind it drops the
234
+ // attribute rather than emitting it empty, which is what HTML's own
235
+ // boolean-attribute semantics require.
236
+ if (value === null)
237
+ el.removeAttribute(attr.name);
238
+ else if (value !== attr.value)
239
+ el.setAttribute(attr.name, value);
240
+ }
241
+ }
242
+ const walker = doc.createTreeWalker(scope, 4 /* TEXT */);
243
+ const texts = [];
244
+ while (walker.nextNode())
245
+ texts.push(walker.currentNode);
246
+ for (const text of texts) {
247
+ const raw = text.data;
248
+ if (!PLACEHOLDER.test(raw.trim()))
249
+ continue;
250
+ const value = resolve(raw, attrs, asked) ?? "";
251
+ // Keep the author's surrounding whitespace: "{{label}} <input>" should
252
+ // still separate the label from the control.
253
+ const lead = raw.slice(0, raw.length - raw.trimStart().length);
254
+ const tail = raw.slice(raw.trimEnd().length);
255
+ text.data = lead + value + tail;
256
+ }
257
+ }
258
+ }
259
+ /**
260
+ * Fill the definition's named destinations from the authored templates that
261
+ * name them, and hand back the content that is left for the slot.
262
+ *
263
+ * A destination is filled with the template's contents, not with the
264
+ * template, so what ships is ordinary markup in an ordinary place — a real
265
+ * `<tr>` inside a real `<thead>`, visible in view-source like everything
266
+ * else. The wrapper existed only to survive the parser and is gone.
267
+ *
268
+ * Unlike the slot, there may be several, because the reason for wrapping is
269
+ * per-destination: a table has a head and a body, and each needs its own
270
+ * markup past the same obstacle. They are matched by name, and an empty name
271
+ * is a name, so a widget with one destination costs the author nothing.
272
+ */
273
+ function fillTemplates(tag, root, content) {
274
+ const destinations = new Map();
275
+ for (const el of root.querySelectorAll(`[${ATTR_TEMPLATE}]`)) {
276
+ const name = el.getAttribute(ATTR_TEMPLATE);
277
+ if (destinations.has(name)) {
278
+ throw new Error(`expand: <${tag.toLowerCase()}> declares two ${ATTR_TEMPLATE}="${name}" destinations`);
279
+ }
280
+ destinations.set(name, el);
281
+ el.removeAttribute(ATTR_TEMPLATE);
282
+ }
283
+ const rest = [];
284
+ const filled = new Set();
285
+ for (const node of content) {
286
+ const named = node instanceof HTMLTemplateElement && node.hasAttribute(ATTR_TEMPLATE);
287
+ if (!named) {
288
+ rest.push(node);
289
+ continue;
290
+ }
291
+ const template = node;
292
+ const name = template.getAttribute(ATTR_TEMPLATE);
293
+ const destination = destinations.get(name);
294
+ if (!destination) {
295
+ throw new Error(`expand: a <template ${ATTR_TEMPLATE}="${name}"> was written inside ` +
296
+ `<${tag.toLowerCase()}>, whose definition has no destination by that name.`);
297
+ }
298
+ if (filled.has(name)) {
299
+ throw new Error(`expand: <${tag.toLowerCase()}> was given two templates for '${name}'`);
300
+ }
301
+ filled.add(name);
302
+ destination.append(template.content);
303
+ }
304
+ return rest;
305
+ }
306
+ /** Place authored inner content in the single slot, if the definition has one. */
307
+ function fillSlot(tag, root, content) {
308
+ const slots = root.querySelectorAll(`[${ATTR_SLOT}]`);
309
+ if (slots.length > 1) {
310
+ throw new Error(`expand: <${tag.toLowerCase()}> declares more than one ${ATTR_SLOT}`);
311
+ }
312
+ if (slots.length === 0) {
313
+ // Whitespace is not content. An author who writes only named templates
314
+ // still leaves the newlines between them, and those are the file's
315
+ // formatting rather than something anybody meant to place.
316
+ const wrote = content.some((node) => node.nodeType !== Node.TEXT_NODE || node.data.trim());
317
+ if (wrote) {
318
+ throw new Error(`expand: content was written inside <${tag.toLowerCase()}>, whose definition has no ${ATTR_SLOT}`);
319
+ }
320
+ return;
321
+ }
322
+ const slot = slots[0];
323
+ // The authored nodes move rather than copy. The caller replaces the tag's
324
+ // children with the expanded tree immediately after, so there is nothing
325
+ // left behind for a clone to protect.
326
+ slot.append(...content);
327
+ slot.removeAttribute(ATTR_SLOT);
328
+ }
329
+ function expandElement(el, defs) {
330
+ const tag = el.tagName.toUpperCase();
331
+ const source = defs[tag];
332
+ if (source === undefined) {
333
+ throw new Error(`expand: <${tag.toLowerCase()}> has no definition. Every hub tag must resolve.`);
334
+ }
335
+ // The tag survives expansion carrying every attribute the author wrote,
336
+ // `exp-` ones included. They cost nothing where they are, and what ships
337
+ // shows what was asked for beside what it produced.
338
+ const attrs = new Map();
339
+ for (const attr of el.attributes) {
340
+ if (!attr.name.startsWith(EXP_PREFIX))
341
+ continue;
342
+ attrs.set(attr.name.slice(EXP_PREFIX.length), attr.value);
343
+ }
344
+ const tree = parse(source);
345
+ const asked = new Set();
346
+ substitute(tree, attrs, asked);
347
+ // A parameter the definition never declared is a name that resolves to
348
+ // nothing. Unprefixed attributes could not support this check, because a
349
+ // stray one is indistinguishable from a deliberate HTML attribute.
350
+ for (const name of attrs.keys()) {
351
+ if (asked.has(name))
352
+ continue;
353
+ throw new Error(`expand: <${tag.toLowerCase()}> was given ${EXP_PREFIX}${name}, ` +
354
+ `but its definition has no {{${name}}}.`);
355
+ }
356
+ // Named destinations first: what they take is addressed to them, and only
357
+ // what is left is the slot's.
358
+ fillSlot(tag, tree, fillTemplates(tag, tree, [...el.childNodes]));
359
+ el.replaceChildren(...tree.childNodes);
360
+ }
361
+ function walk(root, defs) {
362
+ for (const child of [...root.children]) {
363
+ const tag = child.tagName.toUpperCase();
364
+ if (tag in defs || (WIDGET_TAG.test(tag) && tag !== HUB_TAG)) {
365
+ expandElement(child, defs);
366
+ }
367
+ // Definitions may use other widgets, so descend after expanding. The
368
+ // acyclicity check is what makes this terminate.
369
+ walk(child, defs);
370
+ // Template content is markup in a file like any other, and it is inert,
371
+ // so a row template ships already expanded.
372
+ if (child instanceof HTMLTemplateElement)
373
+ walk(child.content, defs);
374
+ }
375
+ }
376
+ /**
377
+ * Expand every widget tag in an authored page. Throws rather than shipping.
378
+ *
379
+ * Definitions must come from `loadDefinitions`, which is where the graph is
380
+ * checked for cycles. Handed a cyclic map directly, this recurses forever.
381
+ */
382
+ export function expand(source, defs) {
383
+ const tree = parse(source);
384
+ walk(tree, defs);
385
+ return tree.innerHTML;
386
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pretty-printing the assembled document — cosmetic, and deliberately so.
3
+ *
4
+ * Loadbare App ships the DOM the browser holds, so view-source is a real view of the
5
+ * application. That only pays if it is legible. Expansion produces correct
6
+ * markup but inherits its whitespace from the definition files, so the
7
+ * assembled document reads like a concatenation, which is what it is.
8
+ *
9
+ * Prettier is a nice-to-have here and is treated as one. It runs on the whole
10
+ * document once, after everything is assembled, and every way it can fail —
11
+ * throwing, or not being installed — leaves the unformatted string in place.
12
+ *
13
+ * Ignoring the throw is the point rather than an oversight. The correct output
14
+ * is what `expand` already returned; formatting only makes it pleasant. If a
15
+ * failure could fail the build, we would end up writing checks upstream whose
16
+ * only purpose was to keep the formatter happy, and an error message about
17
+ * markup the author never wrote is worse than an ugly line. So: nothing in this
18
+ * codebase exists to keep prettier from throwing. It either helps or it doesn't.
19
+ *
20
+ * The one failure that would retire this outright is whitespace. Whitespace is
21
+ * semantic in inline contexts — `<label>Name <input></label>` needs that space —
22
+ * and prettier has no display information for `lb-*` elements. If formatting
23
+ * is ever observed to change what renders, delete this file. A prettier page
24
+ * that shows the wrong thing is not a trade worth making.
25
+ */
26
+ /** Format if we can, and return the input unchanged if we cannot. */
27
+ export declare function formatHtml(source: string): Promise<string>;
28
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../build/format.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,qEAAqE;AACrE,wBAAsB,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBhE"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pretty-printing the assembled document — cosmetic, and deliberately so.
3
+ *
4
+ * Loadbare App ships the DOM the browser holds, so view-source is a real view of the
5
+ * application. That only pays if it is legible. Expansion produces correct
6
+ * markup but inherits its whitespace from the definition files, so the
7
+ * assembled document reads like a concatenation, which is what it is.
8
+ *
9
+ * Prettier is a nice-to-have here and is treated as one. It runs on the whole
10
+ * document once, after everything is assembled, and every way it can fail —
11
+ * throwing, or not being installed — leaves the unformatted string in place.
12
+ *
13
+ * Ignoring the throw is the point rather than an oversight. The correct output
14
+ * is what `expand` already returned; formatting only makes it pleasant. If a
15
+ * failure could fail the build, we would end up writing checks upstream whose
16
+ * only purpose was to keep the formatter happy, and an error message about
17
+ * markup the author never wrote is worse than an ugly line. So: nothing in this
18
+ * codebase exists to keep prettier from throwing. It either helps or it doesn't.
19
+ *
20
+ * The one failure that would retire this outright is whitespace. Whitespace is
21
+ * semantic in inline contexts — `<label>Name <input></label>` needs that space —
22
+ * and prettier has no display information for `lb-*` elements. If formatting
23
+ * is ever observed to change what renders, delete this file. A prettier page
24
+ * that shows the wrong thing is not a trade worth making.
25
+ */
26
+ /** Format if we can, and return the input unchanged if we cannot. */
27
+ export async function formatHtml(source) {
28
+ try {
29
+ const prettier = await import("prettier");
30
+ return await prettier.format(source, {
31
+ parser: "html",
32
+ // The default. Prettier respects each element's CSS display default when
33
+ // deciding where whitespace may move, and treats elements it does not
34
+ // know — every hub widget — as inline, which is the conservative side.
35
+ htmlWhitespaceSensitivity: "css",
36
+ });
37
+ }
38
+ catch (err) {
39
+ console.warn(`loadbare: shipping unformatted markup, prettier declined: ${err}`);
40
+ return source;
41
+ }
42
+ }