@heroiclands/package-build 22.15.0 → 22.16.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 (223) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +39 -678
  3. package/bin/{content-build.mjs → content-commands.mjs} +197 -118
  4. package/bin/package-build.mjs +303 -42
  5. package/bin/report.mjs +1 -1
  6. package/ci/ci-docker.mjs +6 -3
  7. package/ci/ci-steps.mjs +6 -4
  8. package/ci/content-format-reference.mjs +106 -0
  9. package/config.mjs +22 -2
  10. package/content-config.mjs +74 -134
  11. package/docs/api.md +294 -234
  12. package/docs/authoring/assets.md +31 -0
  13. package/docs/authoring/dates-and-calendars.md +33 -0
  14. package/docs/authoring/first-note.md +48 -0
  15. package/docs/authoring/frontmatter.md +40 -0
  16. package/docs/authoring/links-and-markup.md +137 -0
  17. package/docs/commands.md +631 -162
  18. package/docs/configuration.md +176 -140
  19. package/docs/content-format.md +50 -4229
  20. package/docs/design.md +32 -0
  21. package/docs/diagnostics.md +29 -22
  22. package/docs/getting-started.md +21 -877
  23. package/docs/guides/book.md +153 -0
  24. package/docs/guides/e2e.md +143 -0
  25. package/docs/guides/icons.md +126 -0
  26. package/docs/guides/localization.md +120 -0
  27. package/docs/guides/module-project.md +915 -0
  28. package/docs/guides/packs.md +167 -0
  29. package/docs/guides/reference-pages.md +148 -0
  30. package/docs/guides/release-and-deploy.md +114 -0
  31. package/docs/guides/site.md +152 -0
  32. package/docs/guides/system-project.md +45 -0
  33. package/docs/index.md +16 -0
  34. package/docs/project-setup.md +54 -35
  35. package/docs/reference/format-details.md +4420 -0
  36. package/docs/reference/note-types.md +531 -0
  37. package/engine/address-render.mjs +26 -0
  38. package/engine/address.mjs +3 -20
  39. package/engine/anchors.mjs +15 -6
  40. package/engine/asset-bindings.mjs +6 -2
  41. package/engine/base-compiler.mjs +47 -11
  42. package/engine/being-measurements.mjs +70 -0
  43. package/engine/calendar-artifacts.mjs +1 -1
  44. package/engine/calendar-choice.mjs +32 -0
  45. package/engine/calendar-human.mjs +71 -0
  46. package/engine/calendar-notes.mjs +41 -3
  47. package/engine/calendars.mjs +8 -1
  48. package/engine/changelog-lint.mjs +2 -2
  49. package/engine/code-fences.mjs +23 -97
  50. package/engine/compile-corpus.mjs +6 -7
  51. package/engine/content-admonitions.mjs +114 -0
  52. package/engine/content-captions.mjs +181 -0
  53. package/engine/content-embeds.mjs +2 -2
  54. package/engine/content-footnotes.mjs +34 -0
  55. package/engine/content-format-check.mjs +2 -2
  56. package/engine/content-format.mjs +127 -73
  57. package/engine/content-format.yaml +745 -0
  58. package/engine/content-html.mjs +1 -1
  59. package/engine/content-icons.mjs +45 -92
  60. package/engine/content-images.mjs +22 -74
  61. package/engine/content-index.mjs +12 -3
  62. package/engine/content-links.mjs +21 -26
  63. package/engine/content-lint.mjs +1 -5
  64. package/engine/content-secrets.mjs +3 -1
  65. package/engine/content-tables.mjs +47 -1257
  66. package/engine/culture-choice.mjs +32 -0
  67. package/engine/date-conversion.mjs +47 -0
  68. package/engine/declaration-check.mjs +87 -0
  69. package/engine/docs-checks.mjs +140 -0
  70. package/engine/documentation-links.mjs +31 -0
  71. package/engine/exported-scene.mjs +1 -1
  72. package/engine/extension-attributes.mjs +74 -0
  73. package/engine/field-reference.mjs +1 -1
  74. package/engine/field-spec.mjs +4 -0
  75. package/engine/foreign-catalog.mjs +5 -5
  76. package/engine/format-generated.mjs +15 -0
  77. package/engine/foundry-entries.mjs +3 -1
  78. package/engine/frontmatter-lint.mjs +5 -1
  79. package/engine/frontmatter.mjs +10 -0
  80. package/engine/generate.mjs +2 -2
  81. package/engine/helpers.mjs +71 -117
  82. package/engine/homepage.mjs +2 -3
  83. package/engine/index-records.mjs +1 -0
  84. package/engine/index.mjs +18 -3
  85. package/engine/infobox-registry.mjs +3 -1
  86. package/engine/infobox.mjs +38 -20
  87. package/engine/item-compiler.mjs +10 -8
  88. package/engine/item-docs.mjs +7 -6
  89. package/engine/itinerary-scenes.mjs +4 -2
  90. package/engine/journals.mjs +51 -10
  91. package/engine/macros.mjs +1 -1
  92. package/engine/map-build.mjs +1 -1
  93. package/engine/map-dot.mjs +1 -1
  94. package/engine/map-raster.mjs +4 -2
  95. package/engine/markdown-expressions.mjs +117 -0
  96. package/engine/metadata-index.mjs +7 -3
  97. package/engine/note-addresses.mjs +1 -0
  98. package/engine/note-dates.mjs +283 -61
  99. package/engine/note-package.mjs +5 -25
  100. package/engine/note-state.mjs +6 -2
  101. package/engine/note-vocabulary.mjs +74 -19
  102. package/engine/number-words.mjs +75 -0
  103. package/engine/office-holders.mjs +188 -0
  104. package/engine/pack-config.mjs +2 -15
  105. package/engine/pack-router.mjs +24 -5
  106. package/engine/packages.mjs +3 -2
  107. package/engine/pathnames.mjs +21 -24
  108. package/engine/pdf-build.mjs +217 -55
  109. package/engine/pdf-fonts.mjs +2 -2
  110. package/engine/pdf-images.mjs +82 -0
  111. package/engine/pdf-render.mjs +174 -22
  112. package/engine/populations.mjs +3 -2
  113. package/engine/project-init.mjs +499 -0
  114. package/engine/prose-config.mjs +1 -1
  115. package/engine/prose-lint.mjs +7 -6
  116. package/engine/readability-lint.mjs +86 -0
  117. package/engine/reckoning-markers.mjs +205 -93
  118. package/engine/scenes.mjs +5 -2
  119. package/engine/schema-check.mjs +3 -3
  120. package/engine/site-build.mjs +87 -37
  121. package/engine/site-config.mjs +8 -8
  122. package/engine/site-index.mjs +4 -0
  123. package/engine/site-maps.mjs +1 -1
  124. package/engine/site-preview.mjs +19 -8
  125. package/engine/social-ties.mjs +5 -1
  126. package/engine/sql-tables.mjs +92 -22
  127. package/engine/web-wikilinks.mjs +15 -31
  128. package/engine/wikilink-syntax.mjs +4 -1
  129. package/engine/wikilinks.mjs +12 -14
  130. package/hm3/item-fields.mjs +1 -1
  131. package/hm3/items.mjs +12 -45
  132. package/index.mjs +2 -14
  133. package/manifest.mjs +31 -4
  134. package/markdownlint-config.mjs +1 -1
  135. package/package.json +32 -11
  136. package/prettier-config.mjs +1 -1
  137. package/sohl/being-info.mjs +3 -145
  138. package/sohl/index.mjs +1 -1
  139. package/sohl/infobox.mjs +2 -1
  140. package/sohl/item-builders.mjs +3 -27
  141. package/sohl/item-fields.mjs +61 -22
  142. package/sohl/kb-passes.mjs +2 -2
  143. package/sohl/note-schemas.mjs +0 -8
  144. package/stage.mjs +19 -0
  145. package/types/config.d.mts +12 -3
  146. package/types/content-config.d.mts +15 -81
  147. package/types/engine/address-render.d.mts +13 -0
  148. package/types/engine/address.d.mts +2 -11
  149. package/types/engine/anchors.d.mts +3 -4
  150. package/types/engine/asset-bindings.d.mts +9 -2
  151. package/types/engine/base-compiler.d.mts +1 -2
  152. package/types/engine/being-measurements.d.mts +16 -0
  153. package/types/engine/calendar-choice.d.mts +14 -0
  154. package/types/engine/calendar-human.d.mts +4 -0
  155. package/types/engine/calendar-notes.d.mts +2 -0
  156. package/types/engine/calendars.d.mts +5 -3
  157. package/types/engine/code-fences.d.mts +6 -72
  158. package/types/engine/compile-corpus.d.mts +1 -3
  159. package/types/engine/content-admonitions.d.mts +24 -0
  160. package/types/engine/content-captions.d.mts +45 -0
  161. package/types/engine/content-footnotes.d.mts +10 -0
  162. package/types/engine/content-format-check.d.mts +17 -4
  163. package/types/engine/content-format.d.mts +24 -11
  164. package/types/engine/content-icons.d.mts +14 -23
  165. package/types/engine/content-images.d.mts +2 -2
  166. package/types/engine/content-index.d.mts +9 -2
  167. package/types/engine/content-links.d.mts +3 -5
  168. package/types/engine/content-tables.d.mts +11 -140
  169. package/types/engine/culture-choice.d.mts +14 -0
  170. package/types/engine/date-conversion.d.mts +4 -0
  171. package/types/engine/declaration-check.d.mts +24 -0
  172. package/types/engine/docs-checks.d.mts +28 -0
  173. package/types/engine/documentation-links.d.mts +7 -0
  174. package/types/engine/exported-scene.d.mts +6 -2
  175. package/types/engine/extension-attributes.d.mts +14 -0
  176. package/types/engine/field-spec.d.mts +9 -0
  177. package/types/engine/format-generated.d.mts +7 -0
  178. package/types/engine/frontmatter.d.mts +14 -4
  179. package/types/engine/generate.d.mts +1 -1
  180. package/types/engine/helpers.d.mts +37 -92
  181. package/types/engine/homepage.d.mts +2 -3
  182. package/types/engine/index.d.mts +5 -0
  183. package/types/engine/infobox-registry.d.mts +3 -1
  184. package/types/engine/infobox.d.mts +17 -6
  185. package/types/engine/item-compiler.d.mts +4 -4
  186. package/types/engine/item-docs.d.mts +4 -3
  187. package/types/engine/journals.d.mts +10 -7
  188. package/types/engine/map-graphviz.d.mts +1 -1
  189. package/types/engine/markdown-expressions.d.mts +17 -0
  190. package/types/engine/note-dates.d.mts +47 -1
  191. package/types/engine/note-package.d.mts +3 -19
  192. package/types/engine/note-state.d.mts +2 -1
  193. package/types/engine/note-vocabulary.d.mts +9 -0
  194. package/types/engine/number-words.d.mts +4 -0
  195. package/types/engine/office-holders.d.mts +14 -0
  196. package/types/engine/pack-config.d.mts +1 -1
  197. package/types/engine/packages.d.mts +3 -2
  198. package/types/engine/pdf-images.d.mts +38 -0
  199. package/types/engine/populations.d.mts +2 -2
  200. package/types/engine/project-init.d.mts +17 -0
  201. package/types/engine/prose-config.d.mts +1 -1
  202. package/types/engine/prose-lint.d.mts +3 -2
  203. package/types/engine/readability-lint.d.mts +22 -0
  204. package/types/engine/reckoning-markers.d.mts +4 -2
  205. package/types/engine/schema-check.d.mts +3 -3
  206. package/types/engine/site-build.d.mts +38 -28
  207. package/types/engine/site-config.d.mts +2 -2
  208. package/types/engine/site-index.d.mts +4 -0
  209. package/types/engine/site-preview.d.mts +1 -1
  210. package/types/engine/social-ties.d.mts +8 -2
  211. package/types/engine/sql-tables.d.mts +13 -2
  212. package/types/engine/web-wikilinks.d.mts +2 -1
  213. package/types/engine/wikilinks.d.mts +7 -1
  214. package/types/hm3/items.d.mts +5 -6
  215. package/types/labels.d.mts +40 -0
  216. package/types/manifest.d.mts +3 -3
  217. package/types/sohl/actors.d.mts +2 -2
  218. package/types/sohl/being-info.d.mts +3 -43
  219. package/types/sohl/index.d.mts +1 -1
  220. package/types/sohl/infobox.d.mts +5 -2
  221. package/types/sohl/item-fields.d.mts +4 -13
  222. package/types/stage.d.mts +9 -0
  223. package/CONTENT.md +0 -3239
