@graview/core 0.1.54 → 0.1.56

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 (208) hide show
  1. package/dist/alias-findings.d.ts +40 -0
  2. package/dist/alias-findings.d.ts.map +1 -0
  3. package/dist/alias-findings.js +95 -0
  4. package/dist/alias-findings.js.map +1 -0
  5. package/dist/app.d.ts +13 -0
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js.map +1 -1
  8. package/dist/arranging.d.ts +6 -1
  9. package/dist/arranging.d.ts.map +1 -1
  10. package/dist/arranging.js +21 -3
  11. package/dist/arranging.js.map +1 -1
  12. package/dist/capabilities.js +1 -1
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/catalog.d.ts +32 -0
  15. package/dist/catalog.d.ts.map +1 -1
  16. package/dist/catalog.js +17 -1
  17. package/dist/catalog.js.map +1 -1
  18. package/dist/check.d.ts +2 -0
  19. package/dist/check.d.ts.map +1 -1
  20. package/dist/check.js +2 -0
  21. package/dist/check.js.map +1 -1
  22. package/dist/cli/check/aliases.d.ts +11 -0
  23. package/dist/cli/check/aliases.d.ts.map +1 -0
  24. package/dist/cli/check/aliases.js +17 -0
  25. package/dist/cli/check/aliases.js.map +1 -0
  26. package/dist/cli/check/context.d.ts +2 -0
  27. package/dist/cli/check/context.d.ts.map +1 -1
  28. package/dist/cli/check/context.js.map +1 -1
  29. package/dist/cli/check/index.d.ts +2 -0
  30. package/dist/cli/check/index.d.ts.map +1 -1
  31. package/dist/cli/check/index.js +2 -0
  32. package/dist/cli/check/index.js.map +1 -1
  33. package/dist/cli/check/jobs.d.ts +12 -0
  34. package/dist/cli/check/jobs.d.ts.map +1 -0
  35. package/dist/cli/check/jobs.js +25 -0
  36. package/dist/cli/check/jobs.js.map +1 -0
  37. package/dist/cli/check.d.ts +13 -0
  38. package/dist/cli/check.d.ts.map +1 -1
  39. package/dist/cli/check.js +25 -2
  40. package/dist/cli/check.js.map +1 -1
  41. package/dist/cli/describe.d.ts.map +1 -1
  42. package/dist/cli/describe.js +41 -0
  43. package/dist/cli/describe.js.map +1 -1
  44. package/dist/cli/document-schema.d.ts.map +1 -1
  45. package/dist/cli/document-schema.js +7 -0
  46. package/dist/cli/document-schema.js.map +1 -1
  47. package/dist/compose.d.ts +99 -0
  48. package/dist/compose.d.ts.map +1 -0
  49. package/dist/compose.js +245 -0
  50. package/dist/compose.js.map +1 -0
  51. package/dist/conformance/fixtures.d.ts.map +1 -1
  52. package/dist/conformance/fixtures.js +15 -5
  53. package/dist/conformance/fixtures.js.map +1 -1
  54. package/dist/days.d.ts +14 -1
  55. package/dist/days.d.ts.map +1 -1
  56. package/dist/days.js +17 -2
  57. package/dist/days.js.map +1 -1
  58. package/dist/document/brand-check.d.ts.map +1 -1
  59. package/dist/document/brand-check.js +6 -2
  60. package/dist/document/brand-check.js.map +1 -1
  61. package/dist/document/compile-checked.d.ts.map +1 -1
  62. package/dist/document/compile-checked.js +11 -5
  63. package/dist/document/compile-checked.js.map +1 -1
  64. package/dist/document/compiled.d.ts.map +1 -1
  65. package/dist/document/compiled.js +20 -4
  66. package/dist/document/compiled.js.map +1 -1
  67. package/dist/document/design-findings.d.ts +32 -0
  68. package/dist/document/design-findings.d.ts.map +1 -0
  69. package/dist/document/design-findings.js +593 -0
  70. package/dist/document/design-findings.js.map +1 -0
  71. package/dist/document/edit.d.ts +1 -1
  72. package/dist/document/edit.d.ts.map +1 -1
  73. package/dist/document/edit.js +303 -16
  74. package/dist/document/edit.js.map +1 -1
  75. package/dist/document/field-spec.d.ts.map +1 -1
  76. package/dist/document/field-spec.js +3 -2
  77. package/dist/document/field-spec.js.map +1 -1
  78. package/dist/document/findings.d.ts +8 -2
  79. package/dist/document/findings.d.ts.map +1 -1
  80. package/dist/document/findings.js +4 -3
  81. package/dist/document/findings.js.map +1 -1
  82. package/dist/document/graview-template.d.ts +3 -3
  83. package/dist/document/purpose-spec.d.ts +37 -0
  84. package/dist/document/purpose-spec.d.ts.map +1 -0
  85. package/dist/document/purpose-spec.js +41 -0
  86. package/dist/document/purpose-spec.js.map +1 -0
  87. package/dist/document/schema.d.ts +29 -11
  88. package/dist/document/schema.d.ts.map +1 -1
  89. package/dist/document/schema.js +26 -0
  90. package/dist/document/schema.js.map +1 -1
  91. package/dist/document/template.js +2 -2
  92. package/dist/document/template.js.map +1 -1
  93. package/dist/document/to-document.d.ts.map +1 -1
  94. package/dist/document/to-document.js +5 -1
  95. package/dist/document/to-document.js.map +1 -1
  96. package/dist/document.schema.json +146 -2
  97. package/dist/friction-record.d.ts +52 -0
  98. package/dist/friction-record.d.ts.map +1 -0
  99. package/dist/friction-record.js +36 -0
  100. package/dist/friction-record.js.map +1 -0
  101. package/dist/friction.d.ts +209 -0
  102. package/dist/friction.d.ts.map +1 -0
  103. package/dist/friction.js +302 -0
  104. package/dist/friction.js.map +1 -0
  105. package/dist/import/coerce.d.ts.map +1 -1
  106. package/dist/import/coerce.js +3 -1
  107. package/dist/import/coerce.js.map +1 -1
  108. package/dist/import/rows.d.ts +8 -0
  109. package/dist/import/rows.d.ts.map +1 -1
  110. package/dist/import/rows.js +19 -3
  111. package/dist/import/rows.js.map +1 -1
  112. package/dist/index.d.ts +5 -1
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +5 -1
  115. package/dist/index.js.map +1 -1
  116. package/dist/job-findings.d.ts +39 -0
  117. package/dist/job-findings.d.ts.map +1 -0
  118. package/dist/job-findings.js +171 -0
  119. package/dist/job-findings.js.map +1 -0
  120. package/dist/job-shape.d.ts +57 -0
  121. package/dist/job-shape.d.ts.map +1 -0
  122. package/dist/job-shape.js +5 -0
  123. package/dist/job-shape.js.map +1 -0
  124. package/dist/jobs.d.ts +62 -0
  125. package/dist/jobs.d.ts.map +1 -0
  126. package/dist/jobs.js +112 -0
  127. package/dist/jobs.js.map +1 -0
  128. package/dist/learn.d.ts +151 -0
  129. package/dist/learn.d.ts.map +1 -0
  130. package/dist/learn.js +520 -0
  131. package/dist/learn.js.map +1 -0
  132. package/dist/mutations/arg-shape.d.ts +2 -0
  133. package/dist/mutations/arg-shape.d.ts.map +1 -1
  134. package/dist/mutations/arg-shape.js +2 -2
  135. package/dist/mutations/arg-shape.js.map +1 -1
  136. package/dist/mutations/define-mutation.d.ts.map +1 -1
  137. package/dist/mutations/define-mutation.js +25 -2
  138. package/dist/mutations/define-mutation.js.map +1 -1
  139. package/dist/mutations/derive-edits.d.ts.map +1 -1
  140. package/dist/mutations/derive-edits.js +17 -3
  141. package/dist/mutations/derive-edits.js.map +1 -1
  142. package/dist/mutations/derive-relations.d.ts +8 -3
  143. package/dist/mutations/derive-relations.d.ts.map +1 -1
  144. package/dist/mutations/derive-relations.js +18 -7
  145. package/dist/mutations/derive-relations.js.map +1 -1
  146. package/dist/mutations/form.d.ts +2 -0
  147. package/dist/mutations/form.d.ts.map +1 -1
  148. package/dist/mutations/form.js +1 -1
  149. package/dist/mutations/form.js.map +1 -1
  150. package/dist/mutations/types.d.ts +7 -0
  151. package/dist/mutations/types.d.ts.map +1 -1
  152. package/dist/ops/tidying.d.ts.map +1 -1
  153. package/dist/ops/tidying.js +3 -0
  154. package/dist/ops/tidying.js.map +1 -1
  155. package/dist/schema/choice-words.d.ts +16 -0
  156. package/dist/schema/choice-words.d.ts.map +1 -0
  157. package/dist/schema/choice-words.js +31 -0
  158. package/dist/schema/choice-words.js.map +1 -0
  159. package/dist/schema/define-node.d.ts +2 -1
  160. package/dist/schema/define-node.d.ts.map +1 -1
  161. package/dist/schema/define-node.js +3 -15
  162. package/dist/schema/define-node.js.map +1 -1
  163. package/dist/schema/humanize.d.ts +3 -0
  164. package/dist/schema/humanize.d.ts.map +1 -0
  165. package/dist/schema/humanize.js +18 -0
  166. package/dist/schema/humanize.js.map +1 -0
  167. package/dist/schema/json-schema.d.ts.map +1 -1
  168. package/dist/schema/json-schema.js +2 -1
  169. package/dist/schema/json-schema.js.map +1 -1
  170. package/dist/schema/reading.d.ts.map +1 -1
  171. package/dist/schema/reading.js +10 -2
  172. package/dist/schema/reading.js.map +1 -1
  173. package/dist/schema/types.d.ts +9 -0
  174. package/dist/schema/types.d.ts.map +1 -1
  175. package/dist/search.d.ts.map +1 -1
  176. package/dist/search.js +2 -0
  177. package/dist/search.js.map +1 -1
  178. package/dist/sync/connection.js +1 -1
  179. package/dist/theme/accent.d.ts.map +1 -1
  180. package/dist/theme/accent.js +2 -2
  181. package/dist/theme/accent.js.map +1 -1
  182. package/dist/theme/derive.d.ts +10 -1
  183. package/dist/theme/derive.d.ts.map +1 -1
  184. package/dist/theme/derive.js +1 -1
  185. package/dist/theme/derive.js.map +1 -1
  186. package/dist/theme/ground-check.d.ts +12 -0
  187. package/dist/theme/ground-check.d.ts.map +1 -1
  188. package/dist/theme/ground-check.js +37 -11
  189. package/dist/theme/ground-check.js.map +1 -1
  190. package/dist/theme/ground.d.ts +28 -7
  191. package/dist/theme/ground.d.ts.map +1 -1
  192. package/dist/theme/ground.js +62 -18
  193. package/dist/theme/ground.js.map +1 -1
  194. package/dist/tidying.d.ts +10 -0
  195. package/dist/tidying.d.ts.map +1 -1
  196. package/dist/tidying.js +17 -0
  197. package/dist/tidying.js.map +1 -1
  198. package/dist/version.d.ts +1 -1
  199. package/dist/version.js +1 -1
  200. package/dist/when-typed.d.ts +7 -0
  201. package/dist/when-typed.d.ts.map +1 -1
  202. package/dist/when-typed.js +24 -0
  203. package/dist/when-typed.js.map +1 -1
  204. package/dist/words.d.ts +1 -1
  205. package/dist/words.d.ts.map +1 -1
  206. package/dist/words.js +1 -1
  207. package/dist/words.js.map +1 -1
  208. package/package.json +17 -1
