@loadbare/app 0.5.6 → 0.6.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 (81) hide show
  1. package/README.md +3 -4
  2. package/dist/build/assemble.d.ts +1 -1
  3. package/dist/build/assemble.d.ts.map +1 -1
  4. package/dist/build/assemble.js +80 -7
  5. package/dist/build/cli.d.ts +2 -2
  6. package/dist/build/cli.js +2 -2
  7. package/dist/build/expand.d.ts.map +1 -1
  8. package/dist/build/expand.js +19 -32
  9. package/dist/build/locations.d.ts +3 -3
  10. package/dist/build/locations.d.ts.map +1 -1
  11. package/dist/build/locations.js +15 -3
  12. package/dist/build/origins.d.ts +0 -13
  13. package/dist/build/origins.d.ts.map +1 -1
  14. package/dist/build/origins.js +32 -8
  15. package/dist/build/pages.d.ts +3 -3
  16. package/dist/build/pages.d.ts.map +1 -1
  17. package/dist/build/pages.js +9 -7
  18. package/dist/core/lb-constants.d.ts +15 -13
  19. package/dist/core/lb-constants.d.ts.map +1 -1
  20. package/dist/core/lb-constants.js +103 -53
  21. package/dist/core/lb-types.d.ts +103 -62
  22. package/dist/core/lb-types.d.ts.map +1 -1
  23. package/dist/core/lb-types.js +11 -3
  24. package/dist/hub/lb-apply.d.ts +18 -4
  25. package/dist/hub/lb-apply.d.ts.map +1 -1
  26. package/dist/hub/lb-apply.js +184 -34
  27. package/dist/hub/lb-hub.browser.d.ts +1 -1
  28. package/dist/hub/lb-hub.browser.d.ts.map +1 -1
  29. package/dist/hub/lb-hub.browser.js +131 -84
  30. package/dist/server/lb-express.d.ts +7 -4
  31. package/dist/server/lb-express.d.ts.map +1 -1
  32. package/dist/server/lb-express.js +45 -33
  33. package/dist/server/lb-server.d.ts +67 -45
  34. package/dist/server/lb-server.d.ts.map +1 -1
  35. package/dist/server/lb-server.js +55 -18
  36. package/dist/tests/assemble.test.js +154 -2
  37. package/dist/tests/expand.test.js +6 -3
  38. package/dist/tests/helpers/hub.d.ts +73 -0
  39. package/dist/tests/helpers/hub.d.ts.map +1 -0
  40. package/dist/tests/helpers/hub.js +151 -0
  41. package/dist/tests/lb-apply.test.js +86 -62
  42. package/dist/tests/lb-express.test.d.ts +1 -1
  43. package/dist/tests/lb-express.test.js +43 -38
  44. package/dist/tests/lb-hub.test.d.ts +14 -0
  45. package/dist/tests/lb-hub.test.d.ts.map +1 -0
  46. package/dist/tests/lb-hub.test.js +319 -0
  47. package/dist/tests/{lb-rows.test.d.ts → lb-list.test.d.ts} +1 -1
  48. package/dist/tests/lb-list.test.d.ts.map +1 -0
  49. package/dist/tests/{lb-rows.test.js → lb-list.test.js} +109 -106
  50. package/dist/tests/lb-server.test.js +151 -100
  51. package/dist/tests/origins.test.js +19 -1
  52. package/dist/tests/pages.test.d.ts +1 -1
  53. package/dist/tests/pages.test.js +64 -14
  54. package/docs/TECHREF-1.0.md +1000 -0
  55. package/docs/reference/builder.md +4 -4
  56. package/docs/reference/chrome.md +10 -9
  57. package/docs/reference/custom-elements.md +51 -36
  58. package/docs/reference/data-binding.md +141 -88
  59. package/docs/reference/overview.md +1 -1
  60. package/docs/reference/page-files.md +64 -49
  61. package/docs/reference/server.md +7 -6
  62. package/docs/reference/widgets.md +22 -30
  63. package/docs/roadmap.md +68 -22
  64. package/docs/testing.md +47 -17
  65. package/docs/theory.md +2 -2
  66. package/docs/tutorials/010-pages-and-navigation.md +8 -8
  67. package/docs/tutorials/040-displaying-data.md +9 -9
  68. package/docs/tutorials/050-actions.md +5 -5
  69. package/docs/tutorials/060-custom-element-code.md +1 -1
  70. package/docs/tutorials/065-conditional-rendering.md +4 -4
  71. package/docs/tutorials/070-displaying-a-list.md +24 -47
  72. package/docs/tutorials/072-inserting-into-a-list.md +17 -17
  73. package/docs/tutorials/074-deleting-from-a-list.md +15 -17
  74. package/docs/tutorials/076-updating-a-list-item.md +20 -22
  75. package/docs/tutorials/080-widget-requests.md +27 -23
  76. package/docs/tutorials/090-using-widget-libraries.md +1 -1
  77. package/package.json +1 -2
  78. package/dist/hub/lb-rows.d.ts +0 -18
  79. package/dist/hub/lb-rows.d.ts.map +0 -1
  80. package/dist/hub/lb-rows.js +0 -106
  81. package/dist/tests/lb-rows.test.d.ts.map +0 -1
