@heroiclands/package-build 23.11.0 → 23.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +1 -0
  3. package/assets/preview/check-sanitizer.go +68 -0
  4. package/assets/preview/gitea-theme.js +36 -0
  5. package/assets/preview/gitea.ini +127 -0
  6. package/assets/styles/content-base.css +119 -16
  7. package/bin/content-commands.mjs +2 -2
  8. package/bin/package-build-preview.mjs +75 -0
  9. package/content-config.mjs +11 -0
  10. package/docs/api.md +105 -46
  11. package/docs/authoring/links-and-markup.md +46 -11
  12. package/docs/commands.md +46 -2
  13. package/docs/configuration.md +21 -4
  14. package/docs/design.md +4 -3
  15. package/docs/guides/book.md +10 -6
  16. package/docs/reference/format-details.md +33 -34
  17. package/docs/reference/note-types.md +0 -1
  18. package/docs/reference/theme-pages.md +1 -1
  19. package/docs/source-preview.md +172 -0
  20. package/engine/base-compiler.mjs +7 -8
  21. package/engine/being-age.mjs +19 -105
  22. package/engine/calendar-notes.mjs +3 -25
  23. package/engine/compile-corpus.mjs +2 -1
  24. package/engine/content-format.yaml +0 -1
  25. package/engine/content-index.mjs +9 -9
  26. package/engine/content-spans.mjs +28 -6
  27. package/engine/current-date.mjs +37 -0
  28. package/engine/definition-list.mjs +58 -0
  29. package/engine/frontmatter-lint.mjs +7 -2
  30. package/engine/helpers.mjs +2 -0
  31. package/engine/index.mjs +2 -0
  32. package/engine/infobox.mjs +1 -5
  33. package/engine/note-vocabulary.mjs +2 -2
  34. package/engine/npc-generate.mjs +7 -6
  35. package/engine/pdf-render.mjs +37 -4
  36. package/engine/preview-current-date.mjs +60 -0
  37. package/engine/site-build.mjs +5 -5
  38. package/engine/site-index.mjs +5 -2
  39. package/engine/site-preview.mjs +641 -325
  40. package/hugo-theme/layouts/_default/list.html +1 -1
  41. package/hugo-theme/layouts/blog/list.html +1 -1
  42. package/hugo-theme/layouts/blog-post/single.html +1 -1
  43. package/hugo-theme/layouts/partials/dictionary-lists.html +53 -0
  44. package/hugo-theme/layouts/partials/page-body.html +1 -1
  45. package/hugo-theme/static/css/style.css +22 -8
  46. package/package.json +8 -2
  47. package/types/content-config.d.mts +8 -0
  48. package/types/engine/base-compiler.d.mts +1 -1
  49. package/types/engine/being-age.d.mts +10 -44
  50. package/types/engine/calendar-notes.d.mts +2 -6
  51. package/types/engine/compile-corpus.d.mts +2 -1
  52. package/types/engine/content-index.d.mts +3 -1
  53. package/types/engine/content-spans.d.mts +4 -2
  54. package/types/engine/current-date.d.mts +9 -0
  55. package/types/engine/definition-list.d.mts +8 -0
  56. package/types/engine/frontmatter-lint.d.mts +6 -2
  57. package/types/engine/index.d.mts +2 -0
  58. package/types/engine/npc-generate.d.mts +2 -2
  59. package/types/engine/preview-current-date.d.mts +8 -0
  60. package/types/engine/site-index.d.mts +5 -1
  61. package/types/engine/site-preview.d.mts +40 -33
@@ -11,345 +11,661 @@
11
11
  * SPDX-License-Identifier: GPL-3.0-or-later
12
12
  */
13
13
 
14
- /** A private, reusable site renderer for an editor's active note. @module */
14
+ /** Buffer-only HTML preview shared by editors and external renderers. @module */
15
15
 
16
- import path from "node:path";
16
+ import MarkdownIt from "markdown-it";
17
+ import footnotePlugin from "markdown-it-footnote";
18
+ import deflistPlugin from "markdown-it-deflist";
19
+ import { dictionaryListPlugin } from "./definition-list.mjs";
20
+ import Handlebars from "handlebars";
17
21
  import matter from "gray-matter";
18
- import { indexRecordsFor, indexRecordsForNote } from "./content-index.mjs";
19
- import { noteAddressContext, decodeNoteAddresses, encodeAddresses } from "./note-addresses.mjs";
20
- import { noteFile } from "./index-records.mjs";
21
- import { foundryIdentities } from "./foundry-entries.mjs";
22
- import { applyComputedBeingAge, presentAmongRecords } from "./being-age.mjs";
23
- import { reckoningContext } from "./reckoning-markers.mjs";
24
- import { cachedMetadataIndexes, noContentIndexPackages } from "./metadata-index.mjs";
25
- import { seeAlsoPages } from "./see-also.mjs";
26
- import { buildSiteIndex, resolveInfoboxRef } from "./site-index.mjs";
27
- import {
28
- attachGeneratedSections,
29
- findSqlBlocks,
30
- generatedSectionsFor,
31
- joinGeneratedSections,
32
- openNotesDatabase,
33
- prepareSqlTables,
34
- } from "./sql-tables.mjs";
35
- import { findPageListBlocks } from "./page-lists.mjs";
36
- import { isGmNote } from "./note-vocabulary.mjs";
37
- import { relatedPages } from "./related-pages.mjs";
38
- import { assetAddressIndex } from "./art-fields.mjs";
39
- import { eventNoteIndex } from "./event-fields.mjs";
40
- import { frontmatterWikilinks } from "./web-wikilinks.mjs";
41
- import { positionOfLiteral } from "./diagnostics.mjs";
42
- import { addressSlug } from "./content-address.mjs";
43
- import { homepageTitle } from "./homepage.mjs";
44
- import { loadPackConfig } from "./pack-config.mjs";
45
- import { drawSiteMaps } from "./site-maps.mjs";
46
- import {
47
- collectContentPages,
48
- collectHomepages,
49
- gatesFailed,
50
- renderPages,
51
- renderSitePage,
52
- resolveSitePass,
53
- siteGates,
54
- sitePageDecorator,
55
- tableUniverse,
56
- } from "./site-build.mjs";
22
+ import { ALERT_TYPES, alertMarkdownPlugin, scanAlerts } from "./content-alerts.mjs";
23
+ import { spanMarkdownPlugin } from "./content-spans.mjs";
24
+ import { scanPoetry, poetryStanzas } from "./content-poetry.mjs";
25
+ import { scanFigures } from "./content-figures.mjs";
26
+ import { scanBlocks } from "./content-blocks.mjs";
27
+ import { splitHeadingAttributes } from "./heading-attributes.mjs";
28
+ import { EXPRESSION, renderMarkdownExpressions } from "./markdown-expressions.mjs";
29
+ import { WIKILINK, parseWikilink } from "./wikilink-syntax.mjs";
30
+ import { dataFields } from "./note-vocabulary.mjs";
31
+ import { applyComputedBeingAge } from "./being-age.mjs";
32
+ import { DEFAULT_CURRENT_DATE, normalizeCurrentDate } from "./current-date.mjs";
57
33
 
