@heroiclands/package-build 23.13.0 → 24.0.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 +36 -0
- package/README.md +2 -1
- package/assets/preview/gitea.ini +12 -7
- package/assets/styles/content-base.css +150 -1
- package/bin/content-commands.mjs +356 -210
- package/bin/package-build.mjs +3 -2
- package/ci/check-banners.mjs +7 -7
- package/config.mjs +21 -6
- package/content-config.mjs +199 -80
- package/docs/api.md +93 -72
- package/docs/authoring/links-and-markup.md +316 -78
- package/docs/commands.md +244 -78
- package/docs/configuration.md +210 -102
- package/docs/content-format.md +2 -2
- package/docs/diagnostics.md +2 -2
- package/docs/getting-started.md +1 -1
- package/docs/guides/icons.md +1 -1
- package/docs/guides/module-project.md +5 -8
- package/docs/guides/release-and-deploy.md +2 -2
- package/docs/guides/site.md +112 -23
- package/docs/guides/system-project.md +1 -1
- package/docs/guides/writing-in-invented-scripts.md +1247 -0
- package/docs/index.md +1 -0
- package/docs/project-setup.md +28 -30
- package/docs/reference/format-details.md +589 -308
- package/docs/reference/note-types.md +40 -10
- package/docs/reference/theme-pages.md +127 -108
- package/docs/source-preview.md +15 -1
- package/engine/asset-shipping.mjs +22 -10
- package/engine/base-compiler.mjs +62 -104
- package/engine/changelog-lint.mjs +1 -1
- package/engine/content-address.mjs +1 -1
- package/engine/content-alerts.mjs +8 -4
- package/engine/content-blocks.mjs +26 -11
- package/engine/content-embeds.mjs +1 -1
- package/engine/content-expand.mjs +376 -0
- package/engine/content-figures.mjs +13 -6
- package/engine/content-format.yaml +8 -0
- package/engine/content-glyphs.mjs +213 -0
- package/engine/content-images.mjs +6 -7
- package/engine/content-links.mjs +3 -3
- package/engine/content-lint.mjs +1 -1
- package/engine/content-render.mjs +453 -0
- package/engine/content-scripts.mjs +326 -27
- package/engine/content-spans.mjs +4 -1
- package/engine/content-styles.mjs +91 -0
- package/engine/content-symbols.mjs +186 -0
- package/engine/content-verse.mjs +6 -18
- package/engine/core-report.mjs +169 -0
- package/engine/core-vocabulary.mjs +208 -0
- package/engine/dictionary-html.mjs +119 -0
- package/engine/field-reference.mjs +1 -1
- package/engine/frontmatter-lint.mjs +1 -1
- package/engine/heading-attributes.mjs +89 -0
- package/engine/helpers.mjs +61 -101
- package/engine/homepage.mjs +17 -29
- package/engine/html-minify.mjs +70 -0
- package/engine/index.mjs +8 -5
- package/engine/language-rows.mjs +8 -2
- package/engine/language-terms.mjs +115 -14
- package/engine/language-words.mjs +12 -0
- package/engine/logographic.mjs +255 -0
- package/engine/map-lexicon.mjs +221 -25
- package/engine/map-places.mjs +5 -4
- package/engine/markdown-expressions.mjs +0 -2
- package/engine/markdownlint-run.mjs +9 -3
- package/engine/markup-validation.mjs +21 -0
- package/engine/metre-declaration.mjs +134 -0
- package/engine/note-matter.mjs +2 -2
- package/engine/note-vocabulary.mjs +187 -3
- package/engine/pdf-build.mjs +68 -133
- package/engine/pdf-render.mjs +102 -44
- package/engine/post-fields.mjs +157 -0
- package/engine/preview-index.mjs +130 -0
- package/engine/preview-source.mjs +450 -0
- package/engine/project-init.mjs +5 -12
- package/engine/prose-config.mjs +3 -3
- package/engine/run-words.mjs +3 -2
- package/engine/script-emphasis-terms.mjs +38 -0
- package/engine/script-emphasis.mjs +295 -0
- package/engine/script-font-delivery.mjs +4 -4
- package/engine/site-build.mjs +259 -314
- package/engine/site-config.mjs +26 -238
- package/engine/site-files.mjs +175 -0
- package/engine/site-index.mjs +9 -4
- package/engine/site-preview.mjs +50 -523
- package/engine/site-render.mjs +593 -0
- package/engine/site-root.mjs +22 -13
- package/engine/site-search.mjs +161 -0
- package/engine/site-serve.mjs +273 -0
- package/engine/site-theme-banner.mjs +154 -0
- package/engine/site-theme-urls.mjs +125 -0
- package/engine/site-theme.mjs +926 -0
- package/engine/text-maps.mjs +57 -10
- package/engine/typographer.mjs +248 -0
- package/engine/verse-metre.mjs +357 -0
- package/engine/wikilink-syntax.mjs +29 -47
- package/engine/wikilinks.mjs +758 -279
- package/engine/word-forms.mjs +39 -24
- package/engine/word-rows.mjs +12 -2
- package/engine/word-terms.mjs +29 -4
- package/manifest.mjs +3 -4
- package/package.json +10 -7
- package/{hugo-theme → site-theme}/data/banners.yaml +6 -10
- package/site-theme/layouts/base.hbs +31 -0
- package/site-theme/layouts/blog-list.hbs +30 -0
- package/site-theme/layouts/blog-post.hbs +40 -0
- package/site-theme/layouts/home.hbs +35 -0
- package/site-theme/layouts/list.hbs +20 -0
- package/site-theme/layouts/not-found.hbs +31 -0
- package/site-theme/layouts/page.hbs +7 -0
- package/site-theme/partials/copy-buttons.hbs +37 -0
- package/site-theme/partials/draft-notice.hbs +5 -0
- package/site-theme/partials/exturl.hbs +2 -0
- package/{hugo-theme/layouts/partials/footer.html → site-theme/partials/footer.hbs} +10 -7
- package/site-theme/partials/header.hbs +44 -0
- package/site-theme/partials/hero-banner.hbs +11 -0
- package/site-theme/partials/infobox-entry.hbs +2 -0
- package/site-theme/partials/infobox-section.hbs +31 -0
- package/site-theme/partials/infobox-value.hbs +2 -0
- package/site-theme/partials/infobox.hbs +18 -0
- package/site-theme/partials/list-members.hbs +9 -0
- package/site-theme/partials/nav-links.hbs +5 -0
- package/site-theme/partials/page-body.hbs +48 -0
- package/site-theme/partials/page-list.hbs +14 -0
- package/site-theme/partials/related.hbs +24 -0
- package/site-theme/partials/search-meta.hbs +6 -0
- package/site-theme/partials/search-names.hbs +2 -0
- package/site-theme/partials/search.hbs +52 -0
- package/site-theme/partials/tags.hbs +4 -0
- package/site-theme/partials/toc.hbs +7 -0
- package/{hugo-theme → site-theme}/static/css/style.css +14 -0
- package/stage.mjs +1 -2
- package/types/config.d.mts +8 -4
- package/types/content-config.d.mts +15 -15
- package/types/engine/asset-shipping.d.mts +5 -2
- package/types/engine/base-compiler.d.mts +20 -2
- package/types/engine/content-address.d.mts +1 -1
- package/types/engine/content-alerts.d.mts +8 -2
- package/types/engine/content-blocks.d.mts +11 -4
- package/types/engine/content-expand.d.mts +288 -0
- package/types/engine/content-figures.d.mts +11 -3
- package/types/engine/content-glyphs.d.mts +85 -0
- package/types/engine/content-images.d.mts +6 -7
- package/types/engine/content-links.d.mts +1 -1
- package/types/engine/content-render.d.mts +199 -0
- package/types/engine/content-scripts.d.mts +62 -0
- package/types/engine/content-spans.d.mts +1 -1
- package/types/engine/content-styles.d.mts +33 -0
- package/types/engine/content-symbols.d.mts +35 -0
- package/types/engine/core-report.d.mts +45 -0
- package/types/engine/core-vocabulary.d.mts +53 -0
- package/types/engine/dictionary-html.d.mts +8 -0
- package/types/engine/field-reference.d.mts +1 -1
- package/types/engine/heading-attributes.d.mts +20 -0
- package/types/engine/helpers.d.mts +3 -10
- package/types/engine/homepage.d.mts +11 -24
- package/types/engine/html-minify.d.mts +9 -0
- package/types/engine/index.d.mts +2 -1
- package/types/engine/language-terms.d.mts +24 -2
- package/types/engine/logographic.d.mts +45 -0
- package/types/engine/map-lexicon.d.mts +48 -8
- package/types/engine/map-places.d.mts +8 -0
- package/types/engine/markdown-expressions.d.mts +0 -2
- package/types/engine/metre-declaration.d.mts +39 -0
- package/types/engine/pdf-render.d.mts +6 -2
- package/types/engine/post-fields.d.mts +44 -0
- package/types/engine/preview-index.d.mts +72 -0
- package/types/engine/preview-source.d.mts +51 -0
- package/types/engine/script-emphasis-terms.d.mts +21 -0
- package/types/engine/script-emphasis.d.mts +84 -0
- package/types/engine/script-font-delivery.d.mts +4 -4
- package/types/engine/site-build.d.mts +71 -113
- package/types/engine/site-config.d.mts +14 -134
- package/types/engine/site-files.d.mts +73 -0
- package/types/engine/site-index.d.mts +11 -3
- package/types/engine/site-preview.d.mts +15 -19
- package/types/engine/site-render.d.mts +171 -0
- package/types/engine/site-root.d.mts +12 -4
- package/types/engine/site-search.d.mts +54 -0
- package/types/engine/site-serve.d.mts +44 -0
- package/types/engine/site-theme-banner.d.mts +51 -0
- package/types/engine/site-theme-urls.d.mts +60 -0
- package/types/engine/site-theme.d.mts +127 -0
- package/types/engine/text-maps.d.mts +17 -3
- package/types/engine/typographer.d.mts +11 -0
- package/types/engine/verse-metre.d.mts +131 -0
- package/types/engine/wikilink-syntax.d.mts +30 -48
- package/types/engine/wikilinks.d.mts +330 -14
- package/types/engine/word-forms.d.mts +3 -2
- package/types/manifest.d.mts +3 -4
- package/types/stage.d.mts +1 -2
- package/types/vendor/markdownlint.d.mts +8 -0
- package/vendor/markdownlint.mjs +20388 -0
- package/engine/web-wikilinks.mjs +0 -594
- package/hugo-theme/layouts/404.html +0 -85
- package/hugo-theme/layouts/_default/baseof.html +0 -67
- package/hugo-theme/layouts/_default/list.html +0 -139
- package/hugo-theme/layouts/_default/single.html +0 -4
- package/hugo-theme/layouts/blog/list.html +0 -54
- package/hugo-theme/layouts/blog-post/single.html +0 -36
- package/hugo-theme/layouts/index.html +0 -75
- package/hugo-theme/layouts/partials/banner-url.html +0 -141
- package/hugo-theme/layouts/partials/cdn-url.html +0 -36
- package/hugo-theme/layouts/partials/dictionary-lists.html +0 -58
- package/hugo-theme/layouts/partials/draft-notice.html +0 -41
- package/hugo-theme/layouts/partials/draft-state.html +0 -26
- package/hugo-theme/layouts/partials/header.html +0 -35
- package/hugo-theme/layouts/partials/hero-banner.html +0 -85
- package/hugo-theme/layouts/partials/infobox/entry.html +0 -18
- package/hugo-theme/layouts/partials/infobox/items.html +0 -26
- package/hugo-theme/layouts/partials/infobox/section.html +0 -56
- package/hugo-theme/layouts/partials/infobox/value.html +0 -43
- package/hugo-theme/layouts/partials/infobox.html +0 -57
- package/hugo-theme/layouts/partials/page-body.html +0 -152
- package/hugo-theme/layouts/partials/related.html +0 -138
- package/hugo-theme/layouts/partials/search.html +0 -84
- package/hugo-theme/layouts/partials/site-url.html +0 -32
- package/hugo-theme/layouts/partials/tags.html +0 -30
- package/hugo-theme/layouts/partials/toc.html +0 -28
- package/hugo-theme/layouts/partials/warn-missing-banner.html +0 -3
- package/hugo-theme/layouts/shortcodes/cartouche.html +0 -64
- package/hugo-theme/layouts/shortcodes/exturl.html +0 -12
- package/hugo-theme/layouts/shortcodes/glyph.html +0 -43
- package/hugo-theme/layouts/shortcodes/pagelist.html +0 -63
- package/hugo-theme/theme.toml +0 -11
- package/types/engine/web-wikilinks.d.mts +0 -107
- /package/{hugo-theme → site-theme}/data/banner-kinds.yaml +0 -0
- /package/{hugo-theme → site-theme}/static/css/sohl-icons.css +0 -0
- /package/{hugo-theme → site-theme}/static/fonts/game-icons-kb.woff2 +0 -0
- /package/{hugo-theme → site-theme}/static/img/banners/takheperu-hieroglyphs.svg +0 -0
- /package/{hugo-theme → site-theme}/static/img/cartouches/takheperu.svg +0 -0
- /package/{hugo-theme → site-theme}/static/js/inline-spoilers.js +0 -0
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
* **A verse fence declares its metre, and the build counts its beats.** A
|
|
16
|
+
* metre is declared once, as an entry of a `doc` note's `data.metres`:
|
|
17
|
+
*
|
|
18
|
+
* ```yaml
|
|
19
|
+
* data:
|
|
20
|
+
* metres:
|
|
21
|
+
* - { name: hexameter, beats: 6, kind: accentual }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* A fence that states `scan=` with the `name` and `lang=` with a language
|
|
25
|
+
* whose note states `data.sounds.stress` is counted line by line, and a line
|
|
26
|
+
* that cannot have the metre's beats is a warning at that line. No `scan=`
|
|
27
|
+
* means no check.
|
|
28
|
+
*
|
|
29
|
+
* **Syllables** are vowel groups: a vowel is a letter whose base is `a`, `e`,
|
|
30
|
+
* `i`, `o` or `u`, and a pair of vowels the language lists in
|
|
31
|
+
* `data.sounds.syllables.vowelPairs` is one syllable. **The stressed
|
|
32
|
+
* syllable** follows `data.sounds.stress`: `two` for a word of two, `longer`
|
|
33
|
+
* for a word of three or more, and a vowel carrying the rule's `mark` is
|
|
34
|
+
* stressed wherever it sits.
|
|
35
|
+
*
|
|
36
|
+
* **Beats** follow the accentual convention:
|
|
37
|
+
*
|
|
38
|
+
* - a word of two or more syllables has one beat, on its stressed syllable;
|
|
39
|
+
* - a one-syllable noun, verb, adjective, numeral or proper name has a beat;
|
|
40
|
+
* a one-syllable particle, preposition, conjunction, adverb or pronoun has
|
|
41
|
+
* none, by the `class` of the lexicon entry the word is, or is derived
|
|
42
|
+
* from; a word whose entries differ is a beat that may be left out;
|
|
43
|
+
* - a one-syllable word the lexicon does not hold is a content word;
|
|
44
|
+
* - a word of four or more syllables whose stress is not on its first may take
|
|
45
|
+
* a second, lighter beat on its first syllable, and a line scans when its
|
|
46
|
+
* count can be the metre's, choosing second beats as it needs;
|
|
47
|
+
* - the caesura is not written.
|
|
48
|
+
*
|
|
49
|
+
* @module
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { nearest } from "./near-miss.mjs";
|
|
53
|
+
import { isNoteRecord } from "./index-records.mjs";
|
|
54
|
+
import { METRE_KINDS, METRE_NAME } from "./metre-declaration.mjs";
|
|
55
|
+
import { languageRecordsByName } from "./map-lexicon.mjs";
|
|
56
|
+
import { STRESS_LONGER, STRESS_MARKS, STRESS_TWO } from "./language-terms.mjs";
|
|
57
|
+
import { parseWordClass } from "./word-classes.mjs";
|
|
58
|
+
import { derivedForms, foldWord } from "./word-forms.mjs";
|
|
59
|
+
import { WORD } from "./word-pattern.mjs";
|
|
60
|
+
|
|
61
|
+
export { METRE_KINDS, METRE_NAME, checkMetresField } from "./metre-declaration.mjs";
|
|
62
|
+
|
|
63
|
+
/** The bases whose one-syllable words carry a beat. */
|
|
64
|
+
export const BEAT_BASES = Object.freeze(["n", "v", "adj", "num", "name"]);
|
|
65
|
+
|
|
66
|
+
/** The characters a compound is split at. */
|
|
67
|
+
const JOINS = /[–-]/u;
|
|
68
|
+
|
|
69
|
+
/** A letter, which a counted word holds at least one of. */
|
|
70
|
+
const LETTER = /\p{L}/u;
|
|
71
|
+
|
|
72
|
+
/** Whether a value is a plain map. */
|
|
73
|
+
const isMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
74
|
+
|
|
75
|
+
/** A list value, or an empty one. */
|
|
76
|
+
const listOf = (value) => (Array.isArray(value) ? value : []);
|
|
77
|
+
|
|
78
|
+
/** A character's base letter, lowercase. */
|
|
79
|
+
const baseOf = (char) => char.normalize("NFD")[0].toLowerCase();
|
|
80
|
+
|
|
81
|
+
/** Whether a character is a vowel: its base letter is a, e, i, o or u. */
|
|
82
|
+
const isVowel = (char) => "aeiou".includes(baseOf(char));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The syllables of a word, each as the vowel group that makes it.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} word - The word.
|
|
88
|
+
* @param {Iterable<string>} pairs - The vowel pairs that are one syllable.
|
|
89
|
+
* @returns {string[]} The nuclei, in order; none for a word with no vowel.
|
|
90
|
+
*/
|
|
91
|
+
export function nucleiOf(word, pairs) {
|
|
92
|
+
const chars = Array.from(String(word).normalize("NFC"));
|
|
93
|
+
const known = new Set(pairs);
|
|
94
|
+
const longest = Math.max(1, ...Array.from(known, (pair) => Array.from(pair).length));
|
|
95
|
+
const nuclei = [];
|
|
96
|
+
for (let at = 0; at < chars.length; at++) {
|
|
97
|
+
if (!isVowel(chars[at])) continue;
|
|
98
|
+
let taken = 1;
|
|
99
|
+
for (let size = Math.min(longest, chars.length - at); size > 1; size--) {
|
|
100
|
+
const group = chars.slice(at, at + size);
|
|
101
|
+
if (!group.every(isVowel)) continue;
|
|
102
|
+
if (known.has(group.join("")) || known.has(group.map(baseOf).join(""))) {
|
|
103
|
+
taken = size;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
nuclei.push(chars.slice(at, at + taken).join(""));
|
|
108
|
+
at += taken - 1;
|
|
109
|
+
}
|
|
110
|
+
return nuclei;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The index of the stressed syllable of a word.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} word - The word.
|
|
117
|
+
* @param {{two: string, longer: string, mark?: string}} rule - The language's
|
|
118
|
+
* `data.sounds.stress`.
|
|
119
|
+
* @param {Iterable<string>} pairs - The vowel pairs that are one syllable.
|
|
120
|
+
* @returns {number} The 0-based syllable; 0 for a word of one syllable or none.
|
|
121
|
+
*/
|
|
122
|
+
export function stressIndex(word, rule, pairs) {
|
|
123
|
+
const nuclei = nucleiOf(word, pairs);
|
|
124
|
+
const mark = rule.mark === undefined ? undefined : STRESS_MARKS[rule.mark];
|
|
125
|
+
if (mark) {
|
|
126
|
+
const marked = nuclei.findIndex((nucleus) => nucleus.normalize("NFD").includes(mark));
|
|
127
|
+
if (marked >= 0) return marked;
|
|
128
|
+
}
|
|
129
|
+
const count = nuclei.length;
|
|
130
|
+
if (count <= 1) return 0;
|
|
131
|
+
if (count === 2) return rule.two === "last" ? 1 : 0;
|
|
132
|
+
if (rule.longer === "antepenultimate") return count - 3;
|
|
133
|
+
if (rule.longer === "final") return count - 1;
|
|
134
|
+
return count - 2;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What a language's lines are counted by.
|
|
139
|
+
*
|
|
140
|
+
* @typedef {object} MetreBook
|
|
141
|
+
* @property {string} language - The language's configured name.
|
|
142
|
+
* @property {{two: string, longer: string, mark?: string}} rule - Its stress rule.
|
|
143
|
+
* @property {ReadonlySet<string>} pairs - Its vowel pairs.
|
|
144
|
+
* @property {ReadonlyMap<string, ReadonlySet<string>>} classes - Each lexicon
|
|
145
|
+
* form, and each form its rules derive, folded, with the class bases of its
|
|
146
|
+
* entries.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/** Whether a stress value is a complete rule. */
|
|
150
|
+
const isRule = (stress) =>
|
|
151
|
+
isMap(stress) && STRESS_TWO.includes(stress.two) && STRESS_LONGER.includes(stress.longer);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Each configured language's metre book: one per language whose `note` names a
|
|
155
|
+
* language note stating a complete `data.sounds.stress`.
|
|
156
|
+
*
|
|
157
|
+
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
158
|
+
* @param {{languages?: Readonly<Record<string, {tag: string, note?: string}>>, contentPackage?: string}} config -
|
|
159
|
+
* The resolved configuration.
|
|
160
|
+
* @returns {Map<string, MetreBook>} The books by language name.
|
|
161
|
+
*/
|
|
162
|
+
export function metreBooksFromRecords(records, config = {}) {
|
|
163
|
+
const books = new Map();
|
|
164
|
+
for (const [language, record] of languageRecordsByName([...(records ?? [])], config)) {
|
|
165
|
+
const data = isMap(record.data) ? record.data : {};
|
|
166
|
+
const sounds = isMap(data.sounds) ? data.sounds : {};
|
|
167
|
+
if (!isRule(sounds.stress)) continue;
|
|
168
|
+
const syllables = isMap(sounds.syllables) ? sounds.syllables : {};
|
|
169
|
+
const classes = new Map();
|
|
170
|
+
const add = (form, word) => {
|
|
171
|
+
const base = parseWordClass(word.class).base;
|
|
172
|
+
if (base === undefined) return;
|
|
173
|
+
const folded = foldWord(form);
|
|
174
|
+
if (!classes.has(folded)) classes.set(folded, new Set());
|
|
175
|
+
classes.get(folded).add(base);
|
|
176
|
+
};
|
|
177
|
+
for (const word of listOf(data.words))
|
|
178
|
+
if (isMap(word) && typeof word.form === "string") add(word.form, word);
|
|
179
|
+
for (const { form, word } of derivedForms(data)) add(form, word);
|
|
180
|
+
books.set(
|
|
181
|
+
language,
|
|
182
|
+
Object.freeze({
|
|
183
|
+
language,
|
|
184
|
+
rule: Object.freeze({ ...sounds.stress }),
|
|
185
|
+
pairs: new Set(listOf(syllables.vowelPairs).filter((p) => typeof p === "string")),
|
|
186
|
+
classes,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return books;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Every metre the index's `doc` notes declare, by name. The first note to
|
|
195
|
+
* declare a name keeps it.
|
|
196
|
+
*
|
|
197
|
+
* @param {Iterable<Record<string, any>>|undefined} records - Index records.
|
|
198
|
+
* @returns {Map<string, import("./metre-declaration.mjs").Metre>} The metres.
|
|
199
|
+
*/
|
|
200
|
+
export function metresFromRecords(records) {
|
|
201
|
+
const metres = new Map();
|
|
202
|
+
for (const record of records ?? []) {
|
|
203
|
+
if (!record || !isNoteRecord(record)) continue;
|
|
204
|
+
for (const entry of listOf(record.data?.metres)) {
|
|
205
|
+
if (
|
|
206
|
+
!isMap(entry) ||
|
|
207
|
+
typeof entry.name !== "string" ||
|
|
208
|
+
!METRE_NAME.test(entry.name) ||
|
|
209
|
+
!Number.isInteger(Number(entry.beats)) ||
|
|
210
|
+
Number(entry.beats) < 1 ||
|
|
211
|
+
!METRE_KINDS.includes(entry.kind) ||
|
|
212
|
+
metres.has(entry.name)
|
|
213
|
+
)
|
|
214
|
+
continue;
|
|
215
|
+
metres.set(
|
|
216
|
+
entry.name,
|
|
217
|
+
Object.freeze({ name: entry.name, beats: Number(entry.beats), kind: entry.kind }),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return metres;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The beats a word can carry.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} word - The word as written.
|
|
228
|
+
* @param {MetreBook} book - The language's book.
|
|
229
|
+
* @returns {{min: number, max: number}} The fewest and the most.
|
|
230
|
+
*/
|
|
231
|
+
function wordBeats(word, book) {
|
|
232
|
+
const folded = foldWord(word);
|
|
233
|
+
const count = nucleiOf(folded, book.pairs).length;
|
|
234
|
+
if (count === 0) return { min: 0, max: 0 };
|
|
235
|
+
if (count > 1) {
|
|
236
|
+
const first = stressIndex(folded, book.rule, book.pairs) === 0;
|
|
237
|
+
return { min: 1, max: count >= 4 && !first ? 2 : 1 };
|
|
238
|
+
}
|
|
239
|
+
const bases = book.classes.get(folded);
|
|
240
|
+
if (!bases) return { min: 1, max: 1 };
|
|
241
|
+
const carrying = [...bases].filter((base) => BEAT_BASES.includes(base)).length;
|
|
242
|
+
if (carrying === bases.size) return { min: 1, max: 1 };
|
|
243
|
+
return { min: 0, max: carrying ? 1 : 0 };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The beats a line of verse can have.
|
|
248
|
+
*
|
|
249
|
+
* @param {string} text - The line.
|
|
250
|
+
* @param {MetreBook} book - The line's language's book.
|
|
251
|
+
* @returns {{min: number, max: number}} The beats without the second beats a
|
|
252
|
+
* long word may take, and with all of them.
|
|
253
|
+
*/
|
|
254
|
+
export function lineBeats(text, book) {
|
|
255
|
+
let min = 0;
|
|
256
|
+
let max = 0;
|
|
257
|
+
for (const [whole] of String(text).matchAll(WORD)) {
|
|
258
|
+
const parts = book.classes.has(foldWord(whole)) ? [whole] : whole.split(JOINS);
|
|
259
|
+
for (const part of parts) {
|
|
260
|
+
if (!LETTER.test(part)) continue;
|
|
261
|
+
const beats = wordBeats(part, book);
|
|
262
|
+
min += beats.min;
|
|
263
|
+
max += beats.max;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { min, max };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The warning for a line that cannot have its metre's beats.
|
|
271
|
+
*
|
|
272
|
+
* @param {{min: number, max: number}} beats - The line's beats.
|
|
273
|
+
* @param {import("./metre-declaration.mjs").Metre} metre - The fence's metre.
|
|
274
|
+
* @returns {string} The message.
|
|
275
|
+
*/
|
|
276
|
+
export function metreMessage({ min, max }, metre) {
|
|
277
|
+
const has = max === min ? `${min} beats` : `${min} beats (${max} with secondary beats)`;
|
|
278
|
+
return `this line has ${has}; the metre \`${metre.name}\` has ${metre.beats}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Check the verse fences that state `scan=`.
|
|
283
|
+
*
|
|
284
|
+
* @param {ReadonlyArray<{start: number, end: number, indent: string, attributes: Record<string, string>, classes: string[]}>} blocks -
|
|
285
|
+
* The fences, from {@link module:engine/content-verse.scanVerse}.
|
|
286
|
+
* @param {string[]} lines - The source's lines.
|
|
287
|
+
* @param {{metres?: ReadonlyMap<string, import("./metre-declaration.mjs").Metre>, metreBooks?: ReadonlyMap<string, MetreBook>, lexicons?: ReadonlyMap<string, unknown>, columnsClass: string, columnKeys: readonly string[], attributeColumns: (line: string, key: string) => {key: number, value: number}}} ctx -
|
|
288
|
+
* The metres and books, and how the fence says its columns and where its
|
|
289
|
+
* keys sit.
|
|
290
|
+
* @returns {{errors: object[], warnings: object[]}} The findings at 1-based
|
|
291
|
+
* positions.
|
|
292
|
+
*/
|
|
293
|
+
export function metreFindings(blocks, lines, ctx) {
|
|
294
|
+
const errors = [];
|
|
295
|
+
const warnings = [];
|
|
296
|
+
const metres = ctx.metres ?? new Map();
|
|
297
|
+
for (const block of blocks) {
|
|
298
|
+
const name = block.attributes.scan;
|
|
299
|
+
if (name === undefined) continue;
|
|
300
|
+
const opening = lines[block.start];
|
|
301
|
+
const at = (key, part) => ({
|
|
302
|
+
line: block.start + 1,
|
|
303
|
+
column: ctx.attributeColumns(opening, key)[part],
|
|
304
|
+
});
|
|
305
|
+
const error = (position, message) => errors.push({ ...position, message });
|
|
306
|
+
if (
|
|
307
|
+
block.classes.includes(ctx.columnsClass) ||
|
|
308
|
+
ctx.columnKeys.some((key) => block.attributes[key] !== undefined)
|
|
309
|
+
) {
|
|
310
|
+
error(
|
|
311
|
+
at("scan", "key"),
|
|
312
|
+
"`scan=` counts the beats of one language's lines; a verse in columns sets a line in each of two",
|
|
313
|
+
);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const metre = metres.get(name);
|
|
317
|
+
if (!metre) {
|
|
318
|
+
const near = nearest(name, [...metres.keys()]);
|
|
319
|
+
error(
|
|
320
|
+
at("scan", "value"),
|
|
321
|
+
`\`${name}\` is not a declared metre: \`scan=\` names the \`name\` of a \`data.metres\` entry of a \`doc\` note` +
|
|
322
|
+
(near ? `; did you mean \`${near}\`?` : ""),
|
|
323
|
+
);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const lang = block.attributes.lang;
|
|
327
|
+
if (lang === undefined) {
|
|
328
|
+
error(
|
|
329
|
+
at("scan", "key"),
|
|
330
|
+
"`scan=` counts beats by a language's stress rule: the fence needs a `lang=`",
|
|
331
|
+
);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const book = ctx.metreBooks?.get(lang);
|
|
335
|
+
if (!book) {
|
|
336
|
+
error(
|
|
337
|
+
at("lang", "value"),
|
|
338
|
+
ctx.lexicons?.has(lang) ?
|
|
339
|
+
`the language \`${lang}\` states no \`data.sounds.stress\`, which \`scan=\` counts beats by`
|
|
340
|
+
: `the language \`${lang}\` is not a configured language with a \`note\`, so it has no stress rule for \`scan=\` to count beats by`,
|
|
341
|
+
);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
for (let line = block.start + 1; line < block.end; line++) {
|
|
345
|
+
const text = lines[line];
|
|
346
|
+
if (!text.trim()) continue;
|
|
347
|
+
const beats = lineBeats(text, book);
|
|
348
|
+
if (metre.beats >= beats.min && metre.beats <= beats.max) continue;
|
|
349
|
+
warnings.push({
|
|
350
|
+
line: line + 1,
|
|
351
|
+
column: /^\s*/.exec(text)[0].length + 1,
|
|
352
|
+
message: metreMessage(beats, metre),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { errors, warnings };
|
|
357
|
+
}
|
|
@@ -19,36 +19,23 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
|
|
|
19
19
|
/**
|
|
20
20
|
* What a `[[…]]` **is**, before anything decides where it points.
|
|
21
21
|
*
|
|
22
|
-
* One authored link
|
|
23
|
-
*
|
|
24
|
-
* *only* thing that legitimately differs. The syntax is the
|
|
25
|
-
* the same syntax whichever build is reading it.
|
|
22
|
+
* One authored link is written three ways — a Foundry `@UUID` content link for
|
|
23
|
+
* the packs, a URL for the web, a cross-reference for the book — and those
|
|
24
|
+
* forms are the *only* thing that legitimately differs. The syntax is the
|
|
25
|
+
* author's, and it is the same syntax whichever build is reading it.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
27
|
+
* So the parse lives here, once, and two readers take it: the link checker
|
|
28
|
+
* (`content-links.mjs`) and the resolver (`wikilinks.mjs`). Each receives the
|
|
29
|
+
* parts and decides only what it is for: which document the address names.
|
|
28
30
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* The web side's pattern omitted `\n` from the excluded set, so an unclosed
|
|
35
|
-
* bracket consumed everything up to the next `]]` anywhere in the document —
|
|
36
|
-
* the same shape of corruption a hand-rolled code-fence regex caused on the one
|
|
37
|
-
* page whose subject is link syntax. It was also internally
|
|
38
|
-
* inconsistent: its *frontmatter* scan excluded newlines while its body scan did
|
|
39
|
-
* not.
|
|
40
|
-
*
|
|
41
|
-
* So the parse lives here, once. A resolver receives the parts and decides only
|
|
42
|
-
* what it is actually for: which document the address names.
|
|
43
|
-
*
|
|
44
|
-
* The one rule about a link's *shape* that both resolvers share also lives here
|
|
45
|
-
* — **a link carries a label** ({@link unlabelledLinkMessage}) — because the
|
|
46
|
-
* two state it through one message and the author reads the same words
|
|
47
|
-
* whichever ran first.
|
|
31
|
+
* The one rule about a link's *shape* also lives here — **a link carries a
|
|
32
|
+
* label** ({@link unlabelledLinkMessage}) — because every build states it
|
|
33
|
+
* through one message and the author reads the same words whichever ran
|
|
34
|
+
* first.
|
|
48
35
|
*
|
|
49
36
|
* For the same reason, so does the **vocabulary of link findings**
|
|
50
37
|
* ({@link LINK_FINDING_REASONS}) and the message each one reports through
|
|
51
|
-
* ({@link linkFindingMessage}).
|
|
38
|
+
* ({@link linkFindingMessage}). Every build reads one authored link; an author
|
|
52
39
|
* meets whichever ran first, and a consumer switching on a `reason` should not
|
|
53
40
|
* be switching on which build produced it.
|
|
54
41
|
*
|
|
@@ -66,8 +53,8 @@ import { parseAddress, isAddressTuple, splitAnchor } from "./address.mjs";
|
|
|
66
53
|
* **A leading `!` makes it an embed, not a link**, and the lookbehind is what
|
|
67
54
|
* keeps every reader from seeing one as the other. Stated here rather than in
|
|
68
55
|
* each reader for the reason the rest of this module exists: the checker, the
|
|
69
|
-
*
|
|
70
|
-
*
|
|
56
|
+
* resolver and the embed reader all match on this pattern, and a copy of the
|
|
57
|
+
* exclusion in each is a place for it to be forgotten. An embed's
|
|
71
58
|
* own grammar is {@link module:engine/content-embeds}.
|
|
72
59
|
*/
|
|
73
60
|
export const WIKILINK = /(?<!!)\[\[([^\]\n]+)\]\](?:\{(?:[^{}"\n]|"(?:\\.|[^"\\\n])*")*\})?/g;
|
|
@@ -138,12 +125,8 @@ export function parseWikilink(rawInner, literal = "") {
|
|
|
138
125
|
* `[[x|]]` is labelled and `[[x]]` is not, and only the first addresses
|
|
139
126
|
* anything.
|
|
140
127
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* the web tested `??` — which falls through on `null` only — and emitted
|
|
144
|
-
* `[](/url/)`, a link with no clickable text, through every build. That
|
|
145
|
-
* is the same drift this module exists to prevent, in the case its own
|
|
146
|
-
* {@link ParsedWikilink} docstring calls out. One reading, one place.
|
|
128
|
+
* Read with `??`, which falls through on `null` only, an empty label would
|
|
129
|
+
* emit `[](/url/)`: a link with no clickable text. One reading, one place.
|
|
147
130
|
*
|
|
148
131
|
* @param {{display: string|null}} parsed - A parsed wikilink, or anything
|
|
149
132
|
* carrying its `display`.
|
|
@@ -156,7 +139,7 @@ export function authoredLabel({ display }) {
|
|
|
156
139
|
/**
|
|
157
140
|
* What an author writing an unlabelled link is told, in one place.
|
|
158
141
|
*
|
|
159
|
-
* Shared by the link checker
|
|
142
|
+
* Shared by the link checker and the resolver, on every target, because
|
|
160
143
|
* an author meets whichever runs first and they should read the same. It names
|
|
161
144
|
* the form to write rather than a value to correct: there is no value that
|
|
162
145
|
* makes an unlabelled link resolve.
|
|
@@ -180,14 +163,12 @@ export function unlabelledLinkMessage(target) {
|
|
|
180
163
|
}
|
|
181
164
|
|
|
182
165
|
/**
|
|
183
|
-
* Every way a link can fail, named once for
|
|
166
|
+
* Every way a link can fail, named once for every reader of a link.
|
|
184
167
|
*
|
|
185
|
-
* A link is read
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* consumer switching on a `reason` was switching on which build had produced
|
|
190
|
-
* it. The set is closed and lives here, beside the syntax the three share.
|
|
168
|
+
* A link is read by the checker (`content-links.mjs`) and by the resolver
|
|
169
|
+
* (`wikilinks.mjs`) for every target, and each names a failure by one of these
|
|
170
|
+
* reasons, so a consumer switching on a `reason` never switches on which build
|
|
171
|
+
* produced it. The set is closed and lives here, beside the syntax they share.
|
|
191
172
|
*
|
|
192
173
|
* - `unlabelled` — no `|`, so the link addresses nothing.
|
|
193
174
|
* - `not-an-address` — labelled, but the target does not parse as an address.
|
|
@@ -256,7 +237,7 @@ export function isEventAnchor(hit, anchor) {
|
|
|
256
237
|
*
|
|
257
238
|
* An address naming no note is a typo or an omission: the intent of a
|
|
258
239
|
* placeholder for a note nobody has written yet is spelled as a `draft`-tagged
|
|
259
|
-
* note that exists, resolves and renders marked. Both want fixing, and
|
|
240
|
+
* note that exists, resolves and renders marked. Both want fixing, and every build says so.
|
|
260
241
|
*
|
|
261
242
|
* @param {string} target - The address as authored, named in the message.
|
|
262
243
|
* @returns {string} The message, unpunctuated at the end as a finding is.
|
|
@@ -322,9 +303,9 @@ export function ambiguousAddressMessage(target, packages = []) {
|
|
|
322
303
|
/**
|
|
323
304
|
* The message for one link finding, whichever resolver found it.
|
|
324
305
|
*
|
|
325
|
-
* The single table the checker
|
|
326
|
-
*
|
|
327
|
-
*
|
|
306
|
+
* The single table the checker and the resolver report through, on every
|
|
307
|
+
* target, so one authored link cannot get different explanations of the same
|
|
308
|
+
* mistake depending on which build the author ran first. Callers
|
|
328
309
|
* add their own context around it — the note's name, the file it sits in — and
|
|
329
310
|
* never their own wording for the defect.
|
|
330
311
|
*
|
|
@@ -423,8 +404,9 @@ export function linkFindingMessage({
|
|
|
423
404
|
/**
|
|
424
405
|
* Whether a parsed link addresses a section of the page it is written on.
|
|
425
406
|
*
|
|
426
|
-
* `[[#some-heading]]` — no target, only an anchor.
|
|
427
|
-
* it before consulting any index, because there is nothing to look
|
|
407
|
+
* `[[#some-heading]]` — no target, only an anchor. The resolver and the checker
|
|
408
|
+
* special-case it before consulting any index, because there is nothing to look
|
|
409
|
+
* up.
|
|
428
410
|
*
|
|
429
411
|
* @param {ParsedWikilink} parsed - A parsed wikilink.
|
|
430
412
|
* @returns {boolean} True when the link is same-page.
|