@@ -1,1156 +1,23 @@
1
- /*
2
- * This file is part of the Song of Heroic Lands (SoHL) system for Foundry VTT.
3
- * Copyright (c) 2024-2026 Tom Rodriguez ("Toasty") — <toasty@heroiclands.org>
4
- *
5
- * This work is licensed under the GNU General Public License v3.0 (GPLv3).
6
- * You may copy, modify, and distribute it under the terms of that license.
7
- *
8
- * For full terms, see the LICENSE.md file in the project root or visit:
9
- * https://www.gnu.org/licenses/gpl-3.0.html
10
- *
11
- * SPDX-License-Identifier: GPL-3.0-or-later
12
- */
13
-
14
- import { completeAddress, renderAddress, isAddressTuple } from "./address.mjs";
15
- import { contentPackage } from "./content-package.mjs";
16
-
17
- /**
18
- * Generated content tables from fenced `dataview` `TABLE` queries.
19
- *
20
- * A catalog table (every cloth armour, every animal's abilities) is data that
21
- * already lives in the frontmatter of the notes it describes. Authoring such a
22
- * table by hand duplicates that data and guarantees drift, so a content body
23
- * instead declares what it wants tabulated, in a fenced `dataview` block:
24
- *
25
- * ```dataview
26
- * TABLE WITHOUT ID
27
- * link(file.path, name.full) AS "Name",
28
- * sohl.weight AS "Weight"
29
- * WHERE type = "armor" and sohl.material = "Cloth"
30
- * SORT name.full ASC
31
- * ```
32
- *
33
- * The build evaluates the query against note frontmatter and replaces the
34
- * block with a Markdown table. Pack journals and site pages read the same
35
- * generated table from the authored query.
36
- *
37
- * The emitted table is **markdown**, not HTML, and it is expanded *before*
38
- * wikilink resolution: a cell may therefore carry `[[type/shortcode|Text]]`,
39
- * which each build then resolves into its own flavour of link (a Foundry
40
- * `@UUID` enricher, or a KB href). That is what `link(file.path, …)` compiles
41
- * to — a link to the row's own note.
42
- *
43
- * Only the subset of Dataview the content actually uses is supported, and
44
- * anything outside it is a **build error** rather than a silently empty table;
45
- * see `kb/dev-docs/reference/content-tables.md` for the supported grammar.
46
- *
47
- * Plain ESM with no Foundry, no filesystem, and no dependencies, so it is
48
- * unit-testable — see `packages/content-build/tests/content-tables.test.ts`.
49
- */
50
-
51
- /**
52
- * One content note as a content build hands it to the expander: its parsed
53
- * frontmatter plus its path below the content root, which backs the `file.*`
54
- * fields.
55
- *
56
- * @typedef {{fm: Record<string, any>, path?: string}} ContentTableDoc
57
- */
1
+ /* SPDX-License-Identifier: GPL-3.0-or-later */
58
2
 
59
- // The fence syntax is stated once, in `code-fences.mjs`, so the scanner that
60
- // *skips* code and the one that expands a `dataview` block cannot disagree
61
- // about where a block begins and ends.
62
3
  import { FENCE_LINE } from "./code-fences.mjs";
63
4
 