58
- /** Prepare saved project state once, then render live text without writing a site. */
59
- export async function prepareSitePreview({ config = loadPackConfig() } = {}) {
60
- const contentBase = config.paths.content;
61
- const base = config.site.base || `/${config.contentPackage}/`;
62
- const dependencies = cachedMetadataIndexes(config);
63
- const pass = resolveSitePass(config.site.pass, {
64
- ...config.site.passOptions,
65
- repoRoot: config.rootDir,
66
- });
67
- const context = {
68
- config,
69
- packages: new Set(
70
- config.site.packages.length ? config.site.packages : [config.contentPackage],
71
- ),
72
- contentPackage: config.contentPackage,
73
- skipDirectories: config.skipDirectories,
74
- base,
75
- mount: `${base}${config.publish.address.prefix}`,
76
- scheme: config.publish.address,
77
- };
78
- let snapshot;
34
+ /** Explicit dependency policy: a new registered helper requires a preview decision. */
35
+ export const PREVIEW_HELPERS = Object.freeze({
36
+ eq: "local",
37
+ gt: "local",
38
+ gte: "local",
39
+ lt: "local",
40
+ lte: "local",
41
+ not: "local",
42
+ and: "local",
43
+ or: "local",
44
+ words: "local",
45
+ digits: "local",
46
+ sql: "dependent",
47
+ dateformat: "dependent",
48
+ ref: "reference",
49
+ });
79
50
 
80
- async function load() {
81
- const records = indexRecordsFor({ config });
82
- const ctx = { ...context, records };
83
- const { pages, ...findings } = collectContentPages(contentBase, ctx);
84
- const homepages = collectHomepages(contentBase, ctx).pages;
85
- const homeEntries = homepages.map((home) => ({
86
- kind: "content",
87
- fm: home.fm,
88
- body: home.body,
89
- pkg: config.contentPackage,
90
- name: home.fm.name?.full ?? homepageTitle(home.fm, config),
91
- title: homepageTitle(home.fm, config),
92
- slug: addressSlug(home.fm),
93
- base: path.basename(home.file),
94
- url: base,
95
- }));
96
- const gates = siteGates([...pages, ...homeEntries], findings, { config, records });
97
- if (gatesFailed(gates))
98
- throw new Error("site preview cannot prepare: site integrity gates failed");
99
- const db = await openNotesDatabase(
100
- records.filter((record) => !isGmNote(record)),
101
- {
102
- dependencies,
103
- addressContext: noteAddressContext(config),
104
- audience: "public",
105
- },
106
- );
107
- try {
108
- const sources = pages
109
- .filter(
110
- (page) =>
111
- findSqlBlocks(page.body).length || findPageListBlocks(page.body).length,
51
+ const escapeHtml = new MarkdownIt().utils.escapeHtml;
52
+ const escape = (value) => escapeHtml(String(value ?? ""));
53
+ const colored = (role, source) => `<span class="hl-preview-${role}">${escape(source)}</span>`;
54
+
55
+ /** Highlight without interpreting any Markdown or HTML inside the source. */
56
+ function wikiSource(raw) {
57
+ const open = raw.startsWith("!") ? "![[" : "[[";
58
+ const close = raw.indexOf("]]", open.length);
59
+ const end = close < 0 ? raw.length : close;
60
+ const inner = raw.slice(open.length, end);
61
+ const pipe = inner.indexOf("|");
62
+ const secret = close >= 0 && parseWikilink(inner, raw).attributes?.classes.includes("secret");
63
+ const label =
64
+ colored("address", pipe < 0 ? inner : inner.slice(0, pipe)) +
65
+ (pipe < 0 ? "" : colored("bracket", "|") + colored("label", inner.slice(pipe + 1)));
66
+ const html =
67
+ colored("bracket", open) +
68
+ (secret ? `<span class="hl-preview-secret-content">${label}</span>` : label) +
69
+ (close < 0 ? "" : colored("bracket", "]]") + colored("attribute", raw.slice(close + 2)));
70
+ return secret ? `<span class="hl-preview-secret" tabindex="0">${html}</span>` : html;
71
+ }
72
+
73
+ /** Read a dependent call anywhere in the Handlebars AST, including hash values. */
74
+ function dependentExpression(source, fm) {
75
+ const authoredValue = (node) => {
76
+ if (node?.type === "StringLiteral") return node.value;
77
+ if (node?.type !== "PathExpression" || node.depth || node.data || !node.parts?.length)
78
+ return undefined;
79
+ let value = fm;
80
+ for (const part of node.parts) {
81
+ if (!value || typeof value !== "object" || !Object.hasOwn(value, part))
82
+ return undefined;
83
+ value = value[part];
84
+ }
85
+ return value;
86
+ };
87
+ const visit = (node) => {
88
+ if (!node || typeof node !== "object") return false;
89
+ if (["MustacheStatement", "SubExpression"].includes(node.type)) {
90
+ const name = node.path?.original;
91
+ const policy = PREVIEW_HELPERS[name];
92
+ if (policy === "dependent") return true;
93
+ if (policy === "reference") {
94
+ const target = authoredValue(node.params?.[0]);
95
+ if (typeof target !== "string" || !target.startsWith("#")) return true;
96
+ // Date-valued event fields need calendar context even on this note.
97
+ if (
98
+ node.hash?.pairs?.some(
99
+ (pair) =>
100
+ pair.key === "field" &&
101
+ !["kind", "summary", "name"].includes(pair.value?.value),
102
+ )
112
103
  )
113
- .map((page) => ({ source: page.file, markdown: page.body }));
114
- const publicRecords = records.filter((record) => !isGmNote(record));
115
- const generated = await generatedSectionsFor(db, publicRecords, contentBase, {
116
- foreignIndex: gates.foreign.index,
117
- config,
118
- });
119
- joinGeneratedSections(generated, sources);
120
- const sqlTables = await prepareSqlTables(db, sources, { records: publicRecords });
121
- attachGeneratedSections(sqlTables, generated);
122
- const drawings = drawSiteMaps({
123
- records,
124
- foreignIndex: gates.foreign.index,
125
- config,
126
- base,
127
- }).maps;
128
- const rendered = renderPages(pages, {
129
- index: gates.index,
130
- foreign: gates.foreign,
131
- universe: tableUniverse(pages),
132
- pass,
133
- decorate: sitePageDecorator(config, gates.index),
134
- sqlTables,
135
- config,
136
- records,
137
- homepages: homeEntries,
138
- maps: drawings,
139
- write: false,
140
- capture: "graph",
141
- });
142
- return { records, pages, homeEntries, gates, db, rendered, drawings };
143
- } catch (error) {
144
- await db.close();
145
- throw error;
104
+ return true;
105
+ }
106
+ if ((node.params?.length || node.hash?.pairs?.length) && !policy) return true;
146
107
  }
108
+ return Object.values(node).some((value) =>
109
+ Array.isArray(value) ? value.some(visit) : visit(value),
110
+ );
111
+ };
112
+ try {
113
+ return visit(Handlebars.parse(source));
114
+ } catch {
115
+ return true;
147
116
  }
117
+ }
148
118
 
149
- snapshot = await load();
150
-
151
- return {
152
- /** Render the active buffer. Other local and foreign notes remain saved. */
153
- async render(file, text) {
154
- const absolute = path.resolve(file);
155
- const original = snapshot.pages.find((page) => page.file === absolute);
156
- if (!original)
157
- return {
158
- ok: false,
159
- findings: [
160
- {
161
- file: absolute,
162
- severity: "error",
163
- message: "note is not a published site page",
164
- },
165
- ],
166
- };
167
- let fm;
168
- let parsed;
169
- try {
170
- parsed = matter(text);
171
- fm = decodeNoteAddresses(parsed.data, {
172
- ...noteAddressContext(config),
173
- package: config.contentPackage,
174
- });
175
- applyComputedBeingAge(
176
- fm,
177
- presentAmongRecords(snapshot.records),
178
- reckoningContext({ notes: snapshot.records }),
179
- );
180
- } catch (error) {
181
- return {
182
- ok: false,
183
- findings: [
184
- { file: absolute, severity: "error", message: String(error.message) },
185
- ],
186
- };
119
+ function parserFor(fm, findings, file) {
120
+ const md = new MarkdownIt({ html: false, linkify: false })
121
+ .use(footnotePlugin)
122
+ .use(deflistPlugin)
123
+ .use(dictionaryListPlugin)
124
+ .use(alertMarkdownPlugin)
125
+ .use(spanMarkdownPlugin, { source: true });
126
+ const figures = new Map();
127
+ const inline = (text, env = {}) => {
128
+ const tokens = [];
129
+ md.inline.parse(text, md, env, tokens);
130
+ return md.renderer.renderInline(tokens, md.options, env);
131
+ };
132
+ md.renderer.rules.hl_preview_source = (tokens, idx) => tokens[idx].meta.html;
133
+ // Run before ordinary links and text so protected source becomes one opaque token.
134
+ md.inline.ruler.before("link", "hl_preview_source", (state, silent) => {
135
+ const rest = state.src.slice(state.pos);
136
+ let raw, html;
137
+ if (rest.startsWith("[[") || rest.startsWith("![[")) {
138
+ const match = new RegExp(`^(?:!)?${WIKILINK.source.replace("(?<!!)", "")}`).exec(rest);
139
+ raw = match?.[0] ?? rest.split("\n")[0];
140
+ // Preserve a partially entered trailing attribute too.
141
+ if (rest[raw.length] === "{") {
142
+ let quoted = false,
143
+ escaped = false,
144
+ end = raw.length + 1;
145
+ for (; end < rest.length && rest[end] !== "\n"; end++) {
146
+ const char = rest[end];
147
+ if (escaped) {
148
+ escaped = false;
149
+ continue;
150
+ }
151
+ if (char === "\\") {
152
+ escaped = true;
153
+ continue;
154
+ }
155
+ if (char === '"') quoted = !quoted;
156
+ if (char === "}" && !quoted) {
157
+ end++;
158
+ break;
159
+ }
160
+ }
161
+ raw = rest.slice(0, end);
187
162
  }
188
- const body = parsed.content;
189
- const bodyLine = text.slice(0, text.length - body.length).split("\n").length;
190
- const findings = frontmatterWikilinks(fm).map((hit) => ({
191
- file: absolute,
192
- severity: "error",
193
- ...hit,
194
- }));
195
- let slug;
196
- try {
197
- slug = addressSlug(fm);
198
- } catch (error) {
199
- findings.push({ file: absolute, severity: "error", message: error.message });
163
+ html = wikiSource(raw);
164
+ } else if (rest.startsWith("{{")) {
165
+ const match = new RegExp(`^${EXPRESSION.source.replace("(?<![\\\\{])", "")}`).exec(
166
+ rest,
167
+ );
168
+ raw = match?.[0] ?? rest.split("\n")[0];
169
+ if (!dependentExpression(raw, fm) && match) {
170
+ const entries =
171
+ Array.isArray(fm.data?.events) ?
172
+ fm.data.events.filter((entry) => entry && typeof entry === "object")
173
+ : [];
174
+ const own = {
175
+ events: entries.map((entry) => ({ id: entry.id, entry })),
176
+ anchors: entries
177
+ .filter((entry) => entry.id)
178
+ .map((entry) => ({ slug: entry.id, kind: "event" })),
179
+ };
180
+ const events = { note: () => undefined, own: () => own };
181
+ const result = renderMarkdownExpressions(raw, { fm, figures, events, file });
182
+ if (!result.findings.length)
183
+ html = /^{{\s*ref\b/.test(raw) ? inline(result.markdown) : result.markdown;
200
184
  }
201
- if (findings.length) return { ok: false, findings };
202
- const page = {
203
- ...original,
204
- fm,
205
- body,
206
- bodyLine,
207
- name: fm.name?.full ?? path.basename(absolute, ".md"),
208
- slug,
209
- url: `${base}${slug}/`,
210
- };
211
- const changedMetadata = JSON.stringify(fm) !== JSON.stringify(original.fm);
212
- let records = snapshot.records;
213
- let db = snapshot.db;
214
- let index = snapshot.gates.index;
215
- try {
216
- if (changedMetadata) {
217
- const overlayRecords = indexRecordsForNote({
218
- frontmatter: fm,
219
- relPath: original.relPath,
220
- absPath: absolute,
221
- contentPackage: config.contentPackage,
222
- body,
223
- bodyLine,
224
- manifest: foundryIdentities(config),
225
- addressContext: noteAddressContext(config),
226
- });
227
- records = snapshot.records.filter(
228
- (entry) => noteFile(contentBase, entry) !== absolute,
229
- );
230
- records.push(...overlayRecords);
231
- const allPages = snapshot.pages.map((item) =>
232
- item.file === absolute ? page : item,
233
- );
234
- index = buildSiteIndex([...allPages, ...snapshot.homeEntries], {
235
- package: config.contentPackage,
236
- foreignIndex: snapshot.gates.foreign.index,
237
- foreignReferences: snapshot.gates.foreign.references,
238
- records,
239
- noIndexPackages: noContentIndexPackages(config),
240
- });
241
- db = await openNotesDatabase(
242
- records.filter((record) => !isGmNote(record)),
243
- {
244
- dependencies,
245
- addressContext: noteAddressContext(config),
246
- audience: "public",
247
- },
248
- );
249
- }
250
- const publicRecords = records.filter((record) => !isGmNote(record));
251
- const sources = [{ source: absolute, markdown: body }];
252
- const generated = await generatedSectionsFor(db, publicRecords, contentBase, {
253
- only: absolute,
254
- foreignIndex: snapshot.gates.foreign.index,
255
- config,
256
- });
257
- joinGeneratedSections(generated, sources);
258
- const sqlTables = await prepareSqlTables(db, sources, { records: publicRecords });
259
- attachGeneratedSections(sqlTables, generated);
260
- const allPages = snapshot.pages.map((item) =>
261
- item.file === absolute ? page : item,
262
- );
263
- const result = renderSitePage(page, {
264
- index,
265
- foreign: snapshot.gates.foreign,
266
- universe: tableUniverse(allPages),
267
- pass,
268
- decorate: sitePageDecorator(config, index),
269
- sqlTables,
270
- config,
271
- artIndex: assetAddressIndex(records, {
272
- config,
273
- foreign: snapshot.gates.foreign,
274
- types: index.contentTypes,
275
- }),
276
- events: eventNoteIndex(records, {
277
- contentPackage: index.contentPackage,
278
- foreignIndex: snapshot.gates.foreign?.index,
279
- dates: index.dateContext,
280
- }),
281
- drawings: snapshot.drawings,
282
- });
283
- for (const error of result.tableErrors)
284
- findings.push({
285
- file: absolute,
286
- line: bodyLine + error.line,
287
- column: 1,
288
- severity: "error",
289
- message: error.reason ?? error.message,
290
- });
291
- for (const error of result.secretErrors)
292
- findings.push({ ...error, severity: "error" });
293
- for (const error of result.captionErrors)
294
- findings.push({ ...error, severity: "error" });
295
- for (const error of result.headingErrors)
296
- findings.push({ ...error, severity: "error" });
297
- for (const error of result.footnoteErrors)
298
- findings.push({ ...error, severity: "error" });
299
- for (const error of result.wikiErrors) {
300
- const pos = positionOfLiteral(text, error.link, error.occurrence);
301
- findings.push({
302
- ...error,
303
- ...pos,
304
- severity: "error",
305
- message: error.message ?? error.reason ?? "unresolved wikilink",
306
- });
185
+ html ??= colored("dependent", raw);
186
+ } else return false;
187
+ if (!silent) {
188
+ const token = state.push("hl_preview_source", "", 0);
189
+ token.meta = { html };
190
+ }
191
+ state.pos += raw.length;
192
+ return true;
193
+ });
194
+ md.renderer.rules.span_source = (tokens, idx) => {
195
+ const token = tokens[idx];
196
+ const end = token.meta.labelEnd;
197
+ const secret = Boolean(token.meta.secret);
198
+ const classes = `hl-preview-span${secret ? " hl-preview-secret" : ""}`;
199
+ const focus = secret ? ' tabindex="0"' : "";
200
+ const label = colored("label", token.content.slice(1, end));
201
+ return `<span class="${classes}"${focus}>${
202
+ colored("bracket", "[") +
203
+ (secret ? `<span class="hl-preview-secret-content">${label}</span>` : label) +
204
+ colored("bracket", "]") +
205
+ colored("attribute", token.content.slice(end + 1))
206
+ }</span>`;
207
+ };
208
+ // Keep extension pipes inside their cell. Markdown's table splitter only
209
+ // understands escaped pipes, before inline rules have a chance to see them.
210
+ md.block.ruler.before(
211
+ "table",
212
+ "hl_preview_table",
213
+ (state, start, end, silent) => {
214
+ const lineAt = (line) =>
215
+ state.src.slice(state.bMarks[line] + state.tShift[line], state.eMarks[line]);
216
+ const cells = (line) => {
217
+ const result = [];
218
+ let cell = "",
219
+ wiki = false,
220
+ code = "",
221
+ quote = false,
222
+ braces = 0;
223
+ for (let at = 0; at < line.length; at++) {
224
+ const char = line[at];
225
+ if (char === "\\" && at + 1 < line.length) {
226
+ cell += char + line[++at];
227
+ continue;
228
+ }
229
+ if (char === "`" && !wiki) {
230
+ const marks = /^`+/.exec(line.slice(at))[0];
231
+ if (!code) code = marks;
232
+ else if (code === marks) code = "";
233
+ cell += marks;
234
+ at += marks.length - 1;
235
+ continue;
236
+ }
237
+ if (!code) {
238
+ if (line.slice(at, at + 2) === "[[") wiki = true;
239
+ if (line.slice(at - 1, at + 1) === "]]") wiki = false;
240
+ if (char === '"' && braces) quote = !quote;
241
+ if (!quote && char === "{") braces++;
242
+ if (!quote && char === "}" && braces) braces--;
243
+ }
244
+ if (char === "|" && !wiki && !code && !braces) {
245
+ result.push(cell.trim());
246
+ cell = "";
247
+ } else cell += char;
307
248
  }
308
- for (const error of result.imageErrors) {
309
- const pos = positionOfLiteral(text, error.src, error.occurrence);
310
- findings.push({ ...error, ...pos, severity: "error" });
249
+ result.push(cell.trim());
250
+ if (!result[0]) result.shift();
251
+ if (!result.at(-1)) result.pop();
252
+ return result;
253
+ };
254
+ if (start + 1 >= end || state.sCount[start] - state.blkIndent >= 4) return false;
255
+ const header = cells(lineAt(start)),
256
+ separators = cells(lineAt(start + 1));
257
+ if (
258
+ !lineAt(start).includes("|") ||
259
+ !header.length ||
260
+ separators.length !== header.length ||
261
+ !separators.every((cell) => /^:?-+:?$/.test(cell))
262
+ )
263
+ return false;
264
+ if (silent) return true;
265
+ const row = (values, tag) => {
266
+ state.push("tr_open", "tr", 1);
267
+ for (let col = 0; col < header.length; col++) {
268
+ const cell = state.push(`${tag}_open`, tag, 1);
269
+ const alignment =
270
+ separators[col].startsWith(":") ?
271
+ separators[col].endsWith(":") ?
272
+ "center"
273
+ : "left"
274
+ : separators[col].endsWith(":") ? "right"
275
+ : "";
276
+ if (alignment) cell.attrSet("class", `hl-preview-align-${alignment}`);
277
+ const token = state.push("inline", "", 0);
278
+ token.content = values[col] ?? "";
279
+ token.children = [];
280
+ state.push(`${tag}_close`, tag, -1);
311
281
  }
312
- for (const error of result.embedErrors)
313
- findings.push({ ...error, severity: "error" });
314
- if (findings.length) return { ok: false, findings };
315
- const edges = snapshot.rendered.edges.filter(([source]) => source !== original.url);
316
- for (const hit of result.resolved) if (hit.url) edges.push([page.url, hit.url]);
317
- const entries = new Map(snapshot.rendered.entries);
318
- entries.delete(original.url);
319
- entries.set(page.url, {
320
- title: page.name,
321
- url: page.url,
322
- type: String(fm.type),
323
- });
324
- const deliberate = seeAlsoPages([page], (ref) => resolveInfoboxRef(index, ref));
325
- const related = relatedPages(edges, entries, deliberate).get(page.url);
326
- if (related) result.data.related = related;
327
- return {
328
- ok: true,
329
- markdown: result.body,
330
- frontmatter: encodeAddresses(result.data),
331
- findings: [],
332
- };
333
- } catch (error) {
334
- return {
335
- ok: false,
336
- findings: [
337
- { file: absolute, severity: "error", message: String(error.message) },
338
- ],
282
+ state.push("tr_close", "tr", -1);
283
+ };
284
+ const open = state.push("table_open", "table", 1);
285
+ open.map = [start, start + 2];
286
+ state.push("thead_open", "thead", 1);
287
+ row(header, "th");
288
+ state.push("thead_close", "thead", -1);
289
+ state.push("tbody_open", "tbody", 1);
290
+ let line = start + 2;
291
+ for (; line < end && lineAt(line).trim() && lineAt(line).includes("|"); line++)
292
+ row(cells(lineAt(line)), "td");
293
+ state.push("tbody_close", "tbody", -1);
294
+ state.push("table_close", "table", -1);
295
+ open.map[1] = line;
296
+ state.line = line;
297
+ return true;
298
+ },
299
+ { alt: ["paragraph", "reference"] },
300
+ );
301
+ const quoteOpen = md.renderer.rules.blockquote_open;
302
+ md.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
303
+ const block = tokens[idx].meta?.alert;
304
+ if (!block)
305
+ return quoteOpen ? quoteOpen(tokens, idx, options, env, self) : "<blockquote>\n";
306
+ const id = block.id ? ` id="${escape(block.id)}"` : "";
307
+ return `<blockquote class="alert alert-${block.type}"${id}>\n`;
308
+ };
309
+ md.renderer.rules.hl_preview_alert_title_open = (tokens, idx) =>
310
+ `<p class="alert-title">${ALERT_TYPES[tokens[idx].meta.type].svg}`;
311
+ const fence = md.renderer.rules.fence;
312
+ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
313
+ const token = tokens[idx];
314
+ const name = token.info.trim().split(/\s/)[0];
315
+ if (["sql", "pagelist", "map"].includes(name)) {
316
+ const raw =
317
+ token.meta?.source ??
318
+ `${token.markup}${token.info}\n${token.content}${token.markup}`;
319
+ return `<pre class="hl-preview-${name === "map" ? "dependent" : "fence"}"><code>${escape(raw)}</code></pre>\n`;
320
+ }
321
+ return fence(tokens, idx, options, env, self);
322
+ };
323
+ // Capture fences from their original source lines, retaining all delimiters.
324
+ md.core.ruler.before("inline", "hl_preview_structure", (state) => {
325
+ const lines = state.src.split("\n");
326
+ const alertErrors = scanAlerts(state.src).errors;
327
+ for (let i = state.tokens.length - 1; i >= 0; i--) {
328
+ const token = state.tokens[i],
329
+ alert = token.meta?.alert;
330
+ if (
331
+ !alert ||
332
+ !alertErrors.some(
333
+ (error) => error.line >= alert.start + 1 && error.line <= alert.end + 1,
334
+ )
335
+ )
336
+ continue;
337
+ let depth = 1,
338
+ end = i + 1;
339
+ for (; end < state.tokens.length; end++) {
340
+ if (state.tokens[end].type === "blockquote_open") depth++;
341
+ if (state.tokens[end].type === "blockquote_close" && --depth === 0) break;
342
+ }
343
+ token.type = "hl_preview_source";
344
+ token.nesting = 0;
345
+ token.meta = {
346
+ html: `<pre>${escape(lines.slice(alert.start, alert.end + 1).join("\n"))}</pre>`,
347
+ };
348
+ state.tokens.splice(i + 1, end - i);
349
+ }
350
+ for (let i = state.tokens.length - 1; i >= 0; i--) {
351
+ const block = state.tokens[i].meta?.alert;
352
+ if (!block) continue;
353
+ const open = new state.Token("hl_preview_alert_title_open", "p", 1);
354
+ open.meta = block;
355
+ const title = new state.Token("inline", "", 0);
356
+ title.content = block.title;
357
+ title.children = [];
358
+ const close = new state.Token("paragraph_close", "p", -1);
359
+ state.tokens.splice(i + 1, 0, open, title, close);
360
+ }
361
+ const poetry = scanPoetry(state.src).blocks;
362
+ const localFigures = scanFigures(state.src).figures;
363
+ for (const fig of localFigures) if (fig.id) figures.set(fig.id, fig);
364
+ for (const token of state.tokens) {
365
+ if (token.type === "fence")
366
+ token.meta = {
367
+ ...token.meta,
368
+ source: lines.slice(token.map[0], token.map[1]).join("\n"),
369
+ poem: poetry.find((poem) => poem.start === token.map[0]),
339
370
  };
340
- } finally {
341
- if (db !== snapshot.db) await db.close();
371
+ }
372
+ // Poetry must become inline tokens before footnote collection, so a
373
+ // definition outside the fence remains visible to a verse reference.
374
+ for (let i = state.tokens.length - 1; i >= 0; i--) {
375
+ const token = state.tokens[i];
376
+ const poem = token.meta?.poem;
377
+ if (!poem) continue;
378
+ const replacement = [];
379
+ const push = (type, tag, nesting, attrs) => {
380
+ const part = new state.Token(type, tag, nesting);
381
+ if (attrs) part.attrs = attrs;
382
+ replacement.push(part);
383
+ return part;
384
+ };
385
+ push("hl_preview_poetry_open", "div", 1, [["class", "poetry"]]).map = token.map;
386
+ for (const stanza of poetryStanzas(poem.body)) {
387
+ push("paragraph_open", "p", 1);
388
+ stanza.forEach(({ text, level }, row) => {
389
+ if (row) push("hardbreak", "br", 0);
390
+ push("hl_preview_poetry_line_open", "span", 1, [
391
+ ["class", `poetry-line i${level}`],
392
+ ]);
393
+ const line = push("inline", "", 0);
394
+ line.content = text;
395
+ line.children = [];
396
+ push("hl_preview_poetry_line_close", "span", -1);
397
+ });
398
+ push("paragraph_close", "p", -1);
342
399
  }
400
+ push("hl_preview_poetry_close", "div", -1);
401
+ state.tokens.splice(i, 1, ...replacement);
402
+ }
403
+ // Work backwards so figure ranges and heading removals retain source maps.
404
+ for (let i = state.tokens.length - 1; i >= 0; i--) {
405
+ const token = state.tokens[i];
406
+ if (token.type !== "heading_open") continue;
407
+ const content = state.tokens[i + 1];
408
+ const parsed = splitHeadingAttributes(content.content);
409
+ if (/^(?:see also|related pages)$/i.test(parsed.text)) {
410
+ const level = Number(token.tag.slice(1));
411
+ let end = i + 3;
412
+ while (
413
+ end < state.tokens.length &&
414
+ !(
415
+ state.tokens[end].type === "heading_open" &&
416
+ Number(state.tokens[end].tag.slice(1)) <= level
417
+ )
418
+ )
419
+ end++;
420
+ state.tokens.splice(i, end - i);
421
+ } else if (!parsed.problems.length) {
422
+ content.content = parsed.text;
423
+ if (parsed.id) token.attrSet("id", parsed.id);
424
+ token.meta = { ...token.meta, secret: parsed.classes.includes("secret") };
425
+ }
426
+ }
427
+ for (const fig of localFigures.reverse()) {
428
+ const first = state.tokens.findIndex(
429
+ (token) => token.type === "paragraph_open" && token.map?.[0] === fig.captionStart,
430
+ );
431
+ if (first < 0) continue;
432
+ let after = first + 3;
433
+ while (
434
+ after < state.tokens.length &&
435
+ (state.tokens[after].map?.[0] ?? -1) < fig.bodyEnd
436
+ )
437
+ after++;
438
+ // Match closing tokens of the last block as well.
439
+ while (
440
+ after < state.tokens.length &&
441
+ !state.tokens[after].map &&
442
+ state.tokens[after].nesting < 0
443
+ )
444
+ after++;
445
+ const open = new state.Token("hl_preview_figure_open", "div", 1);
446
+ open.meta = fig;
447
+ const caption = new state.Token("inline", "", 0);
448
+ caption.content = `${fig.label}${fig.label && fig.hasCaption ? ": " : ""}${fig.caption}`;
449
+ caption.children = [];
450
+ const titleOpen = new state.Token("paragraph_open", "p", 1);
451
+ titleOpen.attrSet("class", "content-figure-label");
452
+ const titleClose = new state.Token("paragraph_close", "p", -1);
453
+ const close = new state.Token("hl_preview_figure_close", "div", -1);
454
+ state.tokens.splice(after, 0, titleOpen, caption, titleClose, close);
455
+ state.tokens.splice(first, 3, open);
456
+ }
457
+ // Use parsed heading tokens so fenced examples are never section boundaries.
458
+ // Keep all inline tokens in this document for shared footnote collection.
459
+ const output = [],
460
+ sections = [];
461
+ const closeSection = () => {
462
+ output.push(new state.Token("details_close", "details", -1));
463
+ sections.pop();
464
+ };
465
+ for (const token of state.tokens) {
466
+ const level = token.type === "heading_open" ? Number(token.tag.slice(1)) : null;
467
+ while (
468
+ sections.length &&
469
+ ((token.nesting === -1 && token.level < sections.at(-1).scope) ||
470
+ (level !== null &&
471
+ token.level <= sections.at(-1).scope &&
472
+ level <= sections.at(-1).level))
473
+ )
474
+ closeSection();
475
+ if (token.type === "heading_open" && token.meta?.secret) {
476
+ const open = new state.Token("details_open", "details", 1);
477
+ open.attrSet("class", "secret");
478
+ output.push(open, new state.Token("summary_open", "summary", 1));
479
+ const title = new state.Token("inline", "", 0);
480
+ title.content = "Secret";
481
+ title.children = [];
482
+ output.push(title, new state.Token("summary_close", "summary", -1));
483
+ sections.push({ level, scope: token.level });
484
+ }
485
+ output.push(token);
486
+ }
487
+ while (sections.length) closeSection();
488
+ state.tokens = output;
489
+ });
490
+ md.renderer.rules.hl_preview_figure_open = (tokens, idx) => {
491
+ const fig = tokens[idx].meta;
492
+ return `<div class="content-figure content-figure-${escape(fig.kind)}"${fig.slug ? ` id="${escape(fig.slug)}"` : ""}>\n`;
493
+ };
494
+ md.renderer.rules.hl_preview_figure_close = () => "</div>\n";
495
+ // Shared container parser determines complete nested ranges; tokenizing each
496
+ // body within the same state keeps document footnotes in scope.
497
+ md.block.ruler.before(
498
+ "fence",
499
+ "hl_preview_container",
500
+ (state, start, end, silent) => {
501
+ if (!state.src.slice(state.bMarks[start] + state.tShift[start]).startsWith(":::"))
502
+ return false;
503
+ const block = scanBlocks(state.src).blocks.find((block) => block.start === start);
504
+ if (!block || block.end >= end) return false;
505
+ if (silent) return true;
506
+ const open = state.push(
507
+ "hl_preview_container_open",
508
+ block.name === "secret" ? "details" : "div",
509
+ 1,
510
+ );
511
+ open.meta = block;
512
+ if (block.name === "secret") {
513
+ state.push("summary_open", "summary", 1);
514
+ const title = state.push("inline", "", 0);
515
+ title.content = block.title;
516
+ title.children = [];
517
+ state.push("summary_close", "summary", -1);
518
+ }
519
+ const oldMax = state.lineMax;
520
+ state.lineMax = block.end;
521
+ state.md.block.tokenize(state, start + 1, block.end);
522
+ state.lineMax = oldMax;
523
+ const close = state.push("hl_preview_container_close", open.tag, -1);
524
+ close.meta = block;
525
+ state.line = block.end + 1;
526
+ return true;
343
527
  },
344
- /** Re-read saved notes and dependency indexes after a save. */
345
- async refresh() {
346
- const next = await load();
347
- const previous = snapshot;
348
- snapshot = next;
349
- await previous.db.close();
350
- },
351
- async close() {
352
- await snapshot.db.close();
353
- },
528
+ { alt: ["paragraph", "reference", "blockquote", "list"] },
529
+ );
530
+ md.renderer.rules.hl_preview_container_open = (tokens, idx, _options, env) => {
531
+ const block = tokens[idx].meta;
532
+ const attrs = `${block.id ? ` id="${escape(block.id)}"` : ""} class="${escape([block.name, ...block.classes].join(" "))}"`;
533
+ return block.name === "secret" ? `<details${attrs}>` : `<div${attrs}>\n`;
354
534
  };
535
+ md.renderer.rules.hl_preview_container_close = (tokens, idx) => `</${tokens[idx].tag}>\n`;
536
+ return md;
537
+ }
538
+
539
+ /** A small profile from authored fields; references never acquire resolved data. */
540
+ function localInfobox(fm, md) {
541
+ const fields = dataFields(fm.type) ?? [];
542
+ const rows = [];
543
+ const present = (value) => (typeof value === "string" ? value : JSON.stringify(value));
544
+ for (const [name, value] of Object.entries(fm.data ?? {})) {
545
+ if (["seeAlso", "related", "pack", "packFolder"].includes(name)) continue;
546
+ if (name === "age" && (value === null || value === undefined || value === "")) continue;
547
+ const field = fields.find((field) => field.name === name);
548
+ const source = present(value);
549
+ let html;
550
+ if (
551
+ typeof value === "string" &&
552
+ /^!?\[\[/.test(value) &&
553
+ (/^!/.test(value) || ["portrait", "banner", "icon", "token", "image"].includes(name))
554
+ )
555
+ html = wikiSource(value);
556
+ else if (
557
+ field?.kind === "address" ||
558
+ field?.entryKind === "address" ||
559
+ field?.ref ||
560
+ field?.accepts ||
561
+ ["born", "died", "calendar", "map", "events"].includes(name)
562
+ )
563
+ html = colored("dependent", source);
564
+ else html = md.renderInline(source ?? "");
565
+ rows.push(`<dt>${escape(name)}</dt><dd>${html}</dd>`);
566
+ }
567
+ if (!rows.length) return "";
568
+ return `<aside class="hl-preview-infobox"><dl>${rows.join("")}</dl></aside>`;
569
+ }
570
+
571
+ /**
572
+ * Render one complete buffer with no configuration, index, filesystem or database.
573
+ * @param {string} text - Markdown, optionally beginning with YAML frontmatter.
574
+ * @param {{dark?: boolean, infobox?: boolean, file?: string, currentDate?: string}} [options] - Explicit palette, profile visibility, diagnostic filename and neutral current date (default "720.1").
575
+ * @returns {{ok: boolean, html: string, frontmatter: object, findings: object[]}} HTML and metadata with locally calculable ages; unavailable ages remain absent or null.
576
+ */
577
+ export function renderPreview(
578
+ text,
579
+ { dark = false, infobox = true, file = "", currentDate = DEFAULT_CURRENT_DATE } = {},
580
+ ) {
581
+ const date = normalizeCurrentDate(currentDate);
582
+ const findings = [];
583
+ let frontmatter = {},
584
+ body = String(text ?? "");
585
+ try {
586
+ const parsed = matter(body);
587
+ frontmatter =
588
+ parsed.data && typeof parsed.data === "object" && !Array.isArray(parsed.data) ?
589
+ structuredClone(parsed.data)
590
+ : {};
591
+ body = parsed.content;
592
+ } catch (error) {
593
+ findings.push({ file, severity: "warning", message: String(error.message) });
594
+ }
595
+ applyComputedBeingAge(frontmatter, date);
596
+ if (String(frontmatter.type ?? "").toLowerCase() === "being") {
597
+ frontmatter.data ??= {};
598
+ if (
599
+ typeof frontmatter.data === "object" &&
600
+ !Array.isArray(frontmatter.data) &&
601
+ !Object.hasOwn(frontmatter.data, "age")
602
+ )
603
+ frontmatter.data.age = null;
604
+ }
605
+ const md = parserFor(frontmatter, findings, file);
606
+ const content = md.render(body);
607
+ const profile = infobox ? localInfobox(frontmatter, md) : "";
608
+ return {
609
+ ok: true,
610
+ html: `<div class="hl-preview hl-preview-${dark ? "dark" : "light"}">${profile}<main class="hl-preview-main">${content}</main></div>`,
611
+ frontmatter,
612
+ findings,
613
+ };
614
+ }
615
+
616
+ /**
617
+ * Opt in to reading only the project's currentDate setting; never called by renderPreview.
618
+ * @param {string} root - Directory from which to find the nearest package configuration.
619
+ * @returns {Promise<string>} Validated neutral date, or the shared default when no setting is present.
620
+ */
621
+ export async function readPreviewCurrentDate(root) {
622
+ const reader = await import("./preview-current-date.mjs");
623
+ return reader.readPreviewCurrentDate(root);
624
+ }
625
+
626
+ /** Both explicit palettes, scoped to the preview container. */
627
+ export function previewStyles() {
628
+ return `
629
+ .hl-preview.hl-preview-light { --hl-bg:#ffffff; --hl-panel:#f6f8fa; --hl-fg:#24292f; --hl-blue:#0969da; --hl-purple:#6c3fd1; --hl-green:#1a7f37; --hl-grey:#57606a; --hl-note:#0969da; --hl-note-bg:#f6faff; --hl-tip:#1a7f37; --hl-tip-bg:#f4fbf5; --hl-important:#6c3fd1; --hl-important-bg:#faf7ff; --hl-warning:#9a6700; --hl-warning-bg:#fffaf0; color-scheme:light; }
630
+ .hl-preview.hl-preview-dark { --hl-bg:#0d1117; --hl-panel:#161b22; --hl-fg:#e6edf3; --hl-blue:#58a6ff; --hl-purple:#c4b0ff; --hl-green:#7ee787; --hl-grey:#8b949e; --hl-note:#58a6ff; --hl-note-bg:#101a28; --hl-tip:#7ee787; --hl-tip-bg:#102018; --hl-important:#c4b0ff; --hl-important-bg:#1c172a; --hl-warning:#d29922; --hl-warning-bg:#241d10; color-scheme:dark; }
631
+ .hl-preview { background:var(--hl-bg); color:var(--hl-fg); padding:1rem; overflow-wrap:anywhere; }
632
+ .hl-preview .hl-preview-main { color:var(--hl-fg); background:var(--hl-bg); }
633
+ .hl-preview a, .hl-preview .hl-preview-bracket { color:var(--hl-blue)!important; opacity:1!important; }
634
+ .hl-preview .hl-preview-address { color:var(--hl-purple)!important; opacity:1!important; }
635
+ .hl-preview .hl-preview-label { color:var(--hl-fg)!important; font-weight:700!important; opacity:1!important; }
636
+ .hl-preview .hl-preview-dependent { color:var(--hl-green)!important; opacity:1!important; }
637
+ .hl-preview .hl-preview-attribute, .hl-preview .hl-preview-fence, .hl-preview .hl-preview-fence code { color:var(--hl-grey)!important; font-weight:400!important; opacity:1!important; }
638
+ .hl-preview pre, .hl-preview code, .hl-preview .hl-preview-infobox { background:var(--hl-panel); }
639
+ .hl-preview pre { padding:1rem; white-space:pre-wrap; }
640
+ .hl-preview .hl-preview-infobox { padding:1rem; color:var(--hl-fg); }
641
+ @media (min-width:48rem) { .hl-preview:has(> .hl-preview-infobox) { display:grid; grid-template-columns:minmax(0,1fr) minmax(14rem,20rem); gap:1.5rem; align-items:start; } .hl-preview > .hl-preview-infobox { grid-column:2; grid-row:1; } .hl-preview > .hl-preview-main { grid-column:1; grid-row:1; } }
642
+ .hl-preview dt { font-weight:700; }
643
+ .hl-preview dd { font-weight:400; }
644
+ .hl-preview .dictionary { --indent:150px; --gap:.75em; margin:0; display:block; }
645
+ .hl-preview .dictionary > div { padding-left:var(--indent); text-indent:calc(-1 * var(--indent)); margin-bottom:.5em; }
646
+ .hl-preview .dictionary > div > dt { display:inline-block; min-width:calc(var(--indent) - var(--gap)); margin-right:var(--gap); font-weight:bold; white-space:nowrap; text-indent:0; text-align:left; }
647
+ .hl-preview .dictionary > div > dd { display:inline; margin:0; font-weight:normal; }
648
+ .hl-preview .dictionary > div > dd > :is(p, ul, ol, dl, blockquote, pre, table, div) { text-indent:0; }
649
+ .hl-preview .dictionary > div > dd > p:first-child { display:inline; }
650
+ @media (max-width:600px) { .hl-preview .dictionary { --indent:100px; } }
651
+ .hl-preview .hl-preview-span, .hl-preview .hl-preview-span .hl-preview-attribute, .hl-preview .hl-preview-span .hl-preview-bracket { font-weight:700!important; }
652
+ .hl-preview .hl-preview-secret { cursor:help; }
653
+ .hl-preview .hl-preview-secret:not(:hover):not(:focus):not(:focus-within) .hl-preview-secret-content { color:transparent!important; background-color:var(--hl-fg)!important; text-shadow:none!important; }
654
+ .hl-preview .hl-preview-secret:not(:hover):not(:focus):not(:focus-within) .hl-preview-secret-content * { color:transparent!important; -webkit-text-fill-color:transparent!important; visibility:hidden!important; text-shadow:none!important; text-decoration-color:transparent!important; }
655
+ .hl-preview .hl-preview-secret:focus { outline:2px solid var(--hl-blue); outline-offset:2px; }
656
+ .hl-preview .hl-preview-infobox dd { margin:0 0 .5rem; white-space:pre-wrap; }
657
+ .hl-preview .hl-preview-align-center { text-align:center; }
658
+ .hl-preview .hl-preview-align-right { text-align:right; }
659
+ .hl-preview .hl-preview-align-left { text-align:left; }
660
+ .hl-preview th, .hl-preview td { background:var(--hl-bg); color:var(--hl-fg); padding:.3rem .6rem; border:1px solid var(--hl-grey); }
661
+ .hl-preview .alert { border:0; border-left:4px solid var(--hl-accent); background:var(--hl-alert-bg); color:var(--hl-fg); padding:1rem; border-radius:.375rem; margin:1rem 0; }
662
+ .hl-preview .alert-note { --hl-accent:var(--hl-note); --hl-alert-bg:var(--hl-note-bg); }
663
+ .hl-preview .alert-tip { --hl-accent:var(--hl-tip); --hl-alert-bg:var(--hl-tip-bg); }
664
+ .hl-preview .alert-important { --hl-accent:var(--hl-important); --hl-alert-bg:var(--hl-important-bg); }
665
+ .hl-preview .alert-warning { --hl-accent:var(--hl-warning); --hl-alert-bg:var(--hl-warning-bg); }
666
+ .hl-preview .alert .alert-title { color:var(--hl-accent); font-weight:700; opacity:1; }
667
+ .hl-preview .alert .alert-icon { width:1em; height:1em; margin-right:.5em; vertical-align:-.1em; color:var(--hl-accent); fill:currentColor; }
668
+ .hl-preview .poetry-line { white-space:pre-wrap; }
669
+ ${Array.from({ length: 9 }, (_, n) => `.hl-preview .poetry-line.i${n} { padding-left:${n * 2}em; }`).join("\n")}
670
+ `;
355
671
  }