@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,88 @@
1
+ /** docs/requests.md — the operation set is closed. */
2
+ export type HubRequest = {
3
+ op: "cell-change";
4
+ query: string;
5
+ key: string;
6
+ cell: string;
7
+ value: string;
8
+ } | {
9
+ op: "tuple-insert";
10
+ query: string;
11
+ values: Record<string, string>;
12
+ } | {
13
+ op: "tuple-update";
14
+ query: string;
15
+ key: string;
16
+ values: Record<string, string>;
17
+ } | {
18
+ op: "tuple-delete";
19
+ query: string;
20
+ key: string;
21
+ }
22
+ /**
23
+ * Everything that is not a CRUD operation.
24
+ *
25
+ * The name is the application's and is looked up in the page's declared
26
+ * actions. What keeps this from being an RPC endpoint is that the name must
27
+ * already appear in the page's hooks: the browser cannot reach anything the
28
+ * page has not published, and there is no argument list — only where the
29
+ * interaction happened and, where a control has one, its value.
30
+ *
31
+ * A button carries no value, so the server computes the whole of the new
32
+ * state and the browser never displays a number it has not confirmed. A
33
+ * `<select>` carries one, because the choice is the interaction.
34
+ */
35
+ | {
36
+ op: "action";
37
+ name: string;
38
+ query?: string;
39
+ key?: string;
40
+ cell?: string;
41
+ value?: string;
42
+ };
43
+ /** One query's result: the cells of a single tuple. Every value is a string. */
44
+ export type QueryResult = Record<string, string>;
45
+ /**
46
+ * A query that returns many tuples — see docs/guide.md, "List processing".
47
+ *
48
+ * Two results, because a list widget must be told the difference. `rows` is
49
+ * the entire set and therefore also the order; `patch` names only what
50
+ * changed and leaves everything it does not name alone. Add and remove are
51
+ * one result rather than two because the interesting cases are both at once
52
+ * — a row whose sort key changed has to move, a swap is one out and one in —
53
+ * and two messages would paint the intermediate state.
54
+ *
55
+ * `op` is the same discriminant word the request union uses, so one
56
+ * vocabulary runs in both directions. It is therefore a reserved cell name:
57
+ * a tuple with a cell called `op` whose value is `rows` or `patch` would be
58
+ * read as a projection.
59
+ */
60
+ export type Projection = {
61
+ op: "rows";
62
+ rows: QueryResult[];
63
+ } | {
64
+ op: "patch";
65
+ rows?: QueryResult[];
66
+ drop?: string[];
67
+ };
68
+ /** A tuple is the sugar and the common case; a projection says so. */
69
+ export type HubResult = QueryResult | Projection;
70
+ export declare function isProjection(result: HubResult): result is Projection;
71
+ /**
72
+ * Every response on the data channel is the same shape, whether it is a
73
+ * cold start or the narrowest refresh. The hub does not know which case
74
+ * it is in.
75
+ */
76
+ export type HubData = Record<string, HubResult>;
77
+ /**
78
+ * What a list widget implements. The hub delivers a projection by calling
79
+ * this and stops; where a row goes is the widget's, because only the widget
80
+ * knows whether it sorts, groups, or appends.
81
+ *
82
+ * One protocol name, not a table of tag names — the hub tests for the method,
83
+ * never for the element.
84
+ */
85
+ export interface HubRowHost {
86
+ acceptRows(result: Projection): void;
87
+ }
88
+ //# sourceMappingURL=lb-types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-types.d.ts","sourceRoot":"","sources":["../../core/lb-types.ts"],"names":[],"mappings":"AAEA,sDAAsD;AACtD,MAAM,MAAM,UAAU,GAClB;IACE,EAAE,EAAE,aAAa,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf,GACD;IAAE,EAAE,EAAE,cAAc,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACrE;IACE,EAAE,EAAE,cAAc,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAChC,GACD;IAAE,EAAE,EAAE,cAAc,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE;AACpD;;;;;;;;;;;;GAYG;GACD;IACE,EAAE,EAAE,QAAQ,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEN,gFAAgF;AAChF,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAEjD;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,UAAU,GAClB;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,WAAW,EAAE,CAAA;CAAE,GACnC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,WAAW,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAE3D,sEAAsE;AACtE,MAAM,MAAM,SAAS,GAAG,WAAW,GAAG,UAAU,CAAC;AAEjD,wBAAgB,YAAY,CAAC,MAAM,EAAE,SAAS,GAAG,MAAM,IAAI,UAAU,CAGpE;AAED;;;;GAIG;AACH,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAEhD;;;;;;;GAOG;AACH,MAAM,WAAW,UAAU;IACzB,UAAU,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAAC;CACtC"}
@@ -0,0 +1,5 @@
1
+ // The wire vocabulary. Shared by browser and server.
2
+ export function isProjection(result) {
3
+ const op = result.op;
4
+ return op === "rows" || op === "patch";
5
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A plain custom element with no tie to the framework: no lb- prefix, no
3
+ * expansion definition, nothing but customElements.define. It exists to
4
+ * prove the builder resolves and bundles a tag by directory convention
5
+ * alone, exactly as it does for a widget the framework ships itself.
6
+ *
7
+ * Its look comes from app-box.css, a plain class rather than the style
8
+ * attribute — a style attribute is inline script by another name, and the
9
+ * CSP this framework targets refuses it exactly as it refuses inline
10
+ * <script>.
11
+ */
12
+ declare class AppBox extends HTMLElement {
13
+ connectedCallback(): void;
14
+ }
15
+ //# sourceMappingURL=app-box.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app-box.d.ts","sourceRoot":"","sources":["../../../../demo-static/src/widgets/app-box.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AACH,cAAM,MAAO,SAAQ,WAAW;IAC9B,iBAAiB;CAGlB"}
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ /// <reference lib="dom" />
3
+ /**
4
+ * A plain custom element with no tie to the framework: no lb- prefix, no
5
+ * expansion definition, nothing but customElements.define. It exists to
6
+ * prove the builder resolves and bundles a tag by directory convention
7
+ * alone, exactly as it does for a widget the framework ships itself.
8
+ *
9
+ * Its look comes from app-box.css, a plain class rather than the style
10
+ * attribute — a style attribute is inline script by another name, and the
11
+ * CSP this framework targets refuses it exactly as it refuses inline
12
+ * <script>.
13
+ */
14
+ class AppBox extends HTMLElement {
15
+ connectedCallback() {
16
+ this.classList.add("app-box");
17
+ }
18
+ }
19
+ customElements.define("app-box", AppBox);
@@ -0,0 +1,13 @@
1
+ import { type QueryResult, type HubData } from "../core/lb-types";
2
+ /**
3
+ * Fill one scope from one tuple.
4
+ *
5
+ * The root counts as a cell if it carries one. A `<tr>` holds its cells in
6
+ * `<td>` children, but `<option>`'s content model is text, so an option row
7
+ * has to be the cell it displays. Requiring a wrapper there would require an
8
+ * element HTML does not allow.
9
+ */
10
+ export declare function applyTuple(root: Element, cells: QueryResult): void;
11
+ /** Land a whole response. Every query result arrives through here. */
12
+ export declare function applyData(root: ParentNode, data: HubData): void;
13
+ //# sourceMappingURL=lb-apply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-apply.d.ts","sourceRoot":"","sources":["../../hub/lb-apply.ts"],"names":[],"mappings":"AAcA,OAAO,EAGL,KAAK,WAAW,EAChB,KAAK,OAAO,EAEb,MAAM,kBAAkB,CAAC;AAkB1B;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI,CAMlE;AAmBD,sEAAsE;AACtE,wBAAgB,SAAS,CAAC,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,OAAO,GAAG,IAAI,CAY/D"}
@@ -0,0 +1,77 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Landing data on a live host — see docs/guide.md, "Applying a response".
4
+ *
5
+ * This is one file rather than part of the hub because a list widget needs
6
+ * the same operation. It clones a row and fills it with a tuple, and filling
7
+ * a row is filling a scope: the only difference is how much of the page the
8
+ * root covers. A widget imports `applyTuple` the way it imports `ATTR_VALUE`,
9
+ * so there is one implementation of what a cell means and nobody reaches into
10
+ * anybody's children.
11
+ */
12
+ import { ATTR_CELL, ATTR_QUERY, ATTR_VALUE } from "../core/lb-constants";
13
+ import { isProjection, } from "../core/lb-types";
14
+ /**
15
+ * A cell lands one of two ways. A custom element owns whatever control it
16
+ * wraps, so it receives the value as an attribute and renders it itself.
17
+ * Because the browser runs `attributeChangedCallback` for attributes already
18
+ * present when a widget upgrades, that is the same operation whether the host
19
+ * was inserted a microsecond ago or an hour ago.
20
+ *
21
+ * A native element has no behavior of its own, so its value is its text.
22
+ * The test is lexical — a hyphen in the tag name — and the hub holds no
23
+ * knowledge of any particular element.
24
+ */
25
+ function land(el, value) {
26
+ if (el.localName.includes("-"))
27
+ el.setAttribute(ATTR_VALUE, value);
28
+ else
29
+ el.textContent = value;
30
+ }
31
+ /**
32
+ * Fill one scope from one tuple.
33
+ *
34
+ * The root counts as a cell if it carries one. A `<tr>` holds its cells in
35
+ * `<td>` children, but `<option>`'s content model is text, so an option row
36
+ * has to be the cell it displays. Requiring a wrapper there would require an
37
+ * element HTML does not allow.
38
+ */
39
+ export function applyTuple(root, cells) {
40
+ for (const [cell, value] of Object.entries(cells)) {
41
+ const selector = `[${ATTR_CELL}="${cell}"]`;
42
+ if (root.matches(selector))
43
+ land(root, value);
44
+ for (const el of root.querySelectorAll(selector))
45
+ land(el, value);
46
+ }
47
+ }
48
+ /**
49
+ * A projection has to reach a widget. A native element has one destination
50
+ * for a value and no way to acquire children, so a list is not something it
51
+ * can be asked to show.
52
+ */
53
+ function landRows(scope, query, result) {
54
+ const host = scope;
55
+ if (typeof host.acceptRows !== "function") {
56
+ console.error(`lb-hub: query '${query}' returned rows, but <${scope.localName}> ` +
57
+ `is not a list widget`);
58
+ return;
59
+ }
60
+ host.acceptRows(result);
61
+ }
62
+ /** Land a whole response. Every query result arrives through here. */
63
+ export function applyData(root, data) {
64
+ for (const [query, result] of Object.entries(data)) {
65
+ const scopes = root.querySelectorAll(`[${ATTR_QUERY}="${query}"]`);
66
+ if (scopes.length === 0) {
67
+ console.warn(`lb-hub: no scope for query '${query}', skipping`);
68
+ continue;
69
+ }
70
+ for (const scope of scopes) {
71
+ if (isProjection(result))
72
+ landRows(scope, query, result);
73
+ else
74
+ applyTuple(scope, result);
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,2 @@
1
+ export { applyData, applyTuple } from "./lb-apply";
2
+ //# sourceMappingURL=lb-hub.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-hub.d.ts","sourceRoot":"","sources":["../../hub/lb-hub.ts"],"names":[],"mappings":"AAuBA,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,242 @@
1
+ /// <reference lib="dom" />
2
+ import { ATTR_ACTION, ATTR_CELL, ATTR_DELETE, ATTR_ERROR, ATTR_INSERT, ATTR_KEY, ATTR_NAV_LINK, ATTR_PENDING, ATTR_QUERY, ATTR_UPDATE, HUB_TAG_NAME, PAGE_TEMPLATE_PREFIX, LB_DATA_ENDPOINT, LB_EVENT_NAME, LB_REQUEST_ENDPOINT, LB_REQUEST_TIMEOUT_MS, } from "../core/lb-constants";
3
+ import { applyData } from "./lb-apply";
4
+ export { applyData, applyTuple } from "./lb-apply";
5
+ /**
6
+ * Where a click happened, in the addressing vocabulary. An action carries
7
+ * these and nothing else, so there is no channel for a browser-supplied
8
+ * argument.
9
+ */
10
+ function coordinates(el) {
11
+ const query = el.closest(`[${ATTR_QUERY}]`)?.getAttribute(ATTR_QUERY);
12
+ const key = el.closest(`[${ATTR_KEY}]`)?.getAttribute(ATTR_KEY);
13
+ const cell = el.getAttribute(ATTR_CELL);
14
+ return {
15
+ ...(query === null || query === undefined ? {} : { query }),
16
+ ...(key === null || key === undefined ? {} : { key }),
17
+ ...(cell === null ? {} : { cell }),
18
+ };
19
+ }
20
+ /**
21
+ * The value a cell is currently showing, for gathering rather than landing.
22
+ *
23
+ * A cell is always either a native form control itself (a bare
24
+ * `<input lb-cell>`) or a widget that wraps one (`<lb-input>`, `<lb-select>`)
25
+ * — that wrapping is structural, not incidental, which is what lets this be
26
+ * one rule instead of one per widget type.
27
+ */
28
+ function controlValue(el) {
29
+ const control = el instanceof HTMLInputElement ||
30
+ el instanceof HTMLSelectElement ||
31
+ el instanceof HTMLTextAreaElement
32
+ ? el
33
+ : el.querySelector("input, select, textarea");
34
+ return control?.value;
35
+ }
36
+ /**
37
+ * Every lb-cell inside a form, gathered into one values map. Shared by
38
+ * lb-insert and lb-update — both submit a batch of cells, and differ only in
39
+ * whether a key comes with them.
40
+ */
41
+ function gatherValues(form) {
42
+ const values = {};
43
+ for (const cell of form.querySelectorAll(`[${ATTR_CELL}]`)) {
44
+ const name = cell.getAttribute(ATTR_CELL);
45
+ const value = controlValue(cell);
46
+ if (value === undefined) {
47
+ console.warn(`lb-hub: lb-cell '${name}' has no control to read, ignoring it`, cell);
48
+ continue;
49
+ }
50
+ values[name] = value;
51
+ }
52
+ return values;
53
+ }
54
+ /** A path names a page host, and nothing finer. */
55
+ function pageNameFor(path) {
56
+ const name = path.replace(/^\/+|\/+$/g, "");
57
+ return name === "" ? "counter" : name;
58
+ }
59
+ /**
60
+ * Fetch one round trip against the client-side deadline. Throws — rather
61
+ * than swallowing — on a non-ok response, a network failure, or a timeout,
62
+ * so a caller that needs to know the request failed (as opposed to one that
63
+ * only ever wants the freshest data, like navigate()) can tell the two
64
+ * apart from a genuinely empty result.
65
+ */
66
+ async function fetchData(url, init) {
67
+ const controller = new AbortController();
68
+ const timeout = setTimeout(() => controller.abort(), LB_REQUEST_TIMEOUT_MS);
69
+ try {
70
+ const res = await fetch(url, { ...init, signal: controller.signal });
71
+ if (!res.ok) {
72
+ throw new Error(`lb-hub: server responded ${res.status} for ${url}`);
73
+ }
74
+ return (await res.json());
75
+ }
76
+ finally {
77
+ clearTimeout(timeout);
78
+ }
79
+ }
80
+ /**
81
+ * The hub is a singleton outside <main>. It survives every page change,
82
+ * so it needs no id to disambiguate it.
83
+ */
84
+ class LbHub extends HTMLElement {
85
+ main;
86
+ /**
87
+ * The page currently in <main>. Hooks are declared per page, so a request
88
+ * has to say which page it came from. The page rides on the URL rather
89
+ * than in the request, leaving the operation set closed.
90
+ */
91
+ page = "";
92
+ connectedCallback() {
93
+ this.main = this.querySelector("main");
94
+ this.addEventListener(LB_EVENT_NAME, (e) => {
95
+ const request = e.detail;
96
+ if (!request?.op) {
97
+ console.warn(`lb-hub: event without an op, ignoring`, request);
98
+ return;
99
+ }
100
+ // The element that dispatched the request, not e.currentTarget (the
101
+ // hub itself, since the event bubbles) — pending/error state belongs
102
+ // at the origin so a widget can watch its own attributes.
103
+ const origin = e.target;
104
+ origin?.removeAttribute(ATTR_ERROR);
105
+ origin?.setAttribute(ATTR_PENDING, "");
106
+ const url = `${LB_REQUEST_ENDPOINT}?page=${encodeURIComponent(this.page)}`;
107
+ void fetchData(url, {
108
+ method: "POST",
109
+ headers: { "content-type": "application/json" },
110
+ body: JSON.stringify(request),
111
+ })
112
+ .then((data) => applyData(this.main, data))
113
+ .catch((err) => {
114
+ console.error(`lb-hub: request failed`, err);
115
+ origin?.setAttribute(ATTR_ERROR, "");
116
+ })
117
+ .finally(() => origin?.removeAttribute(ATTR_PENDING));
118
+ });
119
+ /**
120
+ * A native element carrying an action. The hub builds the request and
121
+ * dispatches it from that element as the ordinary bubbling event, rather
122
+ * than sending it directly, so an ancestor widget can still stop it and
123
+ * confirm. A native action button and a hand-written widget therefore
124
+ * produce identical events.
125
+ *
126
+ * A widget carrying an action is left alone — it owns its own interaction
127
+ * and decides what counts as performing it, which for a <select> is a
128
+ * change rather than a click. The test is the same lexical one applyData
129
+ * uses: a hyphen in the tag name.
130
+ */
131
+ this.addEventListener("click", (e) => {
132
+ const el = e.target?.closest(`[${ATTR_ACTION}]`);
133
+ if (!el || el.localName.includes("-"))
134
+ return;
135
+ e.preventDefault();
136
+ const detail = {
137
+ op: "action",
138
+ name: el.getAttribute(ATTR_ACTION),
139
+ ...coordinates(el),
140
+ };
141
+ el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
142
+ });
143
+ // The CRUD counterpart to the lb-action delegation above: a native
144
+ // element needs no declared name to delete the row it is in, only its
145
+ // own query/key coordinates.
146
+ this.addEventListener("click", (e) => {
147
+ const el = e.target?.closest(`[${ATTR_DELETE}]`);
148
+ if (!el || el.localName.includes("-"))
149
+ return;
150
+ e.preventDefault();
151
+ const { query, key } = coordinates(el);
152
+ if (!query || !key) {
153
+ console.warn(`lb-hub: lb-delete with no query/key coordinates, ignoring`, el);
154
+ return;
155
+ }
156
+ const detail = { op: "tuple-delete", query, key };
157
+ el.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
158
+ });
159
+ // The CRUD op that gathers rather than addresses a single cell: every
160
+ // lb-cell inside the form becomes one entry of the values map. There is
161
+ // no key, because there is no row yet — only the query, found the same
162
+ // way coordinates() finds one for a click, says what it is inserting
163
+ // into.
164
+ this.addEventListener("submit", (e) => {
165
+ const form = e.target?.closest(`[${ATTR_INSERT}]`);
166
+ if (!form)
167
+ return;
168
+ e.preventDefault();
169
+ const { query } = coordinates(form);
170
+ if (!query) {
171
+ console.warn(`lb-hub: lb-insert with no lb-query, ignoring`, form);
172
+ return;
173
+ }
174
+ const detail = {
175
+ op: "tuple-insert",
176
+ query,
177
+ values: gatherValues(form),
178
+ };
179
+ form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
180
+ });
181
+ // The batch counterpart to cell-change: same gathering as lb-insert, but
182
+ // with the key of the row it is a form for — a list's row template root,
183
+ // or a form inside one, has it from the same ancestor a delete button
184
+ // reads.
185
+ this.addEventListener("submit", (e) => {
186
+ const form = e.target?.closest(`[${ATTR_UPDATE}]`);
187
+ if (!form)
188
+ return;
189
+ e.preventDefault();
190
+ const { query, key } = coordinates(form);
191
+ if (!query || !key) {
192
+ console.warn(`lb-hub: lb-update with no query/key coordinates, ignoring`, form);
193
+ return;
194
+ }
195
+ const detail = {
196
+ op: "tuple-update",
197
+ query,
198
+ key,
199
+ values: gatherValues(form),
200
+ };
201
+ form.dispatchEvent(new CustomEvent(LB_EVENT_NAME, { bubbles: true, detail }));
202
+ });
203
+ // Only anchors that opted in. Everything else is a real link.
204
+ this.addEventListener("click", (e) => {
205
+ const link = e.target?.closest(`a[${ATTR_NAV_LINK}]`);
206
+ if (!link)
207
+ return;
208
+ e.preventDefault();
209
+ const path = new URL(link.href).pathname;
210
+ history.pushState(null, "", path);
211
+ void this.navigate(path);
212
+ });
213
+ addEventListener("popstate", () => void this.navigate(location.pathname));
214
+ void this.navigate(location.pathname);
215
+ }
216
+ /**
217
+ * Navigation is the one operation that replaces host DOM. The host is
218
+ * already in the document, so selecting a page is getElementById() and
219
+ * there is no fetching mechanism on the host channel.
220
+ */
221
+ async navigate(path) {
222
+ const page = pageNameFor(path);
223
+ const template = document.getElementById(PAGE_TEMPLATE_PREFIX + page);
224
+ if (!(template instanceof HTMLTemplateElement)) {
225
+ console.error(`lb-hub: no page host for '${page}'`);
226
+ return;
227
+ }
228
+ let data = {};
229
+ try {
230
+ data = await fetchData(`${LB_DATA_ENDPOINT}?page=${encodeURIComponent(page)}`);
231
+ }
232
+ catch (err) {
233
+ console.error(`lb-hub: failed to load data for '${page}'`, err);
234
+ }
235
+ this.page = page;
236
+ // Insert and hydrate in one synchronous block: the browser does not
237
+ // paint mid-task, so there is no empty flash.
238
+ this.main.replaceChildren(template.content.cloneNode(true));
239
+ applyData(this.main, data);
240
+ }
241
+ }
242
+ customElements.define(HUB_TAG_NAME, LbHub);
@@ -0,0 +1,18 @@
1
+ import type { Projection, QueryResult } from "../core/lb-types";
2
+ /**
3
+ * Where a row belongs, called with the row detached on its first appearance.
4
+ *
5
+ * The default puts it immediately before the template, so rows accumulate in
6
+ * the order they arrive and the template stays put as the insertion marker.
7
+ */
8
+ export type Place = (row: Element, tuple: QueryResult, template: HTMLTemplateElement) => void;
9
+ /**
10
+ * Land a projection in a widget's subtree.
11
+ *
12
+ * `rows` is the whole set, so it decides membership and order: every row is
13
+ * placed in the order given, and a row whose key did not arrive is gone.
14
+ * `patch` disturbs only what it names — a row it did not mention keeps its
15
+ * contents and its position.
16
+ */
17
+ export declare function applyRows(scope: Element, result: Projection, place?: Place): void;
18
+ //# sourceMappingURL=lb-rows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-rows.d.ts","sourceRoot":"","sources":["../../hub/lb-rows.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAGhE;;;;;GAKG;AACH,MAAM,MAAM,KAAK,GAAG,CAClB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,WAAW,EAClB,QAAQ,EAAE,mBAAmB,KAC1B,IAAI,CAAC;AAwCV;;;;;;;GAOG;AACH,wBAAgB,SAAS,CACvB,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,UAAU,EAClB,KAAK,GAAE,KAAc,GACpB,IAAI,CAkDN"}
@@ -0,0 +1,106 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * The row machinery a list widget shares — see docs/guide.md, "List
4
+ * processing".
5
+ *
6
+ * A widget owns where a row goes and nothing else. Cloning the template,
7
+ * matching a tuple to the row already showing it, and filling that row are
8
+ * the same in every list, so they are here, and a widget that sorts or groups
9
+ * supplies one function rather than a second implementation of all of it.
10
+ */
11
+ import { ATTR_KEY, ATTR_ROW_COUNT } from "../core/lb-constants";
12
+ import { applyTuple } from "./lb-apply";
13
+ const before = (row, _tuple, template) => {
14
+ template.parentElement.insertBefore(row, template);
15
+ };
16
+ /** The row template: the one the page author wrote inside this widget. */
17
+ function templateIn(scope) {
18
+ const template = scope.querySelector("template");
19
+ if (!template) {
20
+ console.error(`lb-rows: <${scope.localName}> has no <template> to clone`);
21
+ return null;
22
+ }
23
+ if (!template.getAttribute(ATTR_KEY)) {
24
+ console.error(`lb-rows: the <template> in <${scope.localName}> has no ${ATTR_KEY} ` +
25
+ `naming the column that identifies a row`);
26
+ return null;
27
+ }
28
+ return template;
29
+ }
30
+ /**
31
+ * The rows already showing, by the key value each carries.
32
+ *
33
+ * `lb-key` names the key column on the template and carries the key value on
34
+ * a live row. One name in two positions, and never ambiguous, because a
35
+ * template is not a row.
36
+ */
37
+ function showing(scope, template) {
38
+ const rows = new Map();
39
+ for (const el of scope.querySelectorAll(`[${ATTR_KEY}]`)) {
40
+ if (el !== template)
41
+ rows.set(el.getAttribute(ATTR_KEY), el);
42
+ }
43
+ return rows;
44
+ }
45
+ /**
46
+ * Land a projection in a widget's subtree.
47
+ *
48
+ * `rows` is the whole set, so it decides membership and order: every row is
49
+ * placed in the order given, and a row whose key did not arrive is gone.
50
+ * `patch` disturbs only what it names — a row it did not mention keeps its
51
+ * contents and its position.
52
+ */
53
+ export function applyRows(scope, result, place = before) {
54
+ const template = templateIn(scope);
55
+ if (!template)
56
+ return;
57
+ const keyCell = template.getAttribute(ATTR_KEY);
58
+ const rows = showing(scope, template);
59
+ const upsert = (tuple) => {
60
+ const key = tuple[keyCell];
61
+ if (key === undefined) {
62
+ console.error(`lb-rows: a tuple for <${scope.localName}> has no '${keyCell}' cell`);
63
+ return null;
64
+ }
65
+ let row = rows.get(key);
66
+ const fresh = row === undefined;
67
+ if (!row) {
68
+ row = template.content.firstElementChild.cloneNode(true);
69
+ row.setAttribute(ATTR_KEY, key);
70
+ rows.set(key, row);
71
+ }
72
+ // Fill before insertion. The attributes are already there when the row
73
+ // upgrades, which is the same thing that makes hydration and refresh one
74
+ // operation everywhere else.
75
+ applyTuple(row, tuple);
76
+ if (fresh || result.op === "rows")
77
+ place(row, tuple, template);
78
+ return key;
79
+ };
80
+ if (result.op === "rows") {
81
+ const arrived = new Set();
82
+ for (const tuple of result.rows) {
83
+ const key = upsert(tuple);
84
+ if (key !== null)
85
+ arrived.add(key);
86
+ }
87
+ for (const [key, row] of rows)
88
+ if (!arrived.has(key))
89
+ row.remove();
90
+ }
91
+ else {
92
+ for (const tuple of result.rows ?? [])
93
+ upsert(tuple);
94
+ for (const key of result.drop ?? [])
95
+ rows.get(key)?.remove();
96
+ }
97
+ // How many rows are showing, counted from the DOM rather than from either
98
+ // branch above, so a set and a patch report the same fact the same way.
99
+ //
100
+ // It is stamped here because only this function knows the count: it is the
101
+ // one conditional a page cannot be sent, since the server answers with rows
102
+ // and says nothing about how many survived reconciliation. A page says what
103
+ // an empty list looks like in a stylesheet, and no list widget carries code
104
+ // for it. See docs/guide.md, "Conditional rendering".
105
+ scope.setAttribute(ATTR_ROW_COUNT, String(showing(scope, template).size));
106
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The Express adapter — the standard hand-off from an Express router to a
3
+ * Hub built by createHub(). Every Hub application wires this same router,
4
+ * which is why it ships from here rather than being copied into each one.
5
+ *
6
+ * Express itself is a peer dependency: nothing outside this file imports it,
7
+ * so an application that hosts Hub some other way never pays for it.
8
+ *
9
+ * Two endpoints, and no third:
10
+ *
11
+ * GET /hub/data?page=<name> the page's whole query set
12
+ * POST /hub?page=<name> one operation, then its refresh set
13
+ *
14
+ * The page rides on the query string rather than in the body, which is what
15
+ * lets the operation set stay closed.
16
+ */
17
+ import { type Request, type Router } from "express";
18
+ import type { Hub, HubContext } from "./lb-server";
19
+ /**
20
+ * Building the context is the application's job — it is where an
21
+ * authenticated database handle comes from — so it is injected rather than
22
+ * assumed. Whatever established identity has already run by the time this
23
+ * router is reached; where that happens is the application's ordering
24
+ * decision, not Hub's.
25
+ */
26
+ export type ContextFor = (req: Request) => HubContext;
27
+ export declare function hubRoutes(hub: Hub, contextFor: ContextFor): Router;
28
+ //# sourceMappingURL=lb-express.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lb-express.d.ts","sourceRoot":"","sources":["../../server/lb-express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAgB,EAAE,KAAK,OAAO,EAAE,KAAK,MAAM,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,KAAK,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;AAEtD,wBAAgB,SAAS,CAAC,GAAG,EAAE,GAAG,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CA8DlE"}