64
- /** What a cell shows when its expression resolves to nothing. */
65
- const EMPTY_CELL = "—";
66
-
67
- /* ------------------------------------------------------------------------ */
68
- /* Tokenizer */
69
- /* ------------------------------------------------------------------------ */
70
-
71
- /**
72
- * A field path is one or more dot-joined segments. Hyphens are part of a
73
- * segment (frontmatter keys may carry them) — the grammar has no arithmetic, so
74
- * a hyphen between word characters is never an operator.
75
- */
76
- const IDENT = /[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z_][A-Za-z0-9_-]*)*/y;
77
- const NUMBER = /\d+(?:\.\d+)?/y;
78
- const TAG = /#[A-Za-z0-9_/-]+/y;
79
- const OPERATORS = ["!=", ">=", "<=", "==", "=", ">", "<", "!", "-"];
80
- const PUNCTUATION = ["(", ")", "[", "]", ","];
81
-
82
- /**
83
- * Split a query into tokens.
84
- *
85
- * @param {string} source
86
- * @returns {Array<{kind: string, value: any, text: string, start: number,
87
- * end: number}>} Always terminated by a single `eof` token.
88
- * @throws {Error} On an unterminated string or a character the grammar has no
89
- * meaning for.
90
- */
91
- function tokenize(source) {
92
- const tokens = [];
93
- let i = 0;
94
- while (i < source.length) {
95
- const ch = source[i];
96
- if (/\s/.test(ch)) {
97
- i++;
98
- continue;
99
- }
100
- const start = i;
101
- if (ch === '"' || ch === "'") {
102
- let value = "";
103
- i++;
104
- while (i < source.length && source[i] !== ch) {
105
- if (source[i] === "\\" && i + 1 < source.length) {
106
- value += source[++i];
107
- } else {
108
- value += source[i];
109
- }
110
- i++;
111
- }
112
- if (i >= source.length) {
113
- throw new Error(`unterminated string starting at "${ch}"`);
114
- }
115
- i++; // closing quote
116
- tokens.push({
117
- kind: "string",
118
- value,
119
- text: source.slice(start, i),
120
- start,
121
- end: i,
122
- });
123
- continue;
124
- }
125
- NUMBER.lastIndex = i;
126
- const num = NUMBER.exec(source);
127
- if (num) {
128
- i = NUMBER.lastIndex;
129
- tokens.push({
130
- kind: "number",
131
- value: Number(num[0]),
132
- text: num[0],
133
- start,
134
- end: i,
135
- });
136
- continue;
137
- }
138
- TAG.lastIndex = i;
139
- const tag = TAG.exec(source);
140
- if (tag) {
141
- i = TAG.lastIndex;
142
- tokens.push({
143
- kind: "tag",
144
- value: tag[0],
145
- text: tag[0],
146
- start,
147
- end: i,
148
- });
149
- continue;
150
- }
151
- IDENT.lastIndex = i;
152
- const ident = IDENT.exec(source);
153
- if (ident) {
154
- i = IDENT.lastIndex;
155
- tokens.push({
156
- kind: "ident",
157
- value: ident[0],
158
- text: ident[0],
159
- start,
160
- end: i,
161
- });
162
- continue;
163
- }
164
- const op = OPERATORS.find((o) => source.startsWith(o, i));
165
- if (op) {
166
- i += op.length;
167
- tokens.push({ kind: "op", value: op, text: op, start, end: i });
168
- continue;
169
- }
170
- if (PUNCTUATION.includes(ch)) {
171
- i++;
172
- tokens.push({ kind: "punct", value: ch, text: ch, start, end: i });
173
- continue;
174
- }
175
- throw new Error(`unexpected character "${ch}"`);
176
- }
177
- tokens.push({
178
- kind: "eof",
179
- value: "",
180
- text: "",
181
- start: source.length,
182
- end: source.length,
183
- });
184
- return tokens;
185
- }
186
-
187
- /* ------------------------------------------------------------------------ */
188
- /* Clause splitting */
189
- /* ------------------------------------------------------------------------ */
190
-
191
- /**
192
- * The clauses this expander understands, in the order a query must write them.
193
- * The rank is what lets a **field** share a clause keyword's name: `SORT sort
194
- * ASC` (the vault sorts traits on a frontmatter `sort` field) reads the second
195
- * `sort` as a field, because a clause may only be followed by one of *higher*
196
- * rank.
197
- */
198
- const CLAUSE_RANK = { TABLE: 0, FROM: 1, WHERE: 2, SORT: 3, LIMIT: 4 };
199
-
200
- /** Query types and data commands that are recognised only to be refused. */
201
- const UNSUPPORTED_CLAUSES = new Set(["LIST", "TASK", "CALENDAR", "GROUP", "FLATTEN"]);
202
-
203
- /**
204
- * Group a query's tokens by clause.
205
- *
206
- * @param {Array<object>} tokens - From {@link tokenize}.
207
- * @returns {Map<string, Array<object>>} Clause name → its tokens (the keyword
208
- * itself excluded).
209
- * @throws {Error} When the query is not a `TABLE`, or uses a clause this
210
- * expander does not implement.
211
- */
212
- function splitClauses(tokens) {
213
- const clauses = new Map();
214
- let current = null;
215
- let rank = -1;
216
- let depth = 0;
217
- for (let i = 0; i < tokens.length - 1; i++) {
218
- const token = tokens[i];
219
- if (token.kind === "punct") {
220
- if (token.value === "(" || token.value === "[") depth++;
221
- else if (token.value === ")" || token.value === "]") depth--;
222
- }
223
- const word = token.kind === "ident" ? token.value.toUpperCase() : undefined;
224
- if (depth === 0 && word && UNSUPPORTED_CLAUSES.has(word)) {
225
- // `GROUP BY` is two words; report it the way an author wrote it.
226
- const label =
227
- word === "GROUP" && tokens[i + 1]?.value?.toUpperCase?.() === "BY" ?
228
- "GROUP BY"
229
- : word;
230
- if (current === null) {
231
- throw new Error(`only TABLE queries are supported; this block is a ${label} query`);
232
- }
233
- throw new Error(`the ${label} clause is not supported`);
234
- }
235
- if (depth === 0 && word && word in CLAUSE_RANK) {
236
- const next = CLAUSE_RANK[word];
237
- if (next > rank) {
238
- if (current === null && word !== "TABLE") {
239
- throw new Error(
240
- `only TABLE queries are supported; this block starts with ${word}`,
241
- );
242
- }
243
- current = word;
244
- rank = next;
245
- clauses.set(word, []);
246
- continue;
247
- }
248
- }
249
- if (current === null) {
250
- throw new Error(
251
- `only TABLE queries are supported; expected TABLE, got "${token.text}"`,
252
- );
253
- }
254
- clauses.get(current).push(token);
255
- }
256
- if (!clauses.has("TABLE")) {
257
- throw new Error("only TABLE queries are supported; found no TABLE");
258
- }
259
- return clauses;
260
- }
261
-
262
- /* ------------------------------------------------------------------------ */
263
- /* Expression parser */
264
- /* ------------------------------------------------------------------------ */
265
-
266
- /** The functions a query may call, with their arity range. */
267
- const FUNCTIONS = {
268
- link: [1, 2],
269
- contains: [2, 2],
270
- icontains: [2, 2],
271
- econtains: [2, 2],
272
- startswith: [2, 2],
273
- endswith: [2, 2],
274
- lower: [1, 1],
275
- upper: [1, 1],
276
- length: [1, 1],
277
- default: [2, 2],
278
- number: [1, 1],
279
- string: [1, 1],
280
- join: [1, 2],
281
- regexmatch: [2, 2],
282
- regextest: [2, 2],
283
- };
284
-
285
- const KEYWORD_LITERALS = { true: true, false: false, null: null };
286
-
287
- /**
288
- * A recursive-descent parser over one clause's tokens. Each `parse*` method
289
- * implements one precedence level, loosest first.
290
- */
291
- class ExprParser {
292
- /**
293
- * @param {Array<object>} tokens - The clause's tokens.
294
- * @param {string} source - The whole query, for slicing expression text.
295
- */
296
- constructor(tokens, source) {
297
- this.tokens = tokens;
298
- this.source = source;
299
- this.pos = 0;
300
- }
301
-
302
- /** The token about to be consumed. */
303
- peek(offset = 0) {
304
- return (
305
- this.tokens[this.pos + offset] ?? {
306
- kind: "eof",
307
- value: "",
308
- text: "",
309
- start: 0,
310
- end: 0,
311
- }
312
- );
313
- }
314
-
315
- /** Consume and return the next token. */
316
- next() {
317
- return this.tokens[this.pos++] ?? this.peek();
318
- }
319
-
320
- /** Is the next token this operator/punctuation? */
321
- at(value) {
322
- const t = this.peek();
323
- return (t.kind === "op" || t.kind === "punct") && t.value === String(value);
324
- }
325
-
326
- /** Is the next token this (case-insensitive) bare word? */
327
- atWord(word) {
328
- const t = this.peek();
329
- return t.kind === "ident" && t.value.toLowerCase() === word.toLowerCase();
330
- }
331
-
332
- /** Consume the next token if it matches; report otherwise. */
333
- expect(value) {
334
- if (!this.at(value)) {
335
- throw new Error(`expected "${value}", got "${this.peek().text || "end of query"}"`);
336
- }
337
- return this.next();
338
- }
339
-
340
- /** Have all this clause's tokens been consumed? */
341
- get done() {
342
- return this.pos >= this.tokens.length;
343
- }
344
-
345
- /** `a or b` */
346
- parseExpression() {
347
- let left = this.parseAnd();
348
- while (this.atWord("or")) {
349
- this.next();
350
- left = { kind: "logical", op: "or", left, right: this.parseAnd() };
351
- }
352
- return left;
353
- }
354
-
355
- /** `a and b` */
356
- parseAnd() {
357
- let left = this.parseNot();
358
- while (this.atWord("and")) {
359
- this.next();
360
- left = { kind: "logical", op: "and", left, right: this.parseNot() };
361
- }
362
- return left;
363
- }
364
-
365
- /** `not a` / `!a` */
366
- parseNot() {
367
- if (this.atWord("not") || this.at("!")) {
368
- this.next();
369
- return { kind: "not", operand: this.parseNot() };
370
- }
371
- return this.parseComparison();
372
- }
373
-
374
- /** `a = b`, `a != b`, `a > b`, … — non-associative. */
375
- parseComparison() {
376
- const left = this.parseUnary();
377
- const t = this.peek();
378
- if (t.kind === "op" && ["=", "==", "!=", ">", ">=", "<", "<="].includes(t.value)) {
379
- this.next();
380
- return {
381
- kind: "compare",
382
- op: t.value === "==" ? "=" : t.value,
383
- left,
384
- right: this.parseUnary(),
385
- };
386
- }
387
- return left;
388
- }
389
-
390
- /** `-1` */
391
- parseUnary() {
392
- if (this.at("-")) {
393
- this.next();
394
- return { kind: "negate", operand: this.parseUnary() };
395
- }
396
- return this.parsePrimary();
397
- }
398
-
399
- /**
400
- * A primary, followed by any `["key"]` / `[0]` index accesses — the form a
401
- * query needs for a frontmatter key that is not a bare word
402
- * (`this["name.full"]`).
403
- */
404
- parsePrimary() {
405
- let node = this.parseAtom();
406
- while (this.at("[")) {
407
- this.next();
408
- const key = this.parseExpression();
409
- this.expect("]");
410
- // `this["name.full"]` and `sohl["subType"]` are just longer field
411
- // paths; folding them at parse time keeps the dotted-key precedence
412
- // that plain `sohl.subType` gets.
413
- if (
414
- key.kind === "literal" &&
415
- typeof key.value === "string" &&
416
- (node.kind === "field" || node.kind === "self")
417
- ) {
418
- node = {
419
- ...node,
420
- path: node.path ? `${node.path}.${key.value}` : key.value,
421
- };
422
- } else {
423
- node = { kind: "index", target: node, key };
424
- }
425
- }
426
- return node;
427
- }
428
-
429
- /** A literal, a list, a parenthesised expression, a call, or a field. */
430
- parseAtom() {
431
- const t = this.next();
432
- if (t.kind === "string") return { kind: "literal", value: t.value };
433
- if (t.kind === "number") return { kind: "literal", value: t.value };
434
- if (t.kind === "tag") return { kind: "literal", value: t.value };
435
- if (t.kind === "punct" && t.value === "(") {
436
- const inner = this.parseExpression();
437
- this.expect(")");
438
- return inner;
439
- }
440
- if (t.kind === "punct" && t.value === "[") {
441
- const items = [];
442
- if (!this.at("]")) {
443
- do {
444
- items.push(this.parseExpression());
445
- } while (this.at(",") && this.next());
446
- }
447
- this.expect("]");
448
- return { kind: "list", items };
449
- }
450
- if (t.kind === "ident") {
451
- const lowered = t.value.toLowerCase();
452
- if (lowered in KEYWORD_LITERALS && !this.at("(")) {
453
- return { kind: "literal", value: KEYWORD_LITERALS[lowered] };
454
- }
455
- // `this` is the note *containing* the query, not the row — the same
456
- // meaning Dataview gives it.
457
- if (lowered === "this" || lowered.startsWith("this.")) {
458
- return { kind: "self", path: t.value.slice("this".length + 1) };
459
- }
460
- if (this.at("(")) {
461
- const name = lowered;
462
- if (!(name in FUNCTIONS)) {
463
- throw new Error(`unknown function "${t.value}()"`);
464
- }
465
- this.expect("(");
466
- const args = [];
467
- if (!this.at(")")) {
468
- do {
469
- args.push(this.parseExpression());
470
- } while (this.at(",") && this.next());
471
- }
472
- this.expect(")");
473
- const [min, max] = FUNCTIONS[name];
474
- if (args.length < min || args.length > max) {
475
- throw new Error(
476
- `${name}() takes ${min === max ? min : `${min}–${max}`} argument(s), got ${args.length}`,
477
- );
478
- }
479
- return { kind: "call", name, args };
480
- }
481
- return { kind: "field", path: t.value };
482
- }
483
- throw new Error(`unexpected "${t.text || "end of query"}" in expression`);
484
- }
485
-
486
- /**
487
- * Parse one expression and return it with the exact source text it spanned
488
- * — which is a column's header when the query writes no `AS`.
489
- */
490
- parseExpressionWithText() {
491
- const from = this.peek().start;
492
- const expr = this.parseExpression();
493
- const to = this.tokens[this.pos - 1]?.end ?? from;
494
- return { expr, text: this.source.slice(from, to).trim() };
495
- }
496
- }
497
-
498
- /* ------------------------------------------------------------------------ */
499
- /* Query parsing */
500
- /* ------------------------------------------------------------------------ */
501
-
502
- /**
503
- * Parse one `dataview` block's query.
504
- *
505
- * @param {string} source - The query text (fences already stripped).
506
- * @returns {{columns: Array<{header: string, expr: object}>, from: object|null,
507
- * where: object|null, sort: Array<{expr: object, descending: boolean}>,
508
- * limit: number|null}}
509
- * @throws {Error} When the query is not a supported `TABLE` query — the message
510
- * names the offending clause, column, or token.
511
- */
512
- export function parseDataviewQuery(source) {
513
- const text = String(source ?? "");
514
- const clauses = splitClauses(tokenize(text));
515
-
516
- /* --- TABLE: an optional WITHOUT ID, then the column list --- */
517
- const tableTokens = clauses.get("TABLE");
518
- const parser = new ExprParser(tableTokens, text);
519
- let withoutId = false;
520
- if (parser.atWord("without")) {
521
- parser.next();
522
- if (!parser.atWord("id")) {
523
- throw new Error(`expected "WITHOUT ID", got "WITHOUT ${parser.peek().text}"`);
524
- }
525
- parser.next();
526
- withoutId = true;
527
- }
528
- const columns = [];
529
- if (!withoutId) {
530
- // Dataview's implicit first column: a link to the row's own note.
531
- columns.push({
532
- header: "File",
533
- expr: { kind: "field", path: "file.link" },
534
- });
535
- }
536
- while (!parser.done) {
537
- const { expr, text: exprText } = parser.parseExpressionWithText();
538
- let header = exprText;
539
- if (parser.atWord("as")) {
540
- parser.next();
541
- const label = parser.next();
542
- if (label.kind !== "string" && label.kind !== "ident") {
543
- throw new Error(
544
- `expected a column name after AS, got "${label.text || "end of query"}"`,
545
- );
546
- }
547
- header = String(label.value);
548
- }
549
- columns.push({ header, expr });
550
- if (parser.done) break;
551
- parser.expect(",");
552
- }
553
- if (columns.length === 0) {
554
- throw new Error("a TABLE needs at least one column");
555
- }
556
-
557
- /* --- FROM --- */
558
- let from = null;
559
- if (clauses.has("FROM")) {
560
- const fromParser = new ExprParser(clauses.get("FROM"), text);
561
- from = fromParser.parseExpression();
562
- if (!fromParser.done) {
563
- throw new Error(`unexpected "${fromParser.peek().text}" in the FROM clause`);
564
- }
565
- }
566
-
567
- /* --- WHERE --- */
568
- let where = null;
569
- if (clauses.has("WHERE")) {
570
- const whereParser = new ExprParser(clauses.get("WHERE"), text);
571
- where = whereParser.parseExpression();
572
- if (!whereParser.done) {
573
- throw new Error(`unexpected "${whereParser.peek().text}" in the WHERE clause`);
574
- }
575
- }
576
-
577
- /* --- SORT --- */
578
- const sort = [];
579
- if (clauses.has("SORT")) {
580
- const sortParser = new ExprParser(clauses.get("SORT"), text);
581
- while (!sortParser.done) {
582
- const expr = sortParser.parseExpression();
583
- let descending = false;
584
- if (sortParser.atWord("desc") || sortParser.atWord("descending")) {
585
- sortParser.next();
586
- descending = true;
587
- } else if (sortParser.atWord("asc") || sortParser.atWord("ascending")) {
588
- sortParser.next();
589
- }
590
- sort.push({ expr, descending });
591
- if (sortParser.done) break;
592
- sortParser.expect(",");
593
- }
594
- }
595
-
596
- /* --- LIMIT --- */
597
- let limit = null;
598
- if (clauses.has("LIMIT")) {
599
- const tokens = clauses.get("LIMIT");
600
- if (tokens.length !== 1 || tokens[0].kind !== "number") {
601
- throw new Error("LIMIT takes a single number");
602
- }
603
- limit = tokens[0].value;
604
- }
605
-
606
- return { columns, from, where, sort, limit };
607
- }
608
-
609
- /* ------------------------------------------------------------------------ */
610
- /* Values */
611
- /* ------------------------------------------------------------------------ */
612
-
613
- /** A resolved link to a content note, which a cell renders as a wikilink. */
614
- const makeLink = (display) => ({ __link: true, display: String(display) });
615
-
616
- const isLink = (v) => Boolean(v) && typeof v === "object" && v.__link === true;
617
-
618
- const isEmpty = (v) => v == null || v === "" || (Array.isArray(v) && v.length === 0);
619
-
620
- /** Dataview treats an absent, empty, zero, or false value as false. */
621
- const truthy = (v) => {
622
- if (isEmpty(v)) return false;
623
- if (typeof v === "number") return v !== 0;
624
- return v !== false;
625
- };
626
-
627
- /**
628
- * The note's tags as Dataview exposes them: each frontmatter tag with a leading
629
- * `#`, and — for `file.tags` — every parent of a nested tag as well, so
630
- * `#gear/tools` also matches `#gear`.
631
- *
632
- * @param {ContentTableDoc} doc
633
- * @param {boolean} withParents
634
- * @returns {string[]}
635
- */
636
- function tagsOf(doc, withParents) {
637
- const raw = doc.fm?.tags ?? doc.fm?.tag ?? [];
638
- const list = Array.isArray(raw) ? raw : [raw];
639
- const out = [];
640
- for (const entry of list) {
641
- if (entry == null || entry === "") continue;
642
- const tag = String(entry).startsWith("#") ? String(entry) : `#${entry}`;
643
- out.push(tag);
644
- if (!withParents) continue;
645
- const segments = tag.slice(1).split("/");
646
- for (let i = 1; i < segments.length; i++) {
647
- out.push(`#${segments.slice(0, i).join("/")}`);
648
- }
649
- }
650
- return [...new Set(out)];
651
- }
652
-
653
- /** The `file.*` fields a query may read, in Dataview's spelling. */
654
- const FILE_FIELDS = {
655
- path: (doc) => doc.path ?? null,
656
- name: (doc) => (doc.path ?? "").split("/").pop().replace(/\.md$/i, ""),
657
- folder: (doc) => {
658
- const at = (doc.path ?? "").lastIndexOf("/");
659
- return at === -1 ? "" : doc.path.slice(0, at);
660
- },
661
- ext: (doc) => (doc.path ?? "").match(/\.([^./]+)$/)?.[1] ?? null,
662
- link: (doc) => makeLink(FILE_FIELDS.name(doc)),
663
- tags: (doc) => tagsOf(doc, true),
664
- etags: (doc) => tagsOf(doc, false),
665
- };
666
-
667
- /**
668
- * Resolve a dotted path against a content note. `file.*` names the note's place
669
- * in the tree; everything else reads frontmatter, preferring a literal dotted
670
- * key before walking the segments (the same precedence the pack compilers'
671
- * `getFrontmatter` uses) — so **any** frontmatter property is addressable,
672
- * however deeply nested.
673
- *
674
- * @param {ContentTableDoc} doc
675
- * @param {string} path
676
- * @returns {unknown} `null` when the note has no such field.
677
- * @throws {Error} On an unknown `file.*` field, which would otherwise read as a
678
- * table that silently matches nothing.
679
- */
680
- export function resolveField(doc, path) {
681
- if (path === "file") return null;
682
- if (path.startsWith("file.")) {
683
- const key = path.slice("file.".length);
684
- if (!(key in FILE_FIELDS)) {
685
- throw new Error(
686
- `unknown field "file.${key}" (known: ${Object.keys(FILE_FIELDS).join(", ")})`,
687
- );
688
- }
689
- return FILE_FIELDS[key](doc);
690
- }
691
- let current = doc.fm;
692
- if (current && typeof current === "object" && path in current) {
693
- return current[path] ?? null;
694
- }
695
- for (const segment of path.split(".")) {
696
- if (current == null || typeof current !== "object") return null;
697
- current = current[segment];
698
- }
699
- return current ?? null;
700
- }
701
-
702
- /** Render a value as the plain text a comparison or a cell works with. */
703
- function asText(value) {
704
- if (isAddressTuple(value)) return renderAddress(value);
705
- if (value == null) return "";
706
- if (isLink(value)) return value.display;
707
- if (Array.isArray(value)) return value.map(asText).join(", ");
708
- return String(value);
709
- }
710
-
711
- /**
712
- * Dataview equality: same numbers, same strings (case-sensitively), same list
713
- * elements. Anything else — including a value compared against a missing field
714
- * — is unequal.
715
- */
716
- function looseEquals(a, b) {
717
- if (a == null || b == null) return a == null && b == null;
718
- if (Array.isArray(a) && Array.isArray(b)) {
719
- return a.length === b.length && a.every((v, i) => looseEquals(v, b[i]));
720
- }
721
- if (typeof a === "number" || typeof b === "number") {
722
- const na = Number(a);
723
- const nb = Number(b);
724
- if (Number.isFinite(na) && Number.isFinite(nb)) return na === nb;
725
- }
726
- if (typeof a === "boolean" || typeof b === "boolean") return a === b;
727
- return asText(a) === asText(b);
728
- }
729
-
730
5
  /**
731
- * Order two values: numerically where both are numbers, else as text. Text
732
- * collates **case-insensitively** — "Horn, Hunting" belongs beside "Horn,
733
- * fanfare", not before it — with the cased text breaking an exact tie so the
734
- * order is still total. Note this is ordering only: `=` stays case-sensitive,
735
- * as Dataview's does.
6
+ * Replace prepared SQL fences with Markdown tables while retaining source lines.
7
+ * Query execution happens in the asynchronous SQL preparation pass.
8
+ * @param {string} markdown - The authored body.
9
+ * @param {{source?: string, sqlTables?: object[]}} [context] - Prepared results.
10
+ * @returns {{markdown: string, errors: object[], warnings: object[], lineMap: object[]}}
736
11
  */
