@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.
- package/CHANGELOG.md +103 -0
- package/README.md +39 -678
- package/bin/{content-build.mjs → content-commands.mjs} +197 -118
- package/bin/package-build.mjs +303 -42
- package/bin/report.mjs +1 -1
- package/ci/ci-docker.mjs +6 -3
- package/ci/ci-steps.mjs +6 -4
- package/ci/content-format-reference.mjs +106 -0
- package/config.mjs +22 -2
- package/content-config.mjs +74 -134
- package/docs/api.md +294 -234
- package/docs/authoring/assets.md +31 -0
- package/docs/authoring/dates-and-calendars.md +33 -0
- package/docs/authoring/first-note.md +48 -0
- package/docs/authoring/frontmatter.md +40 -0
- package/docs/authoring/links-and-markup.md +137 -0
- package/docs/commands.md +631 -162
- package/docs/configuration.md +176 -140
- package/docs/content-format.md +50 -4229
- package/docs/design.md +32 -0
- package/docs/diagnostics.md +29 -22
- package/docs/getting-started.md +21 -877
- package/docs/guides/book.md +153 -0
- package/docs/guides/e2e.md +143 -0
- package/docs/guides/icons.md +126 -0
- package/docs/guides/localization.md +120 -0
- package/docs/guides/module-project.md +915 -0
- package/docs/guides/packs.md +167 -0
- package/docs/guides/reference-pages.md +148 -0
- package/docs/guides/release-and-deploy.md +114 -0
- package/docs/guides/site.md +152 -0
- package/docs/guides/system-project.md +45 -0
- package/docs/index.md +16 -0
- package/docs/project-setup.md +54 -35
- package/docs/reference/format-details.md +4420 -0
- package/docs/reference/note-types.md +531 -0
- package/engine/address-render.mjs +26 -0
- package/engine/address.mjs +3 -20
- package/engine/anchors.mjs +15 -6
- package/engine/asset-bindings.mjs +6 -2
- package/engine/base-compiler.mjs +47 -11
- package/engine/being-measurements.mjs +70 -0
- package/engine/calendar-artifacts.mjs +1 -1
- package/engine/calendar-choice.mjs +32 -0
- package/engine/calendar-human.mjs +71 -0
- package/engine/calendar-notes.mjs +41 -3
- package/engine/calendars.mjs +8 -1
- package/engine/changelog-lint.mjs +2 -2
- package/engine/code-fences.mjs +23 -97
- package/engine/compile-corpus.mjs +6 -7
- package/engine/content-admonitions.mjs +114 -0
- package/engine/content-captions.mjs +181 -0
- package/engine/content-embeds.mjs +2 -2
- package/engine/content-footnotes.mjs +34 -0
- package/engine/content-format-check.mjs +2 -2
- package/engine/content-format.mjs +127 -73
- package/engine/content-format.yaml +745 -0
- package/engine/content-html.mjs +1 -1
- package/engine/content-icons.mjs +45 -92
- package/engine/content-images.mjs +22 -74
- package/engine/content-index.mjs +12 -3
- package/engine/content-links.mjs +21 -26
- package/engine/content-lint.mjs +1 -5
- package/engine/content-secrets.mjs +3 -1
- package/engine/content-tables.mjs +47 -1257
- package/engine/culture-choice.mjs +32 -0
- package/engine/date-conversion.mjs +47 -0
- package/engine/declaration-check.mjs +87 -0
- package/engine/docs-checks.mjs +140 -0
- package/engine/documentation-links.mjs +31 -0
- package/engine/exported-scene.mjs +1 -1
- package/engine/extension-attributes.mjs +74 -0
- package/engine/field-reference.mjs +1 -1
- package/engine/field-spec.mjs +4 -0
- package/engine/foreign-catalog.mjs +5 -5
- package/engine/format-generated.mjs +15 -0
- package/engine/foundry-entries.mjs +3 -1
- package/engine/frontmatter-lint.mjs +5 -1
- package/engine/frontmatter.mjs +10 -0
- package/engine/generate.mjs +2 -2
- package/engine/helpers.mjs +71 -117
- package/engine/homepage.mjs +2 -3
- package/engine/index-records.mjs +1 -0
- package/engine/index.mjs +18 -3
- package/engine/infobox-registry.mjs +3 -1
- package/engine/infobox.mjs +38 -20
- package/engine/item-compiler.mjs +10 -8
- package/engine/item-docs.mjs +7 -6
- package/engine/itinerary-scenes.mjs +4 -2
- package/engine/journals.mjs +51 -10
- package/engine/macros.mjs +1 -1
- package/engine/map-build.mjs +1 -1
- package/engine/map-dot.mjs +1 -1
- package/engine/map-raster.mjs +4 -2
- package/engine/markdown-expressions.mjs +117 -0
- package/engine/metadata-index.mjs +7 -3
- package/engine/note-addresses.mjs +1 -0
- package/engine/note-dates.mjs +283 -61
- package/engine/note-package.mjs +5 -25
- package/engine/note-state.mjs +6 -2
- package/engine/note-vocabulary.mjs +74 -19
- package/engine/number-words.mjs +75 -0
- package/engine/office-holders.mjs +188 -0
- package/engine/pack-config.mjs +2 -15
- package/engine/pack-router.mjs +24 -5
- package/engine/packages.mjs +3 -2
- package/engine/pathnames.mjs +21 -24
- package/engine/pdf-build.mjs +217 -55
- package/engine/pdf-fonts.mjs +2 -2
- package/engine/pdf-images.mjs +82 -0
- package/engine/pdf-render.mjs +174 -22
- package/engine/populations.mjs +3 -2
- package/engine/project-init.mjs +499 -0
- package/engine/prose-config.mjs +1 -1
- package/engine/prose-lint.mjs +7 -6
- package/engine/readability-lint.mjs +86 -0
- package/engine/reckoning-markers.mjs +205 -93
- package/engine/scenes.mjs +5 -2
- package/engine/schema-check.mjs +3 -3
- package/engine/site-build.mjs +87 -37
- package/engine/site-config.mjs +8 -8
- package/engine/site-index.mjs +4 -0
- package/engine/site-maps.mjs +1 -1
- package/engine/site-preview.mjs +19 -8
- package/engine/social-ties.mjs +5 -1
- package/engine/sql-tables.mjs +92 -22
- package/engine/web-wikilinks.mjs +15 -31
- package/engine/wikilink-syntax.mjs +4 -1
- package/engine/wikilinks.mjs +12 -14
- package/hm3/item-fields.mjs +1 -1
- package/hm3/items.mjs +12 -45
- package/index.mjs +2 -14
- package/manifest.mjs +31 -4
- package/markdownlint-config.mjs +1 -1
- package/package.json +32 -11
- package/prettier-config.mjs +1 -1
- package/sohl/being-info.mjs +3 -145
- package/sohl/index.mjs +1 -1
- package/sohl/infobox.mjs +2 -1
- package/sohl/item-builders.mjs +3 -27
- package/sohl/item-fields.mjs +61 -22
- package/sohl/kb-passes.mjs +2 -2
- package/sohl/note-schemas.mjs +0 -8
- package/stage.mjs +19 -0
- package/types/config.d.mts +12 -3
- package/types/content-config.d.mts +15 -81
- package/types/engine/address-render.d.mts +13 -0
- package/types/engine/address.d.mts +2 -11
- package/types/engine/anchors.d.mts +3 -4
- package/types/engine/asset-bindings.d.mts +9 -2
- package/types/engine/base-compiler.d.mts +1 -2
- package/types/engine/being-measurements.d.mts +16 -0
- package/types/engine/calendar-choice.d.mts +14 -0
- package/types/engine/calendar-human.d.mts +4 -0
- package/types/engine/calendar-notes.d.mts +2 -0
- package/types/engine/calendars.d.mts +5 -3
- package/types/engine/code-fences.d.mts +6 -72
- package/types/engine/compile-corpus.d.mts +1 -3
- package/types/engine/content-admonitions.d.mts +24 -0
- package/types/engine/content-captions.d.mts +45 -0
- package/types/engine/content-footnotes.d.mts +10 -0
- package/types/engine/content-format-check.d.mts +17 -4
- package/types/engine/content-format.d.mts +24 -11
- package/types/engine/content-icons.d.mts +14 -23
- package/types/engine/content-images.d.mts +2 -2
- package/types/engine/content-index.d.mts +9 -2
- package/types/engine/content-links.d.mts +3 -5
- package/types/engine/content-tables.d.mts +11 -140
- package/types/engine/culture-choice.d.mts +14 -0
- package/types/engine/date-conversion.d.mts +4 -0
- package/types/engine/declaration-check.d.mts +24 -0
- package/types/engine/docs-checks.d.mts +28 -0
- package/types/engine/documentation-links.d.mts +7 -0
- package/types/engine/exported-scene.d.mts +6 -2
- package/types/engine/extension-attributes.d.mts +14 -0
- package/types/engine/field-spec.d.mts +9 -0
- package/types/engine/format-generated.d.mts +7 -0
- package/types/engine/frontmatter.d.mts +14 -4
- package/types/engine/generate.d.mts +1 -1
- package/types/engine/helpers.d.mts +37 -92
- package/types/engine/homepage.d.mts +2 -3
- package/types/engine/index.d.mts +5 -0
- package/types/engine/infobox-registry.d.mts +3 -1
- package/types/engine/infobox.d.mts +17 -6
- package/types/engine/item-compiler.d.mts +4 -4
- package/types/engine/item-docs.d.mts +4 -3
- package/types/engine/journals.d.mts +10 -7
- package/types/engine/map-graphviz.d.mts +1 -1
- package/types/engine/markdown-expressions.d.mts +17 -0
- package/types/engine/note-dates.d.mts +47 -1
- package/types/engine/note-package.d.mts +3 -19
- package/types/engine/note-state.d.mts +2 -1
- package/types/engine/note-vocabulary.d.mts +9 -0
- package/types/engine/number-words.d.mts +4 -0
- package/types/engine/office-holders.d.mts +14 -0
- package/types/engine/pack-config.d.mts +1 -1
- package/types/engine/packages.d.mts +3 -2
- package/types/engine/pdf-images.d.mts +38 -0
- package/types/engine/populations.d.mts +2 -2
- package/types/engine/project-init.d.mts +17 -0
- package/types/engine/prose-config.d.mts +1 -1
- package/types/engine/prose-lint.d.mts +3 -2
- package/types/engine/readability-lint.d.mts +22 -0
- package/types/engine/reckoning-markers.d.mts +4 -2
- package/types/engine/schema-check.d.mts +3 -3
- package/types/engine/site-build.d.mts +38 -28
- package/types/engine/site-config.d.mts +2 -2
- package/types/engine/site-index.d.mts +4 -0
- package/types/engine/site-preview.d.mts +1 -1
- package/types/engine/social-ties.d.mts +8 -2
- package/types/engine/sql-tables.d.mts +13 -2
- package/types/engine/web-wikilinks.d.mts +2 -1
- package/types/engine/wikilinks.d.mts +7 -1
- package/types/hm3/items.d.mts +5 -6
- package/types/labels.d.mts +40 -0
- package/types/manifest.d.mts +3 -3
- package/types/sohl/actors.d.mts +2 -2
- package/types/sohl/being-info.d.mts +3 -43
- package/types/sohl/index.d.mts +1 -1
- package/types/sohl/infobox.d.mts +5 -2
- package/types/sohl/item-fields.d.mts +4 -13
- package/types/stage.d.mts +9 -0
- 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
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
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
|
|
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
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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:
|
|
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((
|
|
66
|
+
block.forEach((value, offset) => emit(value, i + offset));
|
|
1281
67
|
i = close;
|
|
1282
68
|
continue;
|
|
1283
69
|
}
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
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 };
|