@@ -6,70 +6,82 @@
6
6
  * Express itself is a peer dependency: nothing outside this file imports it,
7
7
  * so an application that hosts Hub some other way never pays for it.
8
8
  *
9
- * Two endpoints, and no third:
9
+ * One endpoint, and no second:
10
10
  *
11
- * GET /hub/data?page=<name> the page's whole query set
12
- * POST /hub?page=<name> one operation, then its refresh set
11
+ * POST /lb?page=<name> an empty body asks for the page's whole query
12
+ * set; a body carrying an action runs that action
13
+ * and answers with its refresh set
13
14
  *
14
15
  * The page rides on the query string rather than in the body, which is what
15
- * lets the operation set stay closed.
16
+ * lets the operation set stay closed. Every hub call is a POST, so an
17
+ * application's own GET routes never collide with this one and no page name
18
+ * is reserved.
16
19
  */
17
20
  import express from "express";
18
- import { LB_DATA_ENDPOINT, LB_REQUEST_ENDPOINT } from "../core/lb-constants.js";
21
+ import { ACTION_CELL_CHANGE, ACTION_ROW_DELETE, ACTION_ROW_INSERT, ACTION_ROW_UPDATE, LB_ENDPOINT, } from "../core/lb-constants.js";
22
+ import { isOperation } from "../core/lb-types.js";
19
23
  export function hubRoutes(hub, contextFor) {
20
24
  const router = express.Router();
21
25
  // Mounted here rather than on the app so that Hub's need for a parsed
22
26
  // body is stated where the routes that need it are.
23
27
  router.use(express.json());
24
- router.get(LB_DATA_ENDPOINT, async (req, res) => {
28
+ router.post(LB_ENDPOINT, async (req, res) => {
25
29
  const page = String(req.query.page ?? "");
26
- res.json(await hub.dataForPage(page, contextFor(req)));
27
- });
28
- router.post(LB_REQUEST_ENDPOINT, async (req, res) => {
29
- const page = String(req.query.page ?? "");
30
- // The body is unknown until op says which shape it is, so it is read
31
- // once through this loose view for the case that does not match one.
32
- const body = req.body;
33
30
  const request = req.body;
34
31
  const ctx = contextFor(req);
32
+ // No body at all is the page load: the browser is asking for this
33
+ // page's whole query set, which is every request it makes that names no
34
+ // action. A body that carries something but not an action is malformed.
35
+ if (request === undefined || Object.keys(request).length === 0) {
36
+ res.json(await hub.dataForPage(page, ctx));
37
+ return;
38
+ }
39
+ if (!request.action) {
40
+ console.warn(`loadbare: request without an action`);
41
+ res.status(400).json({});
42
+ return;
43
+ }
35
44
  // A thrown error is how application code (a query, an action, a CRUD
36
45
  // op) says "this request is invalid" or hits an unexpected failure —
37
46
  // it resolves to a real response rather than an unhandled rejection, so
38
47
  // the browser side has something to catch.
39
48
  try {
40
- switch (request?.op) {
41
- case "action": {
42
- const { name, query, key, cell, value } = request;
43
- res.json(await hub.runAction(page, name, { query, key, cell, value }, ctx));
44
- return;
45
- }
46
- case "cell-change": {
47
- const { query, key, cell, value } = request;
48
- res.json(await hub.runCellChange(page, { query, key, cell, value }, ctx));
49
+ // The reserved prefix is the whole of the discriminant: a name so
50
+ // prefixed is one of the four operations, and anything else is a name
51
+ // the page declared. Nothing else tells them apart, here or anywhere.
52
+ if (!isOperation(request)) {
53
+ const { action, list, row, key, cell, value } = request;
54
+ res.json(await hub.runAction(page, action, { list, row, key, cell, value }, ctx));
55
+ return;
56
+ }
57
+ switch (request.action) {
58
+ case ACTION_CELL_CHANGE: {
59
+ const { list, key, cell, value } = request;
60
+ res.json(await hub.runCellChange(page, { list, key, cell, value }, ctx));
49
61
  return;
50
62
  }
51
- case "tuple-delete": {
52
- const { query, key } = request;
53
- res.json(await hub.runTupleDelete(page, { query, key }, ctx));
63
+ case ACTION_ROW_DELETE: {
64
+ const { list, key } = request;
65
+ res.json(await hub.runRowDelete(page, { list, key }, ctx));
54
66
  return;
55
67
  }
56
- case "tuple-insert": {
57
- const { query, values } = request;
58
- res.json(await hub.runTupleInsert(page, { query, values }, ctx));
68
+ case ACTION_ROW_INSERT: {
69
+ const { list, values } = request;
70
+ res.json(await hub.runRowInsert(page, { list, values }, ctx));
59
71
  return;
60
72
  }
61
- case "tuple-update": {
62
- const { query, key, values } = request;
63
- res.json(await hub.runTupleUpdate(page, { query, key, values }, ctx));
73
+ case ACTION_ROW_UPDATE: {
74
+ const { list, key, values } = request;
75
+ res.json(await hub.runRowUpdate(page, { list, key, values }, ctx));
64
76
  return;
65
77
  }
66
78
  default:
67
- console.warn(`loadbare: operation '${body?.op}' not implemented`);
79
+ console.warn(`loadbare: operation '${request.action}' not implemented`);
68
80
  res.status(400).json({});
69
81
  }
70
82
  }
71
83
  catch (err) {
72
- console.error(`loadbare: operation '${request?.op}' threw`, err);
84
+ console.error(`loadbare: '${request.action}' threw`, err);
73
85
  res.status(500).json({});
74
86
  }
75
87
  });
@@ -1,4 +1,4 @@
1
- import type { Projection, QueryResult, HubData, HubResult } from "../core/lb-types";
1
+ import type { Patch, Row, HubData, HubResult } from "../core/lb-types";
2
2
  /**
3
3
  * Whatever the application hands the engine for the duration of one request.
4
4
  *
@@ -20,24 +20,40 @@ export interface HubContext {
20
20
  /**
21
21
  * A declared query: the request context in, one result out.
22
22
  *
23
- * A plain object is one tuple and is the common case. A query that returns
24
- * many tuples says so with `rows`, below.
23
+ * Cardinality is a property of the name rather than of any one answer, so it
24
+ * is declared here and never inferred from what comes back. One name answers
25
+ * with one shape, always. A page that wants the roster once as a single row
26
+ * and once as a set declares two queries.
27
+ *
28
+ * Write one with `row()` or `list()` below; nothing else builds one.
25
29
  */
26
- export type Query = (ctx: HubContext) => HubResult | Promise<HubResult>;
30
+ export interface Query {
31
+ readonly kind: "row" | "list";
32
+ readonly run: (ctx: HubContext) => HubResult | Promise<HubResult>;
33
+ }
34
+ /** A query that answers with one row. */
35
+ export declare function row(run: (ctx: HubContext) => Row | Promise<Row>): Query;
27
36
  /**
28
- * The entire set, and therefore also the order. A list widget reconciles to
29
- * exactly this: a row whose key is not here is gone.
37
+ * A query that answers with the entire set, and therefore also the order. A
38
+ * list reconciles to exactly this: a row whose key is not here is gone.
39
+ *
40
+ * There is no wrapper around the array, because the declaration already said
41
+ * this name answers with rows.
30
42
  */
31
- export declare function rows(list: QueryResult[]): Projection;
43
+ export declare function list(run: (ctx: HubContext) => Row[] | Promise<Row[]>): Query;
32
44
  /**
33
- * Only what changed. Rows named here arrive or are updated, keys in `drop`
34
- * are gone, and everything unnamed is left alone — its contents, and its
35
- * place in whatever order the widget is keeping.
45
+ * Only what changed, returned from a `crud` run rather than from a query.
46
+ * Rows named here arrive or are updated, keys in `drop` are gone, and
47
+ * everything unnamed is left alone — its contents, and its place in whatever
48
+ * order the widget is keeping.
49
+ *
50
+ * `Array.isArray` is what tells a patch from a whole set, so a patch needs no
51
+ * marker of its own and no column name is reserved to carry one.
36
52
  */
37
53
  export declare function patch(change: {
38
- rows?: QueryResult[];
54
+ rows?: Row[];
39
55
  drop?: string[];
40
- }): Projection;
56
+ }): Patch;
41
57
  /** The shape of a `<name>.queries.ts` module. */
42
58
  export type Queries = Record<string, Query>;
43
59
  /**
@@ -51,7 +67,8 @@ export type Queries = Record<string, Query>;
51
67
  * argument list.
52
68
  */
53
69
  export interface Where {
54
- query?: string;
70
+ list?: string;
71
+ row?: string;
55
72
  key?: string;
56
73
  cell?: string;
57
74
  value?: string;
@@ -82,87 +99,92 @@ export type CellChangeOp = CrudOp<{
82
99
  cell: string;
83
100
  value: string;
84
101
  }>;
85
- export type TupleDeleteOp = CrudOp<{
102
+ export type RowDeleteOp = CrudOp<{
86
103
  key: string;
87
104
  }>;
88
- export type TupleInsertOp = CrudOp<{
105
+ export type RowInsertOp = CrudOp<{
89
106
  values: Record<string, string>;
90
107
  }>;
91
- export type TupleUpdateOp = CrudOp<{
108
+ export type RowUpdateOp = CrudOp<{
92
109
  key: string;
93
110
  values: Record<string, string>;
94
111
  }>;
95
112
  /**
96
- * The CRUD operations declared for one query, keyed by query name in
97
- * `Hooks.crud`. A query with no entry here permits none of them — the
98
- * wire cannot reach anything the page has not published, exactly as for a
99
- * named action.
113
+ * The CRUD operations declared for one list, keyed by its name in
114
+ * `Requests.crud`. A name with no entry here permits none of them — the wire
115
+ * cannot reach anything the page has not published, exactly as for a named
116
+ * action.
117
+ *
118
+ * Every key here is the reserved `lb-action` value with the prefix stripped
119
+ * and the rest camel-cased, so the attribute, the wire field and this key
120
+ * are one vocabulary. All four are list operations: each needs a key, and a
121
+ * key exists only on a live row inside a list.
100
122
  */
101
123
  export interface Crud {
102
124
  cellChange?: CellChangeOp;
103
- tupleDelete?: TupleDeleteOp;
104
- tupleInsert?: TupleInsertOp;
105
- tupleUpdate?: TupleUpdateOp;
125
+ rowDelete?: RowDeleteOp;
126
+ rowInsert?: RowInsertOp;
127
+ rowUpdate?: RowUpdateOp;
106
128
  }
107
129
  /**
108
- * The shape of a `<name>.hooks.ts` module.
130
+ * The shape of a `<name>.requests.ts` module.
109
131
  *
110
- * `beforeGet` runs on the page's data GET, before any query. It declares no
111
- * refresh set: the page GET runs the whole query set afterward, so whatever
112
- * the hook changed is already in the response.
132
+ * `onPageEnter` runs once when the page is entered, before any query. It
133
+ * declares no refresh set: entering the page runs the whole query set
134
+ * afterward, so whatever the hook changed is already in the response.
113
135
  *
114
136
  * `actions` names what this page may be asked to do. A name the page did not
115
137
  * declare is refused, so the wire cannot reach anything the page has not
116
138
  * published.
117
139
  *
118
- * `crud` is the same rule for the typed CRUD operations, keyed by the query
140
+ * `crud` is the same rule for the typed CRUD operations, keyed by the list
119
141
  * they operate on rather than by a declared name — there is nothing to name,
120
142
  * since the row's own binding says what it is.
121
143
  */
122
- export interface Hooks {
123
- beforeGet?: (ctx: HubContext) => void | Promise<void>;
144
+ export interface Requests {
145
+ onPageEnter?: (ctx: HubContext) => void | Promise<void>;
124
146
  actions?: Record<string, Action>;
125
147
  crud?: Record<string, Crud>;
126
148
  }
127
- /** A page is four files sharing a basename; two of them are these. */
149
+ /** A page is three files sharing a basename; two of them are these. */
128
150
  export interface Page {
129
151
  queries: Queries;
130
- hooks: Hooks;
152
+ requests: Requests;
131
153
  }
132
154
  /**
133
155
  * The page registry. The builder populates this automatically from every
134
- * `.hooks.ts`/`.queries.ts` pair it discovers — see docs/reference/builder.md,
156
+ * `.requests.ts`/`.queries.ts` pair it discovers — see docs/reference/builder.md,
135
157
  * "Generating the server-side page registry".
136
158
  */
137
159
  export type Pages = Record<string, Page>;
138
160
  export interface Hub {
139
- /** A page GET: run the page's beforeGet hook, then all of its queries. */
161
+ /** Entering a page: run its onPageEnter hook, then all of its queries. */
140
162
  dataForPage(page: string, ctx: HubContext): Promise<HubData>;
141
163
  /**
142
164
  * An action: run what the page declared under that name, then the refresh
143
165
  * set declared with it.
144
166
  */
145
167
  runAction(page: string, name: string, where: Where, ctx: HubContext): Promise<HubData>;
146
- /** Edit one cell: run the query's declared `cellChange`, then its refresh set. */
168
+ /** Edit one cell: run the list's declared `cellChange`, then its refresh set. */
147
169
  runCellChange(page: string, where: {
148
- query: string;
170
+ list: string;
149
171
  key: string;
150
172
  cell: string;
151
173
  value: string;
152
174
  }, ctx: HubContext): Promise<HubData>;
153
- /** Drop one tuple: run the query's declared `tupleDelete`, then its refresh set. */
154
- runTupleDelete(page: string, where: {
155
- query: string;
175
+ /** Drop one row: run the list's declared `rowDelete`, then its refresh set. */
176
+ runRowDelete(page: string, where: {
177
+ list: string;
156
178
  key: string;
157
179
  }, ctx: HubContext): Promise<HubData>;
158
- /** Add one tuple: run the query's declared `tupleInsert`, then its refresh set. */
159
- runTupleInsert(page: string, where: {
160
- query: string;
180
+ /** Add one row: run the list's declared `rowInsert`, then its refresh set. */
181
+ runRowInsert(page: string, where: {
182
+ list: string;
161
183
  values: Record<string, string>;
162
184
  }, ctx: HubContext): Promise<HubData>;
163
- /** Edit several cells at once: run the query's declared `tupleUpdate`, then its refresh set. */
164
- runTupleUpdate(page: string, where: {
165
- query: string;
185
+ /** Edit several cells at once: run the list's declared `rowUpdate`, then its refresh set. */
186
+ runRowUpdate(page: string, where: {
187
+ list: string;
166
188
  key: string;
167
189
  values: Record<string, string>;
168
190
  }, ctx: HubContext): Promise<HubData>;
@@ -1 +1 @@
1
- {"version":3,"file":"lb-server.d.ts","sourceRoot":"","sources":["../../server/lb-server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"lb-server.d.ts","sourceRoot":"","sources":["../../server/lb-server.ts"],"names":[],"mappings":"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"}
@@ -4,26 +4,54 @@
4
4
  // vocabulary the browser writes, this one is the vocabulary the application
5
5
  // writes. The engine below is the whole of Hub on the server. It ships no
6
6
  // HTTP server, no router, and no data layer.
7
+ import { LB_RESERVED_PREFIX } from "../core/lb-constants";
8
+ /** A query that answers with one row. */
9
+ export function row(run) {
10
+ return { kind: "row", run };
11
+ }
7
12
  /**
8
- * The entire set, and therefore also the order. A list widget reconciles to
9
- * exactly this: a row whose key is not here is gone.
13
+ * A query that answers with the entire set, and therefore also the order. A
14
+ * list reconciles to exactly this: a row whose key is not here is gone.
15
+ *
16
+ * There is no wrapper around the array, because the declaration already said
17
+ * this name answers with rows.
10
18
  */
11
- export function rows(list) {
12
- return { op: "rows", rows: list };
19
+ export function list(run) {
20
+ return { kind: "list", run };
13
21
  }
14
22
  /**
15
- * Only what changed. Rows named here arrive or are updated, keys in `drop`
16
- * are gone, and everything unnamed is left alone — its contents, and its
17
- * place in whatever order the widget is keeping.
23
+ * Only what changed, returned from a `crud` run rather than from a query.
24
+ * Rows named here arrive or are updated, keys in `drop` are gone, and
25
+ * everything unnamed is left alone — its contents, and its place in whatever
26
+ * order the widget is keeping.
27
+ *
28
+ * `Array.isArray` is what tells a patch from a whole set, so a patch needs no
29
+ * marker of its own and no column name is reserved to carry one.
18
30
  */
19
31
  export function patch(change) {
20
- return { op: "patch", ...change };
32
+ return { ...change };
21
33
  }
22
34
  /**
23
35
  * Build the engine over a set of pages. The pages are fixed at startup; the
24
36
  * context is not, and arrives with each call.
25
37
  */
26
38
  export function createHub(pages) {
39
+ // Names beginning with the reserved prefix are Loadbare's — the hub's
40
+ // own query, the CRUD operations — so an application cannot declare one.
41
+ // Refused at startup, because a name is a fact about the page and not
42
+ // about any request.
43
+ for (const [page, entry] of Object.entries(pages)) {
44
+ const declared = [
45
+ ...Object.keys(entry.queries),
46
+ ...Object.keys(entry.requests.actions ?? {}),
47
+ ];
48
+ for (const name of declared) {
49
+ if (name.startsWith(LB_RESERVED_PREFIX)) {
50
+ throw new Error(`loadbare: page '${page}' declares '${name}', but names ` +
51
+ `beginning with '${LB_RESERVED_PREFIX}' are reserved`);
52
+ }
53
+ }
54
+ }
27
55
  async function run(page, names, ctx) {
28
56
  const data = {};
29
57
  for (const name of names) {
@@ -32,7 +60,16 @@ export function createHub(pages) {
32
60
  console.warn(`loadbare: page '${page}' declares no query '${name}'`);
33
61
  continue;
34
62
  }
35
- data[name] = await query(ctx);
63
+ const result = await query.run(ctx);
64
+ // Cardinality is declared, so an answer that disagrees is a mistake in
65
+ // the query rather than a case to handle. Refused here, because a page
66
+ // is better off missing one name than showing the wrong shape for it.
67
+ if (Array.isArray(result) !== (query.kind === "list")) {
68
+ console.warn(`loadbare: query '${name}' is declared ${query.kind} but answered ` +
69
+ `with ${Array.isArray(result) ? "rows" : "one row"}`);
70
+ continue;
71
+ }
72
+ data[name] = result;
36
73
  }
37
74
  return data;
38
75
  }
@@ -57,23 +94,23 @@ export function createHub(pages) {
57
94
  console.warn(`loadbare: no page '${page}'`);
58
95
  return {};
59
96
  }
60
- await entry.hooks.beforeGet?.(ctx);
97
+ await entry.requests.onPageEnter?.(ctx);
61
98
  return run(page, Object.keys(entry.queries), ctx);
62
99
  },
63
100
  runAction(page, name, where, ctx) {
64
- return settle(page, pages[page]?.hooks.actions?.[name], where, ctx, `loadbare: page '${page}' declares no action '${name}'`);
101
+ return settle(page, pages[page]?.requests.actions?.[name], where, ctx, `loadbare: page '${page}' declares no action '${name}'`);
65
102
  },
66
103
  runCellChange(page, where, ctx) {
67
- return settle(page, pages[page]?.hooks.crud?.[where.query]?.cellChange, { key: where.key, cell: where.cell, value: where.value }, ctx, `loadbare: page '${page}' declares no cell-change for query '${where.query}'`);
104
+ return settle(page, pages[page]?.requests.crud?.[where.list]?.cellChange, { key: where.key, cell: where.cell, value: where.value }, ctx, `loadbare: page '${page}' declares no cellChange for '${where.list}'`);
68
105
  },
69
- runTupleDelete(page, where, ctx) {
70
- return settle(page, pages[page]?.hooks.crud?.[where.query]?.tupleDelete, { key: where.key }, ctx, `loadbare: page '${page}' declares no tuple-delete for query '${where.query}'`);
106
+ runRowDelete(page, where, ctx) {
107
+ return settle(page, pages[page]?.requests.crud?.[where.list]?.rowDelete, { key: where.key }, ctx, `loadbare: page '${page}' declares no rowDelete for '${where.list}'`);
71
108
  },
72
- runTupleInsert(page, where, ctx) {
73
- return settle(page, pages[page]?.hooks.crud?.[where.query]?.tupleInsert, { values: where.values }, ctx, `loadbare: page '${page}' declares no tuple-insert for query '${where.query}'`);
109
+ runRowInsert(page, where, ctx) {
110
+ return settle(page, pages[page]?.requests.crud?.[where.list]?.rowInsert, { values: where.values }, ctx, `loadbare: page '${page}' declares no rowInsert for '${where.list}'`);
74
111
  },
75
- runTupleUpdate(page, where, ctx) {
76
- return settle(page, pages[page]?.hooks.crud?.[where.query]?.tupleUpdate, { key: where.key, values: where.values }, ctx, `loadbare: page '${page}' declares no tuple-update for query '${where.query}'`);
112
+ runRowUpdate(page, where, ctx) {
113
+ return settle(page, pages[page]?.requests.crud?.[where.list]?.rowUpdate, { key: where.key, values: where.values }, ctx, `loadbare: page '${page}' declares no rowUpdate for '${where.list}'`);
77
114
  },
78
115
  };
79
116
  }
@@ -43,16 +43,168 @@ describe("assemble", () => {
43
43
  await rm(src, { recursive: true, force: true });
44
44
  }
45
45
  });
46
- it("still throws when the chrome uses an undefined widget", async () => {
46
+ it("reports a chrome widget with no definition as a used tag, for elements.ts to refuse", async () => {
47
+ // Expansion leaves an undefined tag alone; whether it can do anything is
48
+ // one rule in one place — see build/elements.ts.
47
49
  const src = await tree({
48
50
  "chrome.html": `<!doctype html><body><lb-nope></lb-nope></body>`,
49
51
  });
50
52
  try {
51
53
  const locations = await resolveLocations({ src, out: `${src}/dist` });
52
- await assert.rejects(async () => assemble({ ...locations, scans: await scansFor(locations) }), /<lb-nope> has no definition/);
54
+ const { html, usedTags } = await assemble({
55
+ ...locations,
56
+ scans: await scansFor(locations),
57
+ });
58
+ assert.match(html, /<lb-nope><\/lb-nope>/);
59
+ assert.ok(usedTags.has("lb-nope"));
60
+ }
61
+ finally {
62
+ await rm(src, { recursive: true, force: true });
63
+ }
64
+ });
65
+ });
66
+ describe("the unknown-page dialog", () => {
67
+ it("accepts a chrome that declares none", async () => {
68
+ const src = await tree({
69
+ "chrome.html": `<!doctype html><body><lb-hub><main></main></lb-hub></body>`,
70
+ });
71
+ try {
72
+ const locations = await resolveLocations({ src, out: `${src}/dist` });
73
+ const { html } = await assemble({
74
+ ...locations,
75
+ scans: await scansFor(locations),
76
+ });
77
+ assert.doesNotMatch(html, /lb-unknown-page/);
78
+ }
79
+ finally {
80
+ await rm(src, { recursive: true, force: true });
81
+ }
82
+ });
83
+ it("refuses the attribute on anything but a <dialog>", async () => {
84
+ const src = await tree({
85
+ "chrome.html": `<!doctype html><body><lb-hub><main></main><div lb-unknown-page></div></lb-hub></body>`,
86
+ });
87
+ try {
88
+ const locations = await resolveLocations({ src, out: `${src}/dist` });
89
+ const scans = await scansFor(locations);
90
+ await assert.rejects(() => assemble({ ...locations, scans }), /lb-unknown-page on <div>, must be a <dialog>/);
91
+ }
92
+ finally {
93
+ await rm(src, { recursive: true, force: true });
94
+ }
95
+ });
96
+ it("refuses a dialog outside the hub, where the hub cannot reach it", async () => {
97
+ const src = await tree({
98
+ "chrome.html": `<!doctype html><body><dialog lb-unknown-page></dialog><lb-hub><main></main></lb-hub></body>`,
99
+ });
100
+ try {
101
+ const locations = await resolveLocations({ src, out: `${src}/dist` });
102
+ const scans = await scansFor(locations);
103
+ await assert.rejects(() => assemble({ ...locations, scans }), /lb-unknown-page outside <lb-hub>/);
53
104
  }
54
105
  finally {
55
106
  await rm(src, { recursive: true, force: true });
56
107
  }
57
108
  });
58
109
  });
110
+ /**
111
+ * The binding vocabulary, checked against the finished document rather than
112
+ * discovered one console error at a time in a browser — see walkBinding in
113
+ * build/assemble.ts. Every case here is a build that must not produce output.
114
+ */
115
+ describe("the binding check", () => {
116
+ const CHROME = `<!doctype html><body><lb-hub><main></main></lb-hub></body>`;
117
+ /** Assemble a chrome and one page, and hand back whatever it did. */
118
+ async function build(files) {
119
+ const src = await tree({ "chrome.html": CHROME, ...files });
120
+ try {
121
+ const locations = await resolveLocations({ src, out: `${src}/dist` });
122
+ const scans = await scansFor(locations);
123
+ return await assemble({ ...locations, scans });
124
+ }
125
+ finally {
126
+ await rm(src, { recursive: true, force: true });
127
+ }
128
+ }
129
+ async function refuses(page, pattern) {
130
+ await assert.rejects(() => build({ "a.page.html": page }), pattern);
131
+ }
132
+ it("refuses both scope attributes on one element", async () => {
133
+ await refuses(`<ul lb-list="roster" lb-row="prefs"></ul>`, /carries both lb-list and lb-row/);
134
+ });
135
+ it("refuses a cell with no scope around it", async () => {
136
+ await refuses(`<p><span lb-cell="name"></span></p>`, /carries lb-cell="name" with no lb-list or lb-row scope/);
137
+ });
138
+ it("refuses a cell whose only scope is inside a template with none around it", async () => {
139
+ // A template's children are walked in the scope enclosing the template,
140
+ // because a row template's clones land in the list around it.
141
+ await refuses(`<template><span lb-cell="name"></span></template>`, /carries lb-cell="name" with no lb-list or lb-row scope/);
142
+ });
143
+ it("refuses lb-key on anything but a template", async () => {
144
+ await refuses(`<ul lb-list="roster"><li lb-key="id"></li></ul>`, /<li> carries lb-key; it names the column/);
145
+ });
146
+ it("refuses lb-key on a template outside any list scope", async () => {
147
+ await refuses(`<div lb-row="prefs"><template lb-key="id"><p></p></template></div>`, /carries lb-key outside any lb-list scope/);
148
+ });
149
+ it("refuses a template carrying lb-key with no scope at all", async () => {
150
+ await refuses(`<template lb-key="id"><p></p></template>`, /carries lb-key outside any lb-list scope/);
151
+ });
152
+ it("reports every problem in one build, not the first", async () => {
153
+ await refuses(`<span lb-cell="name"></span><ul lb-list="a" lb-row="b"></ul>`, /no lb-list or lb-row scope[\s\S]*carries both lb-list and lb-row/);
154
+ });
155
+ it("names the file the problem is in", async () => {
156
+ await refuses(`<span lb-cell="name"></span>`, /a\.page\.html' binding:/);
157
+ });
158
+ it("checks the chrome as well as the pages", async () => {
159
+ const src = await tree({
160
+ "chrome.html": `<!doctype html><body><lb-hub><main></main><span lb-cell="name"></span></lb-hub></body>`,
161
+ });
162
+ try {
163
+ const locations = await resolveLocations({ src, out: `${src}/dist` });
164
+ const scans = await scansFor(locations);
165
+ await assert.rejects(() => assemble({ ...locations, scans }), /chrome\.html' binding:/);
166
+ }
167
+ finally {
168
+ await rm(src, { recursive: true, force: true });
169
+ }
170
+ });
171
+ it("accepts a cell in either kind of scope", async () => {
172
+ const { html } = await build({
173
+ "a.page.html": `
174
+ <div lb-row="prefs"><span lb-cell="theme"></span></div>
175
+ <ul lb-list="roster">
176
+ <template lb-key="id"><li lb-cell="name"></li></template>
177
+ </ul>`,
178
+ });
179
+ assert.match(html, /lb-cell="theme"/);
180
+ assert.match(html, /lb-cell="name"/);
181
+ });
182
+ it("accepts a nested scope, which begins a scope of its own", async () => {
183
+ const { html } = await build({
184
+ "a.page.html": `
185
+ <div lb-row="prefs">
186
+ <ul lb-list="roster">
187
+ <template lb-key="id"><li lb-cell="name"></li></template>
188
+ </ul>
189
+ </div>`,
190
+ });
191
+ assert.match(html, /lb-cell="name"/);
192
+ });
193
+ it("scopes a template's content by the scope the template itself carries", async () => {
194
+ // A template with no scope of its own is walked in the scope enclosing
195
+ // it, which is what makes a row template's cells legal. One that carries
196
+ // a scope establishes it for its own content instead. That is today's
197
+ // behavior, and this is the test that flips if it changes.
198
+ const { html } = await build({
199
+ "a.page.html": `<template lb-row="prefs"><span lb-cell="theme"></span></template>`,
200
+ });
201
+ assert.match(html, /lb-cell="theme"/);
202
+ });
203
+ it("accepts a list scope with no row template, which displays nothing", async () => {
204
+ // An insert form names the list it adds to and has no rows of its own.
205
+ const { html } = await build({
206
+ "a.page.html": `<form lb-list="roster" lb-action="lb-row-insert"></form>`,
207
+ });
208
+ assert.match(html, /lb-list="roster"/);
209
+ });
210
+ });
@@ -38,9 +38,6 @@ function defs(entries) {
38
38
  // that must not produce output. Each asserts on the message as well as the
39
39
  // throw: an author reads the message, and it is the whole of the diagnosis.
40
40
  describe("expansion rejects", () => {
41
- it("a hub tag with no definition", () => {
42
- assert.throws(() => expand(`<lb-nope></lb-nope>`, defs({})), /<lb-nope> has no definition/);
43
- });
44
41
  it("a parameter the definition never declared", () => {
45
42
  assert.throws(() => expand(`<lb-thing exp-label="Name"></lb-thing>`, defs({ "lb-thing": `<div></div>` })), /given exp-label, but its definition has no \{\{label\}\}/);
46
43
  });
@@ -226,6 +223,12 @@ describe("placement", () => {
226
223
  const html = expand(`<app-box>hi</app-box>`, defs({}));
227
224
  assert.match(html, /<app-box>hi<\/app-box>/);
228
225
  });
226
+ it("leaves a tag with no definition alone, whatever its name", () => {
227
+ // No name is special, the hub's own tag included. A tag that has neither
228
+ // a definition nor a script is refused once, in build/elements.ts.
229
+ const html = expand(`<lb-nope></lb-nope><lb-hub></lb-hub>`, defs({}));
230
+ assert.match(html, /<lb-nope><\/lb-nope><lb-hub><\/lb-hub>/);
231
+ });
229
232
  });
230
233
  // --- Loading -------------------------------------------------------------
231
234
  describe("loadDefinitions", () => {