737
- function compareValues(a, b) {
738
- const aEmpty = isEmpty(a);
739
- const bEmpty = isEmpty(b);
740
- if (aEmpty || bEmpty) {
741
- return (
742
- aEmpty === bEmpty ? 0
743
- : aEmpty ? 1
744
- : -1
745
- );
746
- }
747
- const na = Number(a);
748
- const nb = Number(b);
749
- if (
750
- typeof a !== "boolean" &&
751
- typeof b !== "boolean" &&
752
- Number.isFinite(na) &&
753
- Number.isFinite(nb)
754
- ) {
755
- return na - nb;
756
- }
757
- const sa = asText(a);
758
- const sb = asText(b);
759
- const la = sa.toLowerCase();
760
- const lb = sb.toLowerCase();
761
- if (la !== lb) return la < lb ? -1 : 1;
762
- return (
763
- sa < sb ? -1
764
- : sa > sb ? 1
765
- : 0
766
- );
767
- }
768
-
769
- /**
770
- * Dataview's `contains` family, which recurses into a list: `contains(list, v)`
771
- * holds when **any** element contains `v`, which is why
772
- * `contains(file.tags, "cooking")` matches the tag `#cooking`.
773
- *
774
- * @param {unknown} haystack
775
- * @param {unknown} needle
776
- * @param {"loose"|"insensitive"|"exact"} mode
777
- * @returns {boolean}
778
- */
779
- function containsValue(haystack, needle, mode) {
780
- if (haystack == null) return false;
781
- if (Array.isArray(haystack)) {
782
- return haystack.some((v) =>
783
- mode === "exact" ? looseEquals(v, needle) : containsValue(v, needle, mode),
784
- );
785
- }
786
- if (typeof haystack === "object" && !isLink(haystack)) {
787
- return Object.keys(haystack).some((k) =>
788
- mode === "insensitive" ?
789
- k.toLowerCase() === asText(needle).toLowerCase()
790
- : k === asText(needle),
791
- );
792
- }
793
- const hay = asText(haystack);
794
- const pin = asText(needle);
795
- if (mode === "exact") return hay === pin;
796
- if (mode === "insensitive") {
797
- return hay.toLowerCase().includes(pin.toLowerCase());
798
- }
799
- return hay.includes(pin);
800
- }
801
-
802
- /** Compile a Dataview regex argument, anchored for `regexmatch`. */
803
- function toRegExp(pattern, anchored) {
804
- const body = asText(pattern);
805
- return new RegExp(anchored ? `^(?:${body})$` : body);
806
- }
807
-
808
- /** The implementations behind {@link FUNCTIONS}. */
809
- const CALLS = {
810
- link: (args) => makeLink(args.length > 1 && !isEmpty(args[1]) ? asText(args[1]) : ""),
811
- contains: ([a, b]) => containsValue(a, b, "loose"),
812
- icontains: ([a, b]) => containsValue(a, b, "insensitive"),
813
- econtains: ([a, b]) => containsValue(a, b, "exact"),
814
- startswith: ([a, b]) => asText(a).startsWith(asText(b)),
815
- endswith: ([a, b]) => asText(a).endsWith(asText(b)),
816
- lower: ([a]) => (a == null ? null : asText(a).toLowerCase()),
817
- upper: ([a]) => (a == null ? null : asText(a).toUpperCase()),
818
- length: ([a]) =>
819
- a == null ? 0
820
- : Array.isArray(a) ? a.length
821
- : typeof a === "object" ? Object.keys(a).length
822
- : asText(a).length,
823
- default: ([a, b]) => (isEmpty(a) ? b : a),
824
- number: ([a]) => {
825
- const n = Number(asText(a));
826
- return Number.isFinite(n) ? n : null;
827
- },
828
- string: ([a]) => asText(a),
829
- join: ([a, sep]) =>
830
- (Array.isArray(a) ? a : [a])
831
- .filter((v) => !isEmpty(v))
832
- .map(asText)
833
- .join(sep === undefined ? ", " : asText(sep)),
834
- regexmatch: ([pattern, value]) => toRegExp(pattern, true).test(asText(value)),
835
- regextest: ([pattern, value]) => toRegExp(pattern, false).test(asText(value)),
836
- };
837
-
838
- /**
839
- * Evaluate one parsed expression against a note.
840
- *
841
- * @param {object} node - From {@link parseDataviewQuery}.
842
- * @param {ContentTableDoc} doc - The row being rendered.
843
- * @param {ContentTableDoc} [self] - The note *containing* the query, which is
844
- * what `this` reads; absent means every `this.…` resolves to nothing.
845
- * @returns {unknown}
846
- */
847
- export function evaluate(node, doc, self) {
848
- switch (node.kind) {
849
- case "literal":
850
- return node.value;
851
- case "list":
852
- return node.items.map((item) => evaluate(item, doc, self));
853
- case "field":
854
- return resolveField(doc, node.path);
855
- case "self":
856
- return self && node.path ? resolveField(self, node.path) : null;
857
- case "index": {
858
- const target = evaluate(node.target, doc, self);
859
- const key = evaluate(node.key, doc, self);
860
- if (target == null) return null;
861
- if (Array.isArray(target)) {
862
- const at = Number(key);
863
- return Number.isInteger(at) ? (target[at] ?? null) : null;
864
- }
865
- if (typeof target === "object") {
866
- return target[asText(key)] ?? null;
867
- }
868
- return null;
869
- }
870
- case "logical": {
871
- const left = truthy(evaluate(node.left, doc, self));
872
- if (node.op === "and") {
873
- return left && truthy(evaluate(node.right, doc, self));
874
- }
875
- return left || truthy(evaluate(node.right, doc, self));
876
- }
877
- case "not":
878
- return !truthy(evaluate(node.operand, doc, self));
879
- case "negate": {
880
- const n = Number(asText(evaluate(node.operand, doc, self)));
881
- return Number.isFinite(n) ? -n : null;
882
- }
883
- case "compare": {
884
- const left = evaluate(node.left, doc, self);
885
- const right = evaluate(node.right, doc, self);
886
- if (node.op === "=") return looseEquals(left, right);
887
- if (node.op === "!=") return !looseEquals(left, right);
888
- if (left == null || right == null) return false;
889
- const order = compareValues(left, right);
890
- return (
891
- node.op === ">" ? order > 0
892
- : node.op === ">=" ? order >= 0
893
- : node.op === "<" ? order < 0
894
- : order <= 0
895
- );
896
- }
897
- case "call": {
898
- const args = node.args.map((arg) => evaluate(arg, doc, self));
899
- if (node.name === "link") {
900
- // The display text defaults to the note's own file name.
901
- const display =
902
- node.args.length > 1 && !isEmpty(args[1]) ?
903
- asText(args[1])
904
- : FILE_FIELDS.name(doc);
905
- return makeLink(display);
906
- }
907
- return CALLS[node.name](args);
908
- }
909
- default:
910
- throw new Error(`cannot evaluate a ${node.kind} expression`);
911
- }
912
- }
913
-
914
- /* ------------------------------------------------------------------------ */
915
- /* FROM sources */
916
- /* ------------------------------------------------------------------------ */
917
-
918
- /**
919
- * Does a note satisfy a `FROM` source? A quoted string names a folder (or a
920
- * single note) and includes everything below it; a `#tag` matches the note's
921
- * tags. `and`, `or`, `not`/`!` and parentheses combine them.
922
- *
923
- * @param {object} node
924
- * @param {ContentTableDoc} doc
925
- * @returns {boolean}
926
- */
927
- function matchesSource(node, doc) {
928
- switch (node.kind) {
929
- case "logical": {
930
- const left = matchesSource(node.left, doc);
931
- return node.op === "and" ?
932
- left && matchesSource(node.right, doc)
933
- : left || matchesSource(node.right, doc);
934
- }
935
- case "not":
936
- return !matchesSource(node.operand, doc);
937
- case "literal": {
938
- const value = String(node.value ?? "");
939
- if (value.startsWith("#")) {
940
- return tagsOf(doc, true).includes(value);
941
- }
942
- const folder = value.replace(/\/+$/, "");
943
- const path = doc.path ?? "";
944
- if (folder === "" || folder === "/") return true;
945
- return path === folder || path === `${folder}.md` || path.startsWith(`${folder}/`);
946
- }
947
- default:
948
- throw new Error(
949
- "a FROM clause takes folder strings and #tags, combined with and/or/not",
950
- );
951
- }
952
- }
953
-
954
- /* ------------------------------------------------------------------------ */
955
- /* Selection */
956
- /* ------------------------------------------------------------------------ */
957
-
958
- /**
959
- * The notes a query selects, in the order its `SORT` keys give. With no `SORT`
960
- * the notes keep content-path order, and the note path (then its id) breaks any
961
- * remaining tie, so the emitted table is byte-stable across builds regardless
962
- * of directory-walk order.
963
- *
964
- * @param {object} spec - From {@link parseDataviewQuery}.
965
- * @param {Array<ContentTableDoc>} docs
966
- * @param {ContentTableDoc} [self] - The note containing the query (`this`).
967
- * @returns {Array<ContentTableDoc>} The matching notes, sorted and limited.
968
- */
969
- export function selectRows(spec, docs, self) {
970
- const matched = docs.filter((doc) => {
971
- if (spec.from && !matchesSource(spec.from, doc)) return false;
972
- return spec.where === null || truthy(evaluate(spec.where, doc, self));
973
- });
974
- matched.sort((a, b) => {
975
- for (const { expr, descending } of spec.sort) {
976
- const order = compareValues(evaluate(expr, a, self), evaluate(expr, b, self));
977
- if (order !== 0) return descending ? -order : order;
978
- }
979
- const byPath = compareValues(a.path, b.path);
980
- return byPath !== 0 ? byPath : compareValues(a.fm?.id, b.fm?.id);
981
- });
982
- return spec.limit === null ? matched : matched.slice(0, spec.limit);
983
- }
984
-
985
- /* ------------------------------------------------------------------------ */
986
- /* Rendering */
987
- /* ------------------------------------------------------------------------ */
988
-
989
- /** Escape the characters that would break out of a markdown table cell. */
990
- const escapeCell = (text) => text.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ");
991
-
992
- /**
993
- * Render one evaluated value as table text.
994
- *
995
- * @throws {Error} When the value is an object — almost always a truncated path
996
- * (`sohl.protection` for `sohl.protection.blunt`), which would otherwise ship
997
- * as `[object Object]`.
998
- */
999
- function formatValue(value, header) {
1000
- if (isEmpty(value)) return EMPTY_CELL;
1001
- if (Array.isArray(value)) {
1002
- return escapeCell(value.map(asText).join(", "));
1003
- }
1004
- if (typeof value === "object" && !isLink(value)) {
1005
- throw new Error(`column "${header}" resolves to an object`);
1006
- }
1007
- if (typeof value === "boolean") return value ? "yes" : "no";
1008
- return escapeCell(asText(value));
1009
- }
1010
-
1011
- /**
1012
- * Build the markdown table for one query.
1013
- *
1014
- * A `link(…)` cell — and the implicit `File` column — is emitted as a wikilink
1015
- * to the row's own note, which the caller's wikilink pass resolves; a note the
1016
- * caller reports as unlinkable (no shortcode, or a content directory that
1017
- * compiles into no pack) degrades to plain text. A column is right-aligned when
1018
- * every value it shows is numeric.
1019
- *
1020
- * @param {object} spec - From {@link parseDataviewQuery}.
1021
- * @param {Array<ContentTableDoc>} rows - From {@link selectRows}.
1022
- * @param {(doc: ContentTableDoc) => boolean} linkable - Can this note be linked to?
1023
- * @param {ContentTableDoc} [self] - The note containing the query (`this`).
1024
- * @returns {string} The markdown table (no trailing newline).
1025
- */
1026
- export function renderContentTable(spec, rows, linkable, self) {
1027
- const cells = rows.map((doc) =>
1028
- spec.columns.map((column) => {
1029
- const value = evaluate(column.expr, doc, self);
1030
- if (!isLink(value)) return formatValue(value, column.header);
1031
- const display = value.display || asText(FILE_FIELDS.name(doc)) || EMPTY_CELL;
1032
- const text = escapeCell(display);
1033
- if (!linkable(doc)) return text;
1034
- // A wikilink's own separator is a literal `|`, written `\|` inside a
1035
- // table cell; the resolvers unescape it before splitting, so the
1036
- // display text itself must not carry one.
1037
- const label = text.replace(/\\?\|/g, "/");
1038
- const target = completeAddress(
1039
- { type: doc.fm.type, shortcode: doc.fm.shortcode },
1040
- { package: doc.fm.package ?? contentPackage(), system: "note" },
1041
- );
1042
- return `[[${renderAddress(target)}\\|${label}]]`;
1043
- }),
1044
- );
1045
-
1046
- const align = spec.columns.map((_column, i) => {
1047
- const shown = cells.map((row) => row[i]).filter((c) => c !== EMPTY_CELL);
1048
- const numeric =
1049
- shown.length > 0 && shown.every((c) => Number.isFinite(Number(c)) && c.trim() !== "");
1050
- return numeric ? "---:" : "---";
1051
- });
1052
-
1053
- const line = (values) => `| ${values.join(" | ")} |`;
1054
- return [
1055
- line(spec.columns.map((c) => escapeCell(c.header))),
1056
- line(align),
1057
- ...cells.map(line),
1058
- ].join("\n");
1059
- }
1060
-
1061
- /* ------------------------------------------------------------------------ */
1062
- /* Expansion */
1063
- /* ------------------------------------------------------------------------ */
1064
-
1065
- /**
1066
- * The `WHERE` clause of a query, as authored, for a message that has to name
1067
- * what matched nothing.
1068
- *
1069
- * The parsed spec holds an expression tree, and rendering that back to text
1070
- * would be a second dialect of the query language to keep in step. The authored
1071
- * line is what the author wrote and what they will edit, so it is what the
1072
- * finding quotes.
1073
- *
1074
- * @param {string} query - The fence's contents.
1075
- * @returns {string} The clause, or "" when the query has none.
1076
- */
1077
- function whereText(query) {
1078
- const match = /^\s*WHERE\s+(.+?)\s*$/im.exec(String(query ?? ""));
1079
- return match ? `\`${match[1]}\`` : "";
1080
- }
1081
-
1082
- /**
1083
- * Expand every fenced `dataview` and `sql` block in a markdown body.
1084
- *
1085
- * A block that cannot be honoured — malformed or unsupported — is left in the
1086
- * body verbatim and reported in `errors`, so the failure is visible in the
1087
- * output as well as on the console. Every other code fence, and every code
1088
- * span, is left alone (that is how the syntax is documented).
1089
- *
1090
- * A query that selects **no** note is an error unless the fence says
1091
- * `allow-empty`, which states that an empty table is the intended result.
1092
- *
1093
- * @param {string} markdown - The note body, frontmatter already stripped.
1094
- * @param {object} ctx
1095
- * @param {Array<ContentTableDoc>} ctx.docs - The searchable universe: every
1096
- * content note the caller considers in scope.
1097
- * @param {(doc: ContentTableDoc) => boolean} [ctx.linkable] - Whether a note can
1098
- * be linked to from a cell; defaults to never.
1099
- * @param {string} [ctx.source] - The note being expanded, for error reports.
1100
- * @param {ContentTableDoc} [ctx.self] - The note being expanded, as a searchable
1101
- * doc: what a query's `this` reads.
1102
- * @param {object[]} [ctx.sqlTables] - This note's prepared `sql` results, in
1103
- * document order, from
1104
- * {@link module:engine/sql-tables.prepareSqlTables}. An `sql` directive with
1105
- * no prepared result is an error: nothing here runs a query.
1106
- * @returns {{markdown: string, errors: Array<{source: string, directive: string,
1107
- * reason: string, line: number, column?: number}>,
1108
- * warnings: Array<{source: string, line: number, column: number,
1109
- * reason: string}>, lineMap: Array<{line: number, generated: boolean}>}}
1110
- * `lineMap` is parallel to the emitted lines and says which authored line
1111
- * each came from, so a diagnostic about the expanded body can name an
1112
- * authored position. An `errors` entry carries the 0-based line of the
1113
- * directive that failed, for the same reason. `warnings` holds one entry per
1114
- * `dataview` directive the body still authors, and one per `sql` directive
1115
- * that leaves out stubs it would otherwise list.
1116
- */
1117
- export function expandContentTables(
1118
- markdown,
1119
- {
1120
- docs = [],
1121
- linkable = () => false,
1122
- source = "",
1123
- self = undefined,
1124
- sqlTables = undefined,
1125
- } = {},
1126
- ) {
1127
- const errors = [];
1128
- // How many `sql` directives have been seen, which is how a prepared
1129
- // result is found: the passes disagree about a body's leading whitespace,
1130
- // so a line number would not survive the trip, and an ordinal does.
1131
- let sqlOrdinal = 0;
1132
- // One entry per `dataview` directive still authored, so a caller can say how
1133
- // much of the corpus is still on the retiring language without this pass
1134
- // failing a build over it.
1135
- const warnings = [];
12
+ export function expandContentTables(markdown, { source = "", sqlTables } = {}) {
1136
13
  const lines = String(markdown ?? "").split("\n");
1137
14
  const out = [];
1138
- // Which authored line each emitted line came from, so a later pass can
1139
- // report an authored position for text this one moved. A generated
1140
- // row has no authored line of its own, so it is blamed on the directive
1141
- // that produced it — the thing an author can actually edit — and marked,
1142
- // because its columns mean nothing.
1143
15
  const lineMap = [];
1144
- /**
1145
- * Emits one line, recording where it came from.
1146
- *
1147
- * @param {string} text - The line.
1148
- * @param {number} line - 0-based authored line to attribute it to.
1149
- * @param {boolean} [generated] - Whether this build wrote it.
1150
- * @returns {void}
1151
- */
1152
- const emit = (text, line, generated = false) => {
1153
- out.push(text);
16
+ const errors = [];
17
+ const warnings = [];
18
+ let sqlOrdinal = 0;
19
+ const emit = (value, line, generated = false) => {
20
+ out.push(value);
1154
21
  lineMap.push({ line, generated });
1155
22
  };
1156
23
  for (let i = 0; i < lines.length; i++) {
@@ -1160,134 +27,57 @@ export function expandContentTables(
1160
27
  continue;
1161
28
  }
1162
29
  const [, indent, marker, info] = opening;
1163
- // Find this fence's closing line: the same marker character, at least
1164
- // as long, with nothing after it.
1165
- let close = i + 1;
1166
30
  const closer = new RegExp(`^[ \\t]*${marker[0]}{${marker.length},}[ \\t]*$`);
31
+ let close = i + 1;
1167
32
  while (close < lines.length && !closer.test(lines[close])) close++;
1168
- const isQuery = /^dataview\b/i.test(info.trim());
1169
- const isSql = /^sql\b/i.test(info.trim());
1170
33
  const block = lines.slice(i, Math.min(close + 1, lines.length));
1171
-
1172
- // A `sql` directive was run before this pass began — DuckDB is async and
1173
- // this is not (see `prepareSqlTables`). What is left here is splicing
1174
- // the rendered table in at the directive's own position.
1175
- if (isSql && close < lines.length) {
1176
- const prepared = sqlTables?.[sqlOrdinal];
1177
- sqlOrdinal += 1;
1178
- const failure =
1179
- !prepared ?
1180
- "sql content table was not prepared — this pass was given no " +
1181
- "`sqlTables`, so the query could not be run"
1182
- : prepared.reason ? prepared.reason
1183
- : prepared.rows === 0 && !prepared.allowEmpty ?
1184
- // A table that lost every row to the stub rule says so.
1185
- // "selects no notes" is true and useless here: the notes
1186
- // are there, and the fence is reading the relation that
1187
- // leaves them out.
1188
- prepared.stubsExcluded > 0 ?
1189
- `sql query selects no notes — ${prepared.stubsExcluded} stub(s) ` +
1190
- "match it, and `FROM notes` leaves stubs out; select " +
1191
- "`FROM entries` to list them beside the written notes, or " +
1192
- "write ```sql allow-empty if the empty table is intended"
1193
- : "sql query selects no notes — write ```sql allow-empty if " +
1194
- "that is intended"
1195
- : undefined;
1196
- if (failure) {
1197
- errors.push({
1198
- source,
1199
- directive: block.join("\n"),
1200
- reason: failure,
1201
- line: i,
1202
- column: indent.length + 1,
1203
- });
1204
- block.forEach((text, k) => emit(text, i + k));
1205
- i = close;
1206
- continue;
1207
- }
1208
- // A table selecting `FROM notes` that would gain rows from
1209
- // `FROM entries` has not adopted stubs. It is not wrong — which
1210
- // tables list the unwritten is the author's decision, and it is a
1211
- // one-word edit — so this is a warning at the fence rather than a
1212
- // failure, and the count is what makes it worth reading.
1213
- if (prepared.stubsExcluded > 0) {
1214
- warnings.push({
1215
- source,
1216
- line: i,
1217
- column: indent.length + 1,
1218
- reason:
1219
- `this table selects \`FROM notes\`, which leaves out stubs; ` +
1220
- `${prepared.stubsExcluded} stub(s) match it — select ` +
1221
- `\`FROM entries\` to list them beside the written notes`,
1222
- });
1223
- }
1224
- if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
1225
- for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
1226
- if (close + 1 < lines.length && lines[close + 1].trim() !== "") emit("", i, true);
1227
- i = close;
1228
- continue;
34
+ const language = info.trim().split(/\s+/)[0]?.toLowerCase();
35
+ if (language === "dataview") {
36
+ errors.push({
37
+ source,
38
+ directive: block.join("\n"),
39
+ reason: "this table query language is unsupported; use a SQL fence",
40
+ line: i,
41
+ column: indent.length + 1,
42
+ });
1229
43
  }
1230
-
1231
- if (!isQuery || close >= lines.length) {
1232
- block.forEach((text, k) => emit(text, i + k));
44
+ if (language !== "sql" || close >= lines.length) {
45
+ block.forEach((value, offset) => emit(value, i + offset));
1233
46
  i = close;
1234
47
  continue;
1235
48
  }
1236
- const query = lines.slice(i + 1, close).join("\n");
1237
- warnings.push({
1238
- source,
1239
- line: i,
1240
- column: indent.length + 1,
1241
- reason:
1242
- "`dataview` content tables are being replaced by `sql` over the " + "content index",
1243
- });
1244
- // `dataview allow-empty` says a table selecting nothing is the intended
1245
- // state. Spelled on the fence rather than in the query, because it is a
1246
- // statement about this *directive* and not part of the query language.
1247
- const allowEmpty = /\ballow-empty\b/i.test(info);
1248
- let table;
1249
- try {
1250
- const spec = parseDataviewQuery(query);
1251
- const rows = selectRows(spec, docs, self);
1252
- // A table that selects nothing is almost always a stale query — a
1253
- // renamed type, a retired category, a typo'd path — and publishing
1254
- // a header with nothing under it makes that indistinguishable from
1255
- // a category that is legitimately empty. Eight tables in `sohl`'s
1256
- // `Rules/Bestiary.md` published that way for months after the
1257
- // `creature` → `being` rename, and no build said a word.
1258
- if (rows.length === 0 && !allowEmpty) {
1259
- errors.push({
1260
- source,
1261
- directive: block.join("\n"),
1262
- reason:
1263
- `dataview query selects no notes` +
1264
- `${whereText(query) ? `: nothing matches ${whereText(query)}` : ""}` +
1265
- ` — write \`\`\`dataview allow-empty if that is intended`,
1266
- line: i,
1267
- column: indent.length + 1,
1268
- });
1269
- }
1270
- table = renderContentTable(spec, rows, linkable, self);
1271
- } catch (err) {
49
+ const prepared = sqlTables?.[sqlOrdinal++];
50
+ const failure =
51
+ !prepared ? "SQL content table was not prepared"
52
+ : prepared.reason ? prepared.reason
53
+ : prepared.rows === 0 && !prepared.allowEmpty ?
54
+ prepared.stubsExcluded > 0 ?
55
+ `SQL query selects no notes; ${prepared.stubsExcluded} matching stubs are excluded by FROM notes. Use FROM entries or {allow-empty=true}`
56
+ : "SQL query selects no notes; use {allow-empty=true} if this is intended"
57
+ : undefined;
58
+ if (failure) {
1272
59
  errors.push({
1273
60
  source,
1274
61
  directive: block.join("\n"),
1275
- reason: err.message,
1276
- // The fence's own line, so a table error is as locatable as
1277
- // everything else the build reports.
62
+ reason: failure,
1278
63
  line: i,
64
+ column: indent.length + 1,
1279
65
  });
1280
- block.forEach((text, k) => emit(text, i + k));
66
+ block.forEach((value, offset) => emit(value, i + offset));
1281
67
  i = close;
1282
68
  continue;
1283
69
  }
1284
- // A markdown table must be its own block: keep one blank line on each
1285
- // side of it, without inventing a leading or trailing one.
1286
- if (out.length > 0 && out[out.length - 1].trim() !== "") emit("", i, true);
1287
- for (const row of table.split("\n")) emit(`${indent}${row}`, i, true);
1288
- if (close + 1 < lines.length && lines[close + 1].trim() !== "") {
1289
- emit("", i, true);
70
+ if (prepared.stubsExcluded > 0) {
71
+ warnings.push({
72
+ source,
73
+ line: i,
74
+ column: indent.length + 1,
75
+ reason: `this table selects FROM notes; ${prepared.stubsExcluded} matching stubs require FROM entries`,
76
+ });
1290
77
  }
78
+ if (out.length && out.at(-1).trim()) emit("", i, true);
79
+ for (const row of prepared.markdown.split("\n")) emit(`${indent}${row}`, i, true);
80
+ if (close + 1 < lines.length && lines[close + 1].trim()) emit("", i, true);
1291
81
  i = close;
1292
82
  }
1293
83
  return { markdown: out.join("\n"), errors, warnings, lineMap };