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