@@ -0,0 +1,593 @@
1
+ import { MOST_STANDING } from "../places.js";
2
+ import { parseExpr } from "./expr/parse.js";
3
+ import { defaultLabelField } from "./compiled.js";
4
+ import { warning } from "./findings.js";
5
+ import { homeOf, viewsOf } from "./views.js";
6
+ import { parseTemplate } from "./template-parse.js";
7
+ /** The jobs a document says, read as loosely as a page reads `purpose`: one without a kind or a question is the job checker's to refuse, not this file's to judge. */
8
+ const jobsSaid = (document) => (document.purpose?.jobs ?? []).filter((job) => typeof job?.name === "string" && typeof job.ask === "string" && typeof job.kind === "string");
9
+ /*
10
+ * HOW MANY PLACES A PERSON SCANS. The bar stands at most `MOST_STANDING`
11
+ * places on its row, Home among them, and folds the rest into "More"
12
+ * (`barPlaces`); the home's gallery draws one card per place, two across on
13
+ * a desk. Read as rows, the bar's row and one "More" list as long again is
14
+ * as much as a person takes in as a choice: twelve. Past it, the places are
15
+ * a list to search, and the ones past it are the ones nobody finds.
16
+ */
17
+ export const MOST_PLACES = MOST_STANDING * 2;
18
+ const lensesOf = (document) => (document.lenses ?? []);
19
+ const titleOf = (lens) => (typeof lens.title === "string" && lens.title.trim().length > 0 ? lens.title : undefined);
20
+ const listed = (lens) => titleOf(lens) !== undefined && lens.hidden !== true;
21
+ const kindsOfLens = (lens) => (typeof lens.on === "string" ? [lens.on] : lens.bindings && typeof lens.bindings === "object" ? Object.keys(lens.bindings) : []);
22
+ const listedWords = (words) => (words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`);
23
+ const quoted = (titles) => listedWords(titles.map((title) => `"${title}"`));
24
+ const pluralOfKind = (document, kind) => document.kinds[kind]?.plural ?? `${document.kinds[kind]?.noun ?? kind}s`;
25
+ /** Every block of a list, those inside a group or a `when` among them. */
26
+ function leaves(blocks) {
27
+ const out = [];
28
+ for (const raw of blocks) {
29
+ if (!raw || typeof raw !== "object")
30
+ continue;
31
+ const block = raw;
32
+ if (Array.isArray(block["group"]))
33
+ out.push(...leaves(block["group"]));
34
+ else if (Array.isArray(block["show"]))
35
+ out.push(...leaves(block["show"]));
36
+ else
37
+ out.push(block);
38
+ }
39
+ return out;
40
+ }
41
+ const parsed = (source) => {
42
+ try {
43
+ return parseExpr(source);
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ };
49
+ /** `sort(X, …)` read as X: the order a list is in is not which records it holds. */
50
+ const unsorted = (e) => (e.t === "call" && e.fn === "sort" && e.args[0] ? unsorted(e.args[0]) : e);
51
+ /** The kind `all('k')` names. */
52
+ const allOf = (e) => (e.t === "call" && e.fn === "all" && e.args.length === 1 && e.args[0].t === "lit" && typeof e.args[0].value === "string" ? e.args[0].value : undefined);
53
+ /** The kinds a list's records are drawn from, those it lists whole, and the fields its filters read. */
54
+ function readsOf(source) {
55
+ const kinds = new Set();
56
+ const filters = new Set();
57
+ const whole = new Set();
58
+ const walk = (e, filtered) => {
59
+ const kind = allOf(e);
60
+ if (kind !== undefined) {
61
+ kinds.add(kind);
62
+ if (!filtered)
63
+ whole.add(kind);
64
+ return;
65
+ }
66
+ switch (e.t) {
67
+ case "where":
68
+ walk(e.set, true);
69
+ identsOf(e.filter).forEach((name) => filters.add(name));
70
+ return;
71
+ case "call":
72
+ // A sort is the records it sorts, whole if they are whole; anything else (a count, out()) is not a list of a kind.
73
+ if (e.fn === "sort" && e.args[0])
74
+ walk(e.args[0], filtered);
75
+ else
76
+ e.args.forEach((arg) => walk(arg, true));
77
+ return;
78
+ case "binary":
79
+ // A join is whole where either side is; a meet or a difference keeps some of its records, never all.
80
+ walk(e.left, filtered || e.op !== "|");
81
+ walk(e.right, filtered || e.op !== "|");
82
+ return;
83
+ default:
84
+ return;
85
+ }
86
+ };
87
+ const e = parsed(source);
88
+ if (e)
89
+ walk(e, false);
90
+ return { kinds, whole, filters };
91
+ }
92
+ /** The bare names an expression reads: `stage`, and `partOf` of `partOf.name`. */
93
+ function identsOf(e) {
94
+ const out = new Set();
95
+ const walk = (x) => {
96
+ switch (x.t) {
97
+ case "ident":
98
+ out.add(x.name);
99
+ return;
100
+ case "member":
101
+ walk(x.object);
102
+ return;
103
+ case "list":
104
+ x.items.forEach(walk);
105
+ return;
106
+ case "unary":
107
+ walk(x.operand);
108
+ return;
109
+ case "binary":
110
+ walk(x.left);
111
+ walk(x.right);
112
+ return;
113
+ case "call":
114
+ x.args.forEach(walk);
115
+ return;
116
+ case "where":
117
+ walk(x.set);
118
+ return;
119
+ default:
120
+ return;
121
+ }
122
+ };
123
+ walk(e);
124
+ return out;
125
+ }
126
+ /** `all('k') where f == 'v'` (sorted or not): one value of one field of one kind. */
127
+ function sliceOf(source) {
128
+ const e = parsed(source);
129
+ if (!e)
130
+ return undefined;
131
+ const set = unsorted(e);
132
+ if (set.t !== "where")
133
+ return undefined;
134
+ const kind = allOf(set.set);
135
+ const f = set.filter;
136
+ if (kind === undefined || f.t !== "binary" || f.op !== "==")
137
+ return undefined;
138
+ const [ident, lit] = f.left.t === "ident" ? [f.left, f.right] : [f.right, f.left];
139
+ if (ident.t !== "ident" || lit.t !== "lit" || typeof lit.value !== "string")
140
+ return undefined;
141
+ return { kind, field: ident.name, value: lit.value };
142
+ }
143
+ const listsOf = (lens) => {
144
+ const blocks = lens.options?.blocks;
145
+ return Array.isArray(blocks) ? leaves(blocks).filter((block) => typeof block["list"] === "string") : [];
146
+ };
147
+ const canonical = (value) => JSON.stringify(value, (_key, v) => (v && typeof v === "object" && !Array.isArray(v) ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a.localeCompare(b))) : v));
148
+ /** What shows a kind's records split whole by a field: a status board by it, else the kind's own list grouped by it. */
149
+ function wholeSplits(document) {
150
+ const out = new Map();
151
+ lensesOf(document).forEach((lens) => {
152
+ const title = titleOf(lens);
153
+ if (lens.name !== "columns" || !title || !listed(lens))
154
+ return;
155
+ for (const [kind, roles] of Object.entries((lens.bindings ?? {}))) {
156
+ const column = roles?.column;
157
+ if (typeof column === "string" && !out.has(`${kind}|${column}`))
158
+ out.set(`${kind}|${column}`, `"${title}"`);
159
+ }
160
+ });
161
+ const lists = document.pages?.lists ?? {};
162
+ for (const [kind, list] of Object.entries(lists)) {
163
+ const by = typeof list.group === "string" ? list.group : list.group?.by;
164
+ if (typeof by === "string" && !out.has(`${kind}|${by}`))
165
+ out.set(`${kind}|${by}`, `the list of ${pluralOfKind(document, kind)}`);
166
+ }
167
+ return out;
168
+ }
169
+ const removeLens = (document, lens) => {
170
+ const title = titleOf(lens);
171
+ const twice = lensesOf(document).filter((one) => titleOf(one)?.toLowerCase() === title.toLowerCase()).length > 1;
172
+ return { op: "remove-lens", title, ...(twice && typeof lens.on === "string" ? { on: lens.on } : {}) };
173
+ };
174
+ /** Places that show what another already shows: a lens bound as another is, or one column of a split shown whole. */
175
+ function repeatedPlaces(document) {
176
+ const findings = [];
177
+ const removed = new Set();
178
+ const lenses = lensesOf(document);
179
+ // The same lens, over the same records, bound and optioned the same: the later one repeats the first.
180
+ const seen = new Map();
181
+ lenses.forEach((lens, index) => {
182
+ if (!listed(lens))
183
+ return;
184
+ const key = canonical({ name: lens.name, on: lens.on, bindings: lens.bindings, options: lens.options });
185
+ const first = seen.get(key);
186
+ if (first === undefined)
187
+ return void seen.set(key, index);
188
+ removed.add(index);
189
+ findings.push(warning("design-repeated-place", `lenses.${index}`, `"${titleOf(lens)}" draws what "${titleOf(lenses[first])}" draws: the same lens over the same records, bound the same way, so a person meets one picture under two names.`, `remove "${titleOf(lens)}"; "${titleOf(lenses[first])}" stays`, [removeLens(document, lens)]));
190
+ });
191
+ // A blocks place whose every list is one value of a field another place shows split whole.
192
+ const splits = wholeSplits(document);
193
+ const slices = new Map();
194
+ lenses.forEach((lens, index) => {
195
+ if (lens.name !== "blocks" || !listed(lens) || removed.has(index))
196
+ return;
197
+ const lists = listsOf(lens);
198
+ const each = lists.map((block) => sliceOf(block["list"]));
199
+ const one = each[0];
200
+ if (!one || each.some((s) => !s || s.kind !== one.kind || s.field !== one.field || s.value !== one.value))
201
+ return;
202
+ const key = `${one.kind}|${one.field}`;
203
+ if (!splits.has(key))
204
+ return;
205
+ slices.set(key, [...(slices.get(key) ?? []), { index, lens, value: one.value }]);
206
+ });
207
+ for (const [key, found] of slices) {
208
+ const [kind, field] = key.split("|");
209
+ const keeper = splits.get(key);
210
+ const titles = found.map((one) => titleOf(one.lens));
211
+ found.forEach((one) => removed.add(one.index));
212
+ findings.push(warning("design-repeated-place", `lenses.${found[0].index}`, `${quoted(titles)} ${titles.length === 1 ? "shows" : "each show"} the ${pluralOfKind(document, kind)} of one ${field} (${found.map((one) => one.value).join(", ")}), which ${keeper} already shows side by side with every other ${field}: the same split, ${titles.length === 1 ? "one column of it again" : "a column at a time"}.`, `remove ${quoted(titles)}; ${keeper} keeps the whole split`, found.map((one) => removeLens(document, one.lens))));
213
+ }
214
+ return { findings, removed };
215
+ }
216
+ /*
217
+ * HOW SPECIFIC A PLACE IS, lowest hidden first: a slice of a split another
218
+ * place shows whole says least of its own (0); then a kind's bare list where
219
+ * a picture already shows every one of them (1, said by `tooManyPlaces`);
220
+ * then any other slice of one kind (2); then a place drawn from several
221
+ * kinds, which composes (3); and last a place that shows a kind whole — a
222
+ * board, a calendar, a whole list (4).
223
+ */
224
+ function specificity(lens, splits) {
225
+ if (lens.name !== "blocks")
226
+ return 4;
227
+ const lists = listsOf(lens);
228
+ if (lists.length === 0)
229
+ return 3;
230
+ const reads = lists.map((block) => readsOf(block["list"]));
231
+ const kinds = new Set(reads.flatMap((read) => [...read.kinds]));
232
+ if (reads.some((read) => read.whole.size > 0))
233
+ return 4;
234
+ if (kinds.size !== 1)
235
+ return 3;
236
+ const [kind] = kinds;
237
+ return reads.some((read) => [...read.filters].some((field) => splits.has(`${kind}|${field}`))) ? 0 : 2;
238
+ }
239
+ /** The kinds a listed picture shows every one of: a status board with no column hidden, or a blocks list of all of them. */
240
+ function shownWhole(lens) {
241
+ if (lens.name === "columns")
242
+ return lens.options?.hidden?.length ? [] : kindsOfLens(lens);
243
+ if (lens.name !== "blocks")
244
+ return [];
245
+ return listsOf(lens).flatMap((block) => [...readsOf(block["list"]).whole]);
246
+ }
247
+ function tooManyPlaces(document, removed) {
248
+ const hide = new Set(document.pages?.hide ?? []);
249
+ const lists = Object.keys(document.kinds).filter((kind) => !hide.has(kind));
250
+ const lenses = lensesOf(document);
251
+ const titled = new Set();
252
+ const pictures = [];
253
+ lenses.forEach((lens, index) => {
254
+ const title = titleOf(lens)?.toLowerCase();
255
+ if (!listed(lens) || removed.has(index) || titled.has(title))
256
+ return;
257
+ titled.add(title);
258
+ pictures.push(index);
259
+ });
260
+ const count = lists.length + pictures.length;
261
+ if (count <= MOST_PLACES)
262
+ return [];
263
+ const pages = document.pages;
264
+ const standing = new Set([...(pages?.primary ?? []), ...(pages?.first ? [pages.first] : [])].map((word) => word.toLowerCase()));
265
+ const stands = (...words) => words.some((word) => standing.has(word.toLowerCase()));
266
+ const splits = wholeSplits(document);
267
+ const whole = new Set(pictures.flatMap((index) => shownWhole(lenses[index])));
268
+ // A place is a lens by its index, or a kind's list by its kind (hidden by its name: `set-lens-hidden` keeps it off as `pages.hide`).
269
+ const ranked = [
270
+ ...pictures.filter((index) => !stands(titleOf(lenses[index]))).map((index) => ({ title: titleOf(lenses[index]), said: `"${titleOf(lenses[index])}"`, at: index, rank: specificity(lenses[index], splits) })),
271
+ ...lists.filter((kind) => whole.has(kind) && !stands(kind, pluralOfKind(document, kind))).map((kind) => ({ title: kind, said: `the list of ${pluralOfKind(document, kind)}`, at: lists.indexOf(kind), rank: 1 })),
272
+ ].sort((a, b) => a.rank - b.rank || b.at - a.at);
273
+ const chosen = ranked.slice(0, count - MOST_PLACES);
274
+ if (chosen.length === 0)
275
+ return [];
276
+ const titles = chosen.map((one) => one.said);
277
+ return [
278
+ warning("design-too-many-places", "lenses", `This app offers ${count} places — ${lists.length} ${lists.length === 1 ? "list" : "lists"} and ${pictures.length} ${pictures.length === 1 ? "picture" : "pictures"} — and the bar stands ${MOST_STANDING - 1} beside Home; past ${MOST_PLACES} the rest is a list to search, not a choice to see.`, `keep ${listedWords(titles)} off the bar and the home (set-lens-hidden): ${titles.length === 1 ? "it says" : "they say"} the least of ${titles.length === 1 ? "its" : "their"} own — a slice of what another place shows whole first, then a list a picture already shows whole — and a link or the address still reaches ${titles.length === 1 ? "it" : "them"}`, chosen.map((one) => ({ op: "set-lens-hidden", title: one.title, hidden: true }))),
279
+ ];
280
+ }
281
+ /** The kind most of the app is about: the most relations, its own and those pointing at it, and places over it. Undefined on a tie. */
282
+ function mainKind(document) {
283
+ const score = new Map(Object.keys(document.kinds).map((kind) => [kind, 0]));
284
+ for (const [kind, spec] of Object.entries(document.kinds)) {
285
+ for (const edge of Object.values(spec.edges ?? {})) {
286
+ score.set(kind, (score.get(kind) ?? 0) + 1);
287
+ for (const far of Array.isArray(edge.to) ? edge.to : [])
288
+ if (far !== kind && score.has(far))
289
+ score.set(far, score.get(far) + 1);
290
+ }
291
+ }
292
+ for (const lens of lensesOf(document))
293
+ if (listed(lens))
294
+ for (const kind of kindsOfLens(lens))
295
+ if (score.has(kind))
296
+ score.set(kind, score.get(kind) + 1);
297
+ const ranked = [...score].sort((a, b) => b[1] - a[1]);
298
+ if (ranked.length === 0 || ranked[0][1] === 0 || ranked[1]?.[1] === ranked[0][1])
299
+ return undefined;
300
+ return ranked[0][0];
301
+ }
302
+ const isFocal = (block, inRow) => typeof block["headline"] === "string" || (typeof block["figure"] === "string" && block["emphasis"] === "large" && !inRow);
303
+ function homeUnfocused(document) {
304
+ const home = homeOf(document);
305
+ if (!home)
306
+ return [];
307
+ // Focal: a headline, or a large figure standing alone; a row of large figures is one row of stat tiles.
308
+ let headlines = 0;
309
+ let focal = 0;
310
+ const count = (blocks, inRow) => {
311
+ for (const raw of blocks) {
312
+ if (!raw || typeof raw !== "object")
313
+ continue;
314
+ const block = raw;
315
+ if (Array.isArray(block["group"]))
316
+ count(block["group"], block["direction"] === "row");
317
+ else if (Array.isArray(block["show"]))
318
+ count(block["show"], inRow);
319
+ else if (isFocal(block, inRow)) {
320
+ focal += 1;
321
+ if (typeof block["headline"] === "string")
322
+ headlines += 1;
323
+ }
324
+ }
325
+ };
326
+ count(home, false);
327
+ if (focal === 0) {
328
+ const kind = mainKind(document) ?? Object.keys(document.kinds)[0];
329
+ const edits = kind ? [{ op: "set-home", blocks: [{ headline: `{count(all('${kind}')) | words | capital} ${pluralOfKind(document, kind)}` }, ...home] }] : [];
330
+ return [warning("design-home-unfocused", "views.home", "The home has no headline: nothing on it is read first, so a person scans every block to learn what the app is about now.", "begin it with one headline that says the state of things in a sentence", edits)];
331
+ }
332
+ if (focal === 1)
333
+ return [];
334
+ // Keep the first focal block; say the others as words, and a large figure at its ordinary size.
335
+ let kept = false;
336
+ const calm = (blocks, inRow) => blocks.map((raw) => {
337
+ if (!raw || typeof raw !== "object")
338
+ return raw;
339
+ const block = raw;
340
+ if (Array.isArray(block["group"]))
341
+ return { ...block, group: calm(block["group"], block["direction"] === "row") };
342
+ if (Array.isArray(block["show"]))
343
+ return { ...block, show: calm(block["show"], inRow) };
344
+ if (!isFocal(block, inRow))
345
+ return block;
346
+ if (!kept) {
347
+ kept = true;
348
+ return block;
349
+ }
350
+ if (typeof block["headline"] === "string") {
351
+ const { headline, key, ...rest } = block;
352
+ return { text: headline, ...(key !== undefined ? { key } : {}), ...rest };
353
+ }
354
+ const { emphasis: _large, ...rest } = block;
355
+ return rest;
356
+ });
357
+ const said = [headlines > 0 ? `${headlines} ${headlines === 1 ? "headline" : "headlines"}` : "", focal - headlines > 0 ? `${focal - headlines} large ${focal - headlines === 1 ? "figure" : "figures"} standing alone` : ""].filter(Boolean).join(" and ");
358
+ return [warning("design-home-unfocused", "views.home", `The home has ${said}: each asks to be read first, so none of them is.`, "keep the first; say the others as words, or as figures at their ordinary size", [{ op: "set-home", blocks: calm(home, false) }])];
359
+ }
360
+ /** The fields a kind's records are named by, the label's or the one it is named by unsaid. */
361
+ function labelFields(spec) {
362
+ if (!spec.label)
363
+ return new Set([defaultLabelField(spec) ?? []].flat());
364
+ return templateIdents(spec.label) ?? new Set();
365
+ }
366
+ function templateIdents(template) {
367
+ try {
368
+ const out = new Set();
369
+ for (const part of parseTemplate(template))
370
+ if (part.expr)
371
+ identsOf(part.expr).forEach((name) => out.add(name));
372
+ return out;
373
+ }
374
+ catch {
375
+ return undefined;
376
+ }
377
+ }
378
+ /** The words a block says, as a template: its title, text, badge or headline; a field block's field. */
379
+ const saidBy = (block) => {
380
+ for (const key of ["title", "headline", "text", "badge"])
381
+ if (typeof block[key] === "string")
382
+ return block[key];
383
+ if (typeof block["field"] === "string")
384
+ return `{${block["field"]}}`;
385
+ return undefined;
386
+ };
387
+ function cardsRepeatTitles(document) {
388
+ const findings = [];
389
+ const views = viewsOf(document);
390
+ for (const [kind, spec] of Object.entries(document.kinds)) {
391
+ const naming = labelFields(spec);
392
+ if (naming.size === 0)
393
+ continue;
394
+ const repeats = (template) => {
395
+ const idents = templateIdents(template);
396
+ return idents !== undefined && idents.size > 0 && [...idents].every((name) => naming.has(name));
397
+ };
398
+ for (const slot of ["card", "row"]) {
399
+ const blocks = views[kind]?.[slot];
400
+ if (!blocks || blocks.length === 0)
401
+ continue;
402
+ const all = leaves(blocks);
403
+ if (all.length === 0 || !all.every((block) => (saidBy(block) !== undefined) && repeats(saidBy(block))))
404
+ continue;
405
+ findings.push(warning("design-card-repeats-title", `views.${kind}.${slot}`, `${kind}'s ${slot} says only its name, so a ${slot} of ${pluralOfKind(document, kind)} is a list of names with nothing to compare them by.`, `drop it, and the ${slot} is derived: the name, then ${spec.glance ? "its glance" : "the first facts it has"}; or say a fact beside the name`, [{ op: "set-view", kind, slot, blocks: null }]));
406
+ }
407
+ if (spec.describe && repeats(spec.describe)) {
408
+ findings.push(warning("design-card-repeats-title", `kinds.${kind}.describe`, `${kind}'s describe says "${spec.describe}", the name again, under the name.`, "drop it, or say a fact the name does not", [{ op: "set-describe", kind, describe: null }]));
409
+ }
410
+ }
411
+ return findings;
412
+ }
413
+ /** An id said where a person reads: a record's `.id` where its name would do, and `{id}` itself with what separates it. */
414
+ const RAW_MEMBER = /\{\s*([A-Za-z_][A-Za-z0-9_.]*)\.id\s*(\|[^}]*)?\}/g;
415
+ const RAW_BARE = /(\s*[·•,—–|-]\s*)?#?\{\s*id\s*(?:\|[^}]*)?\}(\s*[·•,—–|-]\s*)?/g;
416
+ function readsAnId(template) {
417
+ try {
418
+ return parseTemplate(template).some((part) => {
419
+ if (!part.expr)
420
+ return false;
421
+ let found = false;
422
+ const walk = (e) => {
423
+ if (e.t === "ident" && e.name === "id")
424
+ found = true;
425
+ else if (e.t === "member") {
426
+ if (e.name === "id")
427
+ found = true;
428
+ walk(e.object);
429
+ }
430
+ else if (e.t === "binary")
431
+ (walk(e.left), walk(e.right));
432
+ else if (e.t === "call")
433
+ e.args.forEach(walk);
434
+ else if (e.t === "unary")
435
+ walk(e.operand);
436
+ };
437
+ walk(part.expr);
438
+ return found;
439
+ });
440
+ }
441
+ catch {
442
+ return false;
443
+ }
444
+ }
445
+ /** The template with each id said by name, or taken out; undefined when nothing is left to say. */
446
+ function withoutIds(template) {
447
+ const named = template.replace(RAW_MEMBER, (_all, path, format) => `{${path}${format ?? ""}}`);
448
+ const left = named.replace(RAW_BARE, (_all, before, after) => (before && after ? before : "")).trim();
449
+ return left.length > 0 && left !== template ? left : undefined;
450
+ }
451
+ function rawValues(document) {
452
+ const findings = [];
453
+ const views = viewsOf(document);
454
+ for (const [kind, spec] of Object.entries(document.kinds)) {
455
+ if (spec.describe && readsAnId(spec.describe)) {
456
+ const fixed = withoutIds(spec.describe);
457
+ findings.push(warning("design-raw-value", `kinds.${kind}.describe`, `${kind}'s describe shows an id ("${spec.describe}"), which nobody reading it can use.`, fixed ? `say "${fixed}": a record is said by its name` : "say a fact a person reads instead", [{ op: "set-describe", kind, describe: fixed ?? null }]));
458
+ }
459
+ for (const slot of ["card", "row"]) {
460
+ const blocks = views[kind]?.[slot];
461
+ if (!blocks)
462
+ continue;
463
+ blocks.forEach((raw, index) => {
464
+ if (!raw || typeof raw !== "object")
465
+ return;
466
+ const block = raw;
467
+ const template = saidBy(block);
468
+ if (template === undefined || !(block["field"] === "id" || readsAnId(template)))
469
+ return;
470
+ const fixed = typeof block["field"] === "string" ? undefined : withoutIds(template);
471
+ const key = ["title", "headline", "text", "badge"].find((one) => typeof block[one] === "string");
472
+ const rewritten = fixed && key ? blocks.map((one, at) => (at === index ? { ...one, [key]: fixed } : one)) : blocks.filter((_one, at) => at !== index);
473
+ findings.push(warning("design-raw-value", `views.${kind}.${slot}.${index}`, `${kind}'s ${slot} shows an id ("${template}"), which nobody reading it can use.`, fixed ? `say "${fixed}": a record is said by its name` : "take it out, or say a fact a person reads", rewritten.length > 0 ? [{ op: "set-view", kind, slot, blocks: rewritten }] : [{ op: "set-view", kind, slot, blocks: null }]));
474
+ });
475
+ }
476
+ }
477
+ return findings;
478
+ }
479
+ /** Why a field is no glance: a long text, a list or a file has no room on a card. Undefined for one that reads at a glance. */
480
+ function noGlance(type) {
481
+ if (type === "text")
482
+ return "a long text";
483
+ if (type === "list")
484
+ return "a list";
485
+ if (/^(image|video|file)$/.test(type))
486
+ return "a file";
487
+ return undefined;
488
+ }
489
+ /** The facts a card could show: fields a person reads at a glance, not the name. */
490
+ function glanceable(spec, naming) {
491
+ return Object.keys(spec.fields).filter((name) => !naming.has(name) && noGlance(spec.fields[name].type) === undefined);
492
+ }
493
+ function noCardForMainKind(document) {
494
+ const kind = mainKind(document);
495
+ if (!kind)
496
+ return [];
497
+ const spec = document.kinds[kind];
498
+ if (spec.glance || viewsOf(document)[kind]?.card)
499
+ return [];
500
+ const naming = labelFields(spec);
501
+ const readable = Object.keys(spec.fields).filter((name) => !naming.has(name));
502
+ /*
503
+ * WHAT A DERIVED CARD SHOWS: the author and the natural time first where
504
+ * the kind's roles name them, then the fields as declared — three the
505
+ * name does not say. Fine by derivation when each of the three reads at a
506
+ * glance; said only when one of them is a long text, a list or a file.
507
+ */
508
+ const roles = spec.fieldRoles ?? {};
509
+ const led = [roles["who"], roles["when"]].filter((field) => field !== undefined && readable.includes(field));
510
+ const derived = [...new Set([...led, ...readable])].slice(0, 3);
511
+ const poor = derived.filter((name) => noGlance(spec.fields[name].type) !== undefined);
512
+ if (poor.length === 0)
513
+ return [];
514
+ const order = (type) => (type === "enum" ? 0 : type === "date" || type === "datetime" ? 1 : type === "number" || type === "boolean" ? 2 : 3);
515
+ const fields = glanceable(spec, naming)
516
+ .map((name, at) => ({ name, at, rank: order(spec.fields[name].type) }))
517
+ .sort((a, b) => a.rank - b.rank || a.at - b.at)
518
+ .slice(0, 3)
519
+ .map(({ name }) => name);
520
+ if (fields.length === 0)
521
+ return [];
522
+ const plural = pluralOfKind(document, kind);
523
+ return [
524
+ warning("design-no-card-for-main-kind", `kinds.${kind}.glance`, `${plural.charAt(0).toUpperCase()}${plural.slice(1)} are what this app is most about, and with no card and no glance theirs show ${derived.join(", ")}, in the order they were declared: ${poor.map((name) => `${name} is ${noGlance(spec.fields[name].type)}`).join(", ")}, with no room on a card.`, `say which facts a person compares ${plural} by: "glance": [${fields.map((field) => `"${field}"`).join(", ")}], or write its card`, [{ op: "set-glance", kind, fields }]),
525
+ ];
526
+ }
527
+ /*
528
+ * THE TWO FINDINGS ABOUT JOBS, asked only of the jobs an app says it is for
529
+ * (f-app-jobs): a job nothing answers, and a picture no job asks for, which
530
+ * is the first to keep off the bar. That one is a note: jobs say what
531
+ * people come to do most, not everything an app may show (todo's "Who may do
532
+ * what" answers no job and is still worth a place).
533
+ *
534
+ * The derived home answers every job itself (f-design-composer): with no
535
+ * `views.home`, the home opens on the jobs, each with its count, its first
536
+ * records and its act. A declared home answers one where it lists the job's
537
+ * kind, and so does a titled place over the kind. Only a declared home that
538
+ * does neither leaves a job unanswered, and the edit gives the home the
539
+ * job's question and its records.
540
+ */
541
+ function jobFindings(document, jobs) {
542
+ if (jobs.length === 0)
543
+ return [];
544
+ const findings = [];
545
+ const lenses = lensesOf(document);
546
+ const home = homeOf(document);
547
+ if (home) {
548
+ const homeLists = new Set(leaves(home).flatMap((block) => (typeof block["list"] === "string" ? [...readsOf(block["list"]).kinds] : [])));
549
+ let blocks = home;
550
+ jobs.forEach((job, index) => {
551
+ if (homeLists.has(job.kind) || lenses.some((lens) => listed(lens) && kindsOfLens(lens).includes(job.kind)))
552
+ return;
553
+ blocks = [...blocks, ...jobBlocks(job)];
554
+ const said = `No place answers "${job.ask}": the home is the app's own and lists no ${pluralOfKind(document, job.kind)}, and nothing else draws them but the list of every one.`;
555
+ findings.push(warning("design-job-unanswered", `purpose.jobs.${index}`, said, "give the home the job's question and its records", [{ op: "set-home", blocks: blocks }]));
556
+ });
557
+ }
558
+ const asked = new Set(jobs.map((job) => job.kind));
559
+ lenses.forEach((lens, index) => {
560
+ if (!listed(lens) || kindsOfLens(lens).length === 0 || kindsOfLens(lens).some((kind) => asked.has(kind)))
561
+ return;
562
+ findings.push({ ...warning("design-place-for-no-job", `lenses.${index}`, `"${titleOf(lens)}" answers none of the jobs this app says it is for.`, `keep it off the bar (set-lens-hidden), or say the job it answers`, [{ op: "set-lens-hidden", title: titleOf(lens), hidden: true }]), severity: "note" });
563
+ });
564
+ return findings;
565
+ }
566
+ /** A job on a declared home: its question, then the first of its records in its order, saying so when there are none. */
567
+ function jobBlocks(job) {
568
+ const sort = typeof job.sort === "string" ? { by: job.sort, direction: "asc" } : job.sort ? { by: job.sort.by, direction: job.sort.direction ?? "asc" } : undefined;
569
+ return [
570
+ { title: job.ask },
571
+ { list: job.where ? `all('${job.kind}') where (${job.where})` : `all('${job.kind}')`, as: "row", limit: 5, empty: "Nothing right now.", ...(sort && sort.by !== "label" ? { sort } : {}) },
572
+ ];
573
+ }
574
+ /**
575
+ * THE LOOK OF A DOCUMENT, JUDGED: places that repeat each other, more places
576
+ * than a person scans, a home with no one thing to read first, cards that
577
+ * say only their name, ids shown to people, and the main kind's card left to
578
+ * declaration order. Warnings only, each with the edits that fix it.
579
+ * Exported from `@graview/core/check` for a host to ask alone.
580
+ */
581
+ export function designFindings(document, options = {}) {
582
+ const repeated = repeatedPlaces(document);
583
+ return [
584
+ ...repeated.findings,
585
+ ...tooManyPlaces(document, repeated.removed),
586
+ ...homeUnfocused(document),
587
+ ...cardsRepeatTitles(document),
588
+ ...rawValues(document),
589
+ ...noCardForMainKind(document),
590
+ ...jobFindings(document, options.jobs ?? jobsSaid(document)),
591
+ ];
592
+ }
593
+ //# sourceMappingURL=design-findings.js.map