gutterpress 0.10.7 → 0.10.8

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 (59) hide show
  1. package/README.md +10 -3
  2. package/dist/README-aaqapam1.md +186 -0
  3. package/dist/README-mdq34cmb.md +155 -0
  4. package/dist/api/index.d.ts +6 -2
  5. package/dist/api/index.js +30 -2
  6. package/dist/{audit-19e2431j.js → audit-m3ce9pvq.js} +4 -4
  7. package/dist/base-ypqh9n7f.css +123 -0
  8. package/dist/book-ted3tzrp.css +58 -0
  9. package/dist/{build-gqqew2vr.js → build-qnecb3a1.js} +4 -4
  10. package/dist/callout-hv56m16h.md +7 -0
  11. package/dist/{cli-kzf64c2g.js → cli-1hx14cgk.js} +946 -401
  12. package/dist/{cli-s8q42x3r.js → cli-4vtgt2ng.js} +78 -2
  13. package/dist/{cli-h8ejbtyr.js → cli-vcv5ewqj.js} +1 -1
  14. package/dist/{cli-pvk2s9d7.js → cli-vxf151ea.js} +1 -1
  15. package/dist/cli.js +16 -16
  16. package/dist/components-0mmswvvm.yaml +51 -0
  17. package/dist/components-aam0fe2z.css +94 -0
  18. package/dist/{doctor-3ccz35yz.js → doctor-qvqye1wd.js} +2 -2
  19. package/dist/{engine-pb6h089e.js → engine-daad33qn.js} +1 -1
  20. package/dist/{engine-88mynx9a.js → engine-db8632w6.js} +2 -2
  21. package/dist/expected-ycmacem8.html +9 -0
  22. package/dist/fixture-2qnyqgb5.md +10 -0
  23. package/dist/gutterpress-m98e2w2d.json +8 -0
  24. package/dist/gutterpress-x2ycdsb8.json +17 -0
  25. package/dist/{index-837htyjy.js → index-9pbaznww.js} +78 -2
  26. package/dist/{index-r4ny4fyr.js → index-dfw8hn8q.js} +1147 -523
  27. package/dist/{index-a0xpm188.js → index-rpt4vtby.js} +1 -1
  28. package/dist/index.js +31 -3
  29. package/dist/lib/build-runner.d.ts +52 -0
  30. package/dist/lib/extension-manifest.d.ts +164 -0
  31. package/dist/lib/extension-scaffold.d.ts +65 -0
  32. package/dist/lib/lint-runner.d.ts +12 -0
  33. package/dist/lib/markdown/assemble.d.ts +12 -0
  34. package/dist/lib/markdown/markers.d.ts +20 -0
  35. package/dist/lib/markdown/plugins.d.ts +1 -1
  36. package/dist/lib/markdown/renderer.d.ts +99 -0
  37. package/dist/lib/snippets.d.ts +121 -5
  38. package/dist/lib/theme-import.d.ts +8 -4
  39. package/dist/lib/theme-manager.d.ts +52 -39
  40. package/dist/lib/validation-exec.d.ts +12 -0
  41. package/dist/{lint-mpz7vfc8.js → lint-3p1s8agk.js} +4 -4
  42. package/dist/{manifest.schema-kwnrwv8b.json → manifest.schema-1q35yxs9.json} +2 -2
  43. package/dist/{new-nbrpsw7t.js → new-xffarp40.js} +111 -6
  44. package/dist/package-rm9d2tht.json +29 -0
  45. package/dist/page-rules-nsj3aw42.css +128 -0
  46. package/dist/page-templates-7md4ev7d.css +60 -0
  47. package/dist/{plugin-gg5mk26h.js → plugin-b05ck7zd.js} +4 -4
  48. package/dist/plugin-m2w7g47v.css +114 -0
  49. package/dist/plugin.js-aszz0zgh.tpl +172 -0
  50. package/dist/plugin.test.js-mv0ghv5n.tpl +232 -0
  51. package/dist/{preflight-jnmd4mg2.js → preflight-z6xpty9w.js} +4 -4
  52. package/dist/{preview-hsr9d12d.js → preview-xa1n7why.js} +4 -4
  53. package/dist/{publish-pcrrn6fd.js → publish-zgmr99kd.js} +4 -4
  54. package/dist/render.js +275 -8
  55. package/dist/term-box-4y0w7y32.md +7 -0
  56. package/dist/{theme-6fa0yjzq.js → theme-m9dtxkhj.js} +4 -4
  57. package/dist/tokens-e4xk3x4v.css +86 -0
  58. package/dist/{validate-7n25fx0g.js → validate-43hqgzz5.js} +4 -4
  59. package/package.json +1 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * {{NAME}} — the plugin's own test suite.
3
+ *
4
+ * bun install # once — pulls the single devDependency, markdown-it
5
+ * bun test
6
+ *
7
+ * Three things are checked, and it is worth knowing which is which.
8
+ *
9
+ * 1. RENDER (fixture.md → expected.html). The bespoke inline rule is an
10
+ * ordinary markdown-it rule, so a plain markdown-it instance with this
11
+ * plugin applied renders it exactly as a book would. This is the test that
12
+ * turns "did I break my plugin" into one command.
13
+ *
14
+ * There is deliberately no snapshot auto-update here. After an intentional
15
+ * change, open `expected.html`, make the edit you meant to make, and commit
16
+ * it — a diff somebody had to read is the point of the file.
17
+ *
18
+ * 2. CONTRACT. The exact export shape Gutterpress's loader requires, and the
19
+ * existence of every file `gutterpress.json` declares. These catch the
20
+ * failures that otherwise surface as a blank page in someone else's book.
21
+ *
22
+ * 3. CONVENTIONS. The prefix rules — every class this package emits is its
23
+ * own, and none is `gp-`. Nothing in Gutterpress enforces this for you;
24
+ * a collision just silently restyles somebody's book. So it is enforced
25
+ * here.
26
+ *
27
+ * What this suite deliberately does NOT do: render the `@term-box` container.
28
+ * That container is produced by Gutterpress CORE's marker parser from the
29
+ * `markers` table below — plain markdown-it knows nothing about it, and this
30
+ * package cannot import core (see README.md, "Why you cannot import
31
+ * gutterpress"). So the table's CONTRACT is checked here, and the rendered
32
+ * container is checked by running `gutterpress preview` on a real book.
33
+ */
34
+ import { describe, expect, test } from "bun:test";
35
+ import { existsSync, readFileSync } from "node:fs";
36
+ import path from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+
39
+ import MarkdownIt from "markdown-it";
40
+
41
+ import plugin, { createTermRule, markers, metadata } from "../plugin.js";
42
+
43
+ const here = path.dirname(fileURLToPath(import.meta.url));
44
+ const root = path.join(here, "..");
45
+ const read = (rel) => readFileSync(path.join(root, rel), "utf8");
46
+
47
+ const pkg = JSON.parse(read("gutterpress.json"));
48
+ const css = read("styles/plugin.css");
49
+ /** The stylesheet's RULES — comments stripped, so a name assertion below
50
+ * tests what the sheet emits rather than what it talks about. */
51
+ const cssRules = css.replace(/\/\*[\s\S]*?\*\//g, "");
52
+
53
+ /** The prefix every class and custom property this package emits must carry.
54
+ * Written out here rather than imported from plugin.js on purpose: this is
55
+ * the independent statement of the convention, so changing the constant in
56
+ * plugin.js alone makes these tests fail instead of silently agreeing. */
57
+ const PREFIX = "{{PREFIX}}";
58
+
59
+ // ── 1. Render ───────────────────────────────────────────────────────────────
60
+
61
+ describe("render", () => {
62
+ test("fixture.md renders to expected.html", () => {
63
+ // The same options Gutterpress builds its own renderer with, so what
64
+ // passes here behaves the same way in a real book.
65
+ const md = new MarkdownIt({ html: true, linkify: true, typographer: true });
66
+ md.use(plugin);
67
+
68
+ const actual = md.render(read("test/fixture.md"));
69
+ expect(actual).toBe(read("test/expected.html"));
70
+ });
71
+
72
+ test("plugin options can override the emitted class", () => {
73
+ const md = new MarkdownIt();
74
+ md.use(plugin, { termClass: "custom-term" });
75
+ expect(md.render("[[x]]")).toContain('class="custom-term"');
76
+ });
77
+
78
+ test("the inline rule honours markdown-it's silent mode", () => {
79
+ // A rule that emits tokens while `silent` is true corrupts markdown-it's
80
+ // link/reference probing. Easy to get wrong, invisible until it breaks
81
+ // somebody's footnote.
82
+ const rule = createTermRule("x");
83
+ const pushed = [];
84
+ const state = {
85
+ src: "[[term]]",
86
+ pos: 0,
87
+ posMax: 8,
88
+ push: (...args) => {
89
+ pushed.push(args);
90
+ return { attrSet() {} };
91
+ },
92
+ };
93
+ expect(rule(state, true)).toBe(true);
94
+ expect(pushed).toHaveLength(0);
95
+ expect(state.pos).toBe(8);
96
+ });
97
+ });
98
+
99
+ // ── 2. Contract ─────────────────────────────────────────────────────────────
100
+
101
+ describe("loader contract", () => {
102
+ test("the default export is a plain (md, options) function", () => {
103
+ // CLAUDE.md §5: a Gutterpress plugin is a plain markdown-it plugin.
104
+ // Nothing else is accepted, and nothing else should be.
105
+ expect(typeof plugin).toBe("function");
106
+ expect(plugin.length).toBeLessThanOrEqual(2);
107
+ });
108
+
109
+ test("`markers` is a plain object", () => {
110
+ expect(typeof markers).toBe("object");
111
+ expect(markers).not.toBeNull();
112
+ expect(Array.isArray(markers)).toBe(false);
113
+ });
114
+
115
+ test("`metadata` carries a name", () => {
116
+ expect(typeof metadata?.name).toBe("string");
117
+ expect(metadata.name.length).toBeGreaterThan(0);
118
+ });
119
+
120
+ test("every path gutterpress.json declares exists", () => {
121
+ const declared = [
122
+ pkg.markdown,
123
+ ...(pkg.styles ?? []),
124
+ ...(pkg.engineStyles?.native ?? []),
125
+ pkg.components,
126
+ pkg.snippets,
127
+ pkg.tokensFile,
128
+ ].filter(Boolean);
129
+
130
+ expect(declared.length).toBeGreaterThan(0);
131
+ for (const rel of declared) {
132
+ // Gutterpress refuses to load an extension that reaches outside its own
133
+ // folder, so a `../` here would fail at install time, not here.
134
+ expect(rel.startsWith("/")).toBe(false);
135
+ expect(rel.split(/[\\/]/)).not.toContain("..");
136
+ // `existsSync`, not a read: `snippets` names a DIRECTORY.
137
+ expect(existsSync(path.join(root, rel))).toBe(true);
138
+ }
139
+ });
140
+ });
141
+
142
+ // ── 3. Conventions ──────────────────────────────────────────────────────────
143
+
144
+ /** Every class name this package declares, from wherever it declares it. */
145
+ function declaredClasses() {
146
+ const out = new Set();
147
+ for (const decl of Object.values(markers)) {
148
+ if (typeof decl?.class === "string") {
149
+ for (const c of decl.class.split(/\s+/).filter(Boolean)) out.add(c);
150
+ }
151
+ for (const variant of Object.values(decl?.variants ?? {})) {
152
+ for (const c of String(variant).split(/\s+/).filter(Boolean)) out.add(c);
153
+ }
154
+ if (typeof decl?.label?.class === "string") out.add(decl.label.class);
155
+ }
156
+ return [...out];
157
+ }
158
+
159
+ describe("conventions", () => {
160
+ test("every class the markers declare carries this package's prefix", () => {
161
+ const classes = declaredClasses();
162
+ expect(classes.length).toBeGreaterThan(0);
163
+ for (const c of classes) expect(c.startsWith(PREFIX)).toBe(true);
164
+ });
165
+
166
+ test("nothing emits a `gp-` class — that prefix belongs to core", () => {
167
+ for (const c of declaredClasses()) expect(c.startsWith("gp-")).toBe(false);
168
+ // Also catch a `gp-` selector added straight to the stylesheet.
169
+ expect(cssRules).not.toMatch(/\.gp-/);
170
+ expect(cssRules).not.toMatch(/--gp-/);
171
+ });
172
+
173
+ test("every declared class has a rule in the stylesheet", () => {
174
+ // A class the plugin emits but never styles is a silent no-op in every
175
+ // book that installs it.
176
+ for (const c of declaredClasses()) {
177
+ expect(cssRules).toContain(`.${c}`);
178
+ }
179
+ });
180
+
181
+ test("marker names are valid and do not shadow a core marker", () => {
182
+ // Core's names are claimed during block parsing, before any plugin runs.
183
+ // Declaring one of them is a hard load error — caught here first.
184
+ const core = [
185
+ "chapter", "spread", "page", "section", "continue",
186
+ "page-break", "column-break", "end-section",
187
+ ];
188
+ for (const name of Object.keys(markers)) {
189
+ expect(name).toMatch(/^[a-z][a-z0-9-]*$/);
190
+ expect(name.startsWith("end-")).toBe(false);
191
+ expect(core).not.toContain(name);
192
+ }
193
+ });
194
+
195
+ test("marker declarations use only supported fields", () => {
196
+ for (const [name, decl] of Object.entries(markers)) {
197
+ if (decl.deprecated !== undefined || decl.alias !== undefined) continue;
198
+ expect(typeof decl.tag === "undefined" || /^[a-z][a-z0-9-]*$/.test(decl.tag)).toBe(true);
199
+ for (const [variant, cls] of Object.entries(decl.variants ?? {})) {
200
+ expect(typeof cls).toBe("string");
201
+ expect(variant).toMatch(/^\S+$/);
202
+ }
203
+ if (decl.label) {
204
+ // "attr:<name>" is the only supported source today.
205
+ expect(decl.label.from).toMatch(/^attr:[A-Za-z_][A-Za-z0-9_-]*$/);
206
+ expect(typeof decl.label.class).toBe("string");
207
+ }
208
+ for (const at of decl.autoCloseAt ?? []) {
209
+ expect(at).toBe("eof");
210
+ }
211
+ expect(name).toBeTruthy();
212
+ }
213
+ });
214
+
215
+ test("every public custom property carries the prefix too", () => {
216
+ // `--x` declared at :root is global. An unprefixed one would collide with
217
+ // the book's own tokens exactly as an unprefixed class would.
218
+ const declaredProps = [...cssRules.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gim)].map((m) => m[1]);
219
+ expect(declaredProps.length).toBeGreaterThan(0);
220
+ for (const prop of declaredProps) {
221
+ expect(prop.startsWith(`--${PREFIX}`)).toBe(true);
222
+ }
223
+ });
224
+
225
+ test("the stylesheet keeps all of its rules inside its own cascade layer", () => {
226
+ // An unlayered rule beats every layered one, including this package's
227
+ // own — see the header comment in styles/plugin.css.
228
+ expect(css).toContain("@layer {{SLUG}} {");
229
+ const outside = css.replace(/@layer\s+[\w-]+\s*\{[\s\S]*\}/m, "");
230
+ expect(outside).not.toMatch(/^[^*\/\s][^{}]*\{/m);
231
+ });
232
+ });
@@ -2,16 +2,16 @@ import {
2
2
  executeValidation,
3
3
  publishTargetFor,
4
4
  reportMissingTools
5
- } from "./cli-kzf64c2g.js";
5
+ } from "./cli-1hx14cgk.js";
6
6
  import {
7
7
  log
8
- } from "./cli-pvk2s9d7.js";
8
+ } from "./cli-vxf151ea.js";
9
9
  import {
10
10
  UsageError,
11
11
  rejectExtraPositionals,
12
12
  rejectUnknownFlags
13
- } from "./cli-h8ejbtyr.js";
14
- import"./cli-s8q42x3r.js";
13
+ } from "./cli-vcv5ewqj.js";
14
+ import"./cli-4vtgt2ng.js";
15
15
  import"./cli-c41yr7he.js";
16
16
  import {
17
17
  EXIT_CODES
@@ -6,10 +6,10 @@ import {
6
6
  runBuild,
7
7
  splitOutPath,
8
8
  startPreviewServer
9
- } from "./cli-kzf64c2g.js";
9
+ } from "./cli-1hx14cgk.js";
10
10
  import {
11
11
  log
12
- } from "./cli-pvk2s9d7.js";
12
+ } from "./cli-vxf151ea.js";
13
13
  import {
14
14
  UsageError,
15
15
  parseEngine,
@@ -18,8 +18,8 @@ import {
18
18
  rejectExtraPositionals,
19
19
  rejectUnknownFlags,
20
20
  resolvePort
21
- } from "./cli-h8ejbtyr.js";
22
- import"./cli-s8q42x3r.js";
21
+ } from "./cli-vcv5ewqj.js";
22
+ import"./cli-4vtgt2ng.js";
23
23
  import"./cli-c41yr7he.js";
24
24
  import {
25
25
  BuildError
@@ -11,17 +11,17 @@ import {
11
11
  publishProviderFor,
12
12
  resolvePublishFormat,
13
13
  runPublish
14
- } from "./cli-kzf64c2g.js";
14
+ } from "./cli-1hx14cgk.js";
15
15
  import {
16
16
  FileTokenStore,
17
17
  log
18
- } from "./cli-pvk2s9d7.js";
18
+ } from "./cli-vxf151ea.js";
19
19
  import {
20
20
  UsageError,
21
21
  rejectExtraPositionals,
22
22
  rejectUnknownFlags
23
- } from "./cli-h8ejbtyr.js";
24
- import"./cli-s8q42x3r.js";
23
+ } from "./cli-vcv5ewqj.js";
24
+ import"./cli-4vtgt2ng.js";
25
25
  import"./cli-c41yr7he.js";
26
26
  import {
27
27
  EXIT_CODES
package/dist/render.js CHANGED
@@ -50,7 +50,7 @@ function escapeHtml(s) {
50
50
  function escapeAttr(s) {
51
51
  return String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
52
52
  }
53
- function parseMarkerLine(line) {
53
+ function parseMarkerLine(line, declaredWords) {
54
54
  const trimmed = line.trim();
55
55
  if (!trimmed.startsWith("@"))
56
56
  return null;
@@ -84,9 +84,9 @@ function parseMarkerLine(line) {
84
84
  tokens[i] = unwrapAttrBraces(tokens[i]);
85
85
  const head = tokens[0];
86
86
  const kind = head.slice(1);
87
- if (!KNOWN_KINDS.includes(kind))
87
+ if (!KNOWN_KINDS.includes(kind) && !(declaredWords && declaredWords.has(kind)))
88
88
  return null;
89
- if (kind === "page-break" || kind === "column-break" || kind === "end-section" || kind === "continue") {
89
+ if (kind === "page-break" || kind === "column-break" || kind === "continue" || kind.startsWith("end-")) {
90
90
  return { kind, name: null, attrs: {} };
91
91
  }
92
92
  const body = tokens.slice(1);
@@ -189,12 +189,14 @@ function attachDataAttrs(token, kind, name, attrs) {
189
189
  if (name) {
190
190
  if (kind === "chapter")
191
191
  token.attrSet("data-chapter-label", name);
192
- if (kind === "spread")
192
+ else if (kind === "spread")
193
193
  token.attrSet("data-spread", name);
194
- if (kind === "page")
194
+ else if (kind === "page")
195
195
  token.attrSet("data-page", name);
196
- if (kind === "section")
196
+ else if (kind === "section")
197
197
  token.attrSet("data-section", name);
198
+ else
199
+ token.attrSet(`data-${kind}`, name);
198
200
  }
199
201
  if (attrs.template)
200
202
  token.attrSet("data-template", attrs.template);
@@ -210,16 +212,193 @@ function attachDataAttrs(token, kind, name, attrs) {
210
212
  token.attrSet(`data-${k}`, v);
211
213
  }
212
214
  }
215
+ var DECLARED_NAME_RE = /^[a-z][a-z0-9-]*$/;
216
+ function validateDeclaredMarkerName(name, pluginName) {
217
+ if (!DECLARED_NAME_RE.test(name)) {
218
+ throw new Error(`Plugin "${pluginName}" declares marker "@${name}" with an invalid name — marker names must be ` + `lower-case letters, digits and hyphens, starting with a letter (like core's own "page-break").`);
219
+ }
220
+ if (name.startsWith("end-")) {
221
+ throw new Error(`Plugin "${pluginName}" declares marker "@${name}", but names starting with "end-" are reserved ` + `for the auto-derived closer of another declared marker (a container named "${name.slice(4)}" ` + `would collide with it). Rename this marker.`);
222
+ }
223
+ }
224
+ var TAG_NAME_RE = /^[a-z][a-z0-9-]*$/;
225
+ function resolveContainerShape(name, pluginName, decl) {
226
+ const tag = decl.tag !== undefined ? decl.tag : "div";
227
+ if (typeof tag !== "string" || !TAG_NAME_RE.test(tag)) {
228
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has an invalid \`tag\` (${JSON.stringify(decl.tag)}) — ` + `must be a lower-case HTML tag name.`);
229
+ }
230
+ const classBase = decl.class !== undefined ? decl.class : "";
231
+ if (typeof classBase !== "string") {
232
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`class\` that is not a string.`);
233
+ }
234
+ let variants;
235
+ if (decl.variants !== undefined) {
236
+ if (typeof decl.variants !== "object" || decl.variants === null || Array.isArray(decl.variants)) {
237
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has \`variants\` that is not a plain object.`);
238
+ }
239
+ variants = {};
240
+ for (const [variantName, variantClass] of Object.entries(decl.variants)) {
241
+ if (typeof variantClass !== "string") {
242
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" variant "${variantName}" is not a string.`);
243
+ }
244
+ variants[variantName] = variantClass;
245
+ }
246
+ }
247
+ let label;
248
+ if (decl.label !== undefined) {
249
+ if (typeof decl.label !== "object" || decl.label === null || Array.isArray(decl.label)) {
250
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`label\` that is not an object.`);
251
+ }
252
+ if (typeof decl.label.class !== "string" || !decl.label.class) {
253
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`label.class\` that is not a non-empty string.`);
254
+ }
255
+ const match = typeof decl.label.from === "string" ? /^attr:([A-Za-z_][A-Za-z0-9_-]*)$/.exec(decl.label.from) : null;
256
+ if (!match) {
257
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`label.from\` (${JSON.stringify(decl.label.from)}) that is not "attr:<name>" — that is the only supported form today.`);
258
+ }
259
+ const labelTag = decl.label.tag !== undefined ? decl.label.tag : "div";
260
+ if (typeof labelTag !== "string" || !TAG_NAME_RE.test(labelTag)) {
261
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has an invalid \`label.tag\` (${JSON.stringify(decl.label.tag)}).`);
262
+ }
263
+ label = { class: decl.label.class, attr: match[1], tag: labelTag };
264
+ }
265
+ let autoCloseAtEof = false;
266
+ if (decl.autoCloseAt !== undefined) {
267
+ if (!Array.isArray(decl.autoCloseAt)) {
268
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has an \`autoCloseAt\` that is not an array.`);
269
+ }
270
+ for (const value of decl.autoCloseAt) {
271
+ if (value !== "eof") {
272
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" declares an unsupported \`autoCloseAt\` value ` + `${JSON.stringify(value)} — only "eof" is currently supported.`);
273
+ }
274
+ }
275
+ autoCloseAtEof = decl.autoCloseAt.includes("eof");
276
+ }
277
+ return { tag, classBase, variants, label, autoCloseAtEof };
278
+ }
279
+ function resolveMarkerDeclaration(name, rawDecl, rawRegistry, originOf) {
280
+ const pluginName = originOf.get(name);
281
+ if (typeof rawDecl !== "object" || rawDecl === null || Array.isArray(rawDecl)) {
282
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" is not a plain object.`);
283
+ }
284
+ if (rawDecl.deprecated !== undefined) {
285
+ if (typeof rawDecl.deprecated !== "string" || !rawDecl.deprecated) {
286
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`deprecated\` value that is not a non-empty string.`);
287
+ }
288
+ return { name, deprecated: rawDecl.deprecated };
289
+ }
290
+ if (rawDecl.alias !== undefined) {
291
+ if (typeof rawDecl.alias !== "string" || !rawDecl.alias) {
292
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has an \`alias\` value that is not a non-empty string.`);
293
+ }
294
+ const targetRaw = rawRegistry.get(rawDecl.alias);
295
+ if (!targetRaw) {
296
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" aliases unknown marker "@${rawDecl.alias}" — no ` + `loaded plugin declares it.`);
297
+ }
298
+ if (targetRaw.deprecated !== undefined) {
299
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" aliases "@${rawDecl.alias}", which is deprecated: ` + `${targetRaw.deprecated}`);
300
+ }
301
+ if (targetRaw.alias !== undefined) {
302
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" aliases "@${rawDecl.alias}", which is itself an alias. ` + `Chained aliases are not supported — alias the base marker directly.`);
303
+ }
304
+ const preset = rawDecl.preset !== undefined ? rawDecl.preset : {};
305
+ if (typeof preset !== "object" || preset === null || Array.isArray(preset)) {
306
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`preset\` that is not a plain object.`);
307
+ }
308
+ if (preset.variant !== undefined && typeof preset.variant !== "string") {
309
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`preset.variant\` that is not a string.`);
310
+ }
311
+ const container = resolveContainerShape(rawDecl.alias, originOf.get(rawDecl.alias), targetRaw);
312
+ return { name, baseKind: rawDecl.alias, presetVariant: preset.variant, ...container };
313
+ }
314
+ return { name, baseKind: name, ...resolveContainerShape(name, pluginName, rawDecl) };
315
+ }
316
+ function buildDeclaredMarkerRegistry(sources) {
317
+ const rawRegistry = new Map;
318
+ const originOf = new Map;
319
+ for (const { pluginName, markers } of sources) {
320
+ if (!markers)
321
+ continue;
322
+ for (const [name, rawDecl] of Object.entries(markers)) {
323
+ validateDeclaredMarkerName(name, pluginName);
324
+ if (KNOWN_KINDS.includes(name)) {
325
+ throw new Error(`Plugin "${pluginName}" declares marker "@${name}", which is a core Gutterpress marker name ` + `(${KNOWN_KINDS.map((k) => `@${k}`).join(", ")}). Core marker names cannot be shadowed — ` + `rename the plugin's marker.`);
326
+ }
327
+ if (rawRegistry.has(name)) {
328
+ throw new Error(`Marker "@${name}" is declared by both plugin "${originOf.get(name)}" and plugin "${pluginName}". ` + `Declared marker names must be unique across every loaded plugin — rename one of them.`);
329
+ }
330
+ rawRegistry.set(name, rawDecl);
331
+ originOf.set(name, pluginName);
332
+ }
333
+ }
334
+ const resolved = new Map;
335
+ for (const [name, rawDecl] of rawRegistry) {
336
+ resolved.set(name, resolveMarkerDeclaration(name, rawDecl, rawRegistry, originOf));
337
+ }
338
+ return resolved;
339
+ }
340
+ function declaredKindWords(declaredMarkers) {
341
+ if (!declaredMarkers)
342
+ return null;
343
+ const words = new Set;
344
+ for (const name of declaredMarkers.keys()) {
345
+ words.add(name);
346
+ words.add(`end-${name}`);
347
+ }
348
+ return words;
349
+ }
350
+ function nearestDeclaredMarkerName(word, declaredMarkers) {
351
+ const w = word.toLowerCase();
352
+ const threshold = Math.max(2, Math.floor(w.length / 3));
353
+ let best = null;
354
+ let bestDistance = Infinity;
355
+ for (const known of declaredMarkers.keys()) {
356
+ const d = editDistance(w, known);
357
+ if (d < bestDistance) {
358
+ bestDistance = d;
359
+ best = known;
360
+ }
361
+ }
362
+ return best !== null && bestDistance > 0 && bestDistance <= threshold ? best : null;
363
+ }
364
+ function scanForUnknownDeclaredMarkers(state, declaredMarkers) {
365
+ if (!declaredMarkers)
366
+ return;
367
+ for (let tokenIndex = 0;tokenIndex < state.tokens.length; tokenIndex++) {
368
+ const tok = state.tokens[tokenIndex];
369
+ if (tok.type !== "inline" || !tok.map)
370
+ continue;
371
+ const opener = state.tokens[tokenIndex - 1];
372
+ if (!opener || opener.type !== "paragraph_open")
373
+ continue;
374
+ const lines = tok.content.split(`
375
+ `);
376
+ for (let i = 0;i < lines.length; i++) {
377
+ const m = /^@([A-Za-z][A-Za-z0-9-]*)(?=\s|$)/.exec(lines[i].trim());
378
+ if (!m)
379
+ continue;
380
+ const word = m[1];
381
+ if (KNOWN_KINDS.includes(word) || declaredMarkers.has(word))
382
+ continue;
383
+ const suggestion = nearestDeclaredMarkerName(word, declaredMarkers);
384
+ if (!suggestion)
385
+ continue;
386
+ warn(state.env, tok.map[0] + i + 1, "unknown_marker", `Unknown marker "@${word}". Did you mean "@${suggestion}"?`, null);
387
+ }
388
+ }
389
+ }
213
390
  function plugin(md, pluginOptions = {}) {
214
391
  const options = {
215
392
  preferPagesInSpreads: false,
216
393
  ...pluginOptions
217
394
  };
395
+ const declaredMarkers = options.declaredMarkers instanceof Map && options.declaredMarkers.size > 0 ? options.declaredMarkers : null;
396
+ const declaredWords = declaredKindWords(declaredMarkers);
218
397
  function markerBlock(state, startLine, endLine, silent) {
219
398
  const pos = state.bMarks[startLine] + state.tShift[startLine];
220
399
  const max = state.eMarks[startLine];
221
400
  const line = state.src.slice(pos, max);
222
- const parsed = parseMarkerLine(line);
401
+ const parsed = parseMarkerLine(line, declaredWords);
223
402
  if (!parsed)
224
403
  return false;
225
404
  if (silent)
@@ -276,10 +455,64 @@ function plugin(md, pluginOptions = {}) {
276
455
  }
277
456
  }
278
457
  md.core.ruler.after("block", "layout_transform", function(state) {
458
+ scanForUnknownDeclaredMarkers(state, declaredMarkers);
279
459
  if (!state.env.__layoutMarkersUsed)
280
460
  return;
281
461
  scanForMistypedMarkers(state);
282
462
  const out = [];
463
+ const declaredFrames = [];
464
+ function declaredFrameIndex(kind) {
465
+ for (let i = declaredFrames.length - 1;i >= 0; i--) {
466
+ if (declaredFrames[i].kind === kind)
467
+ return i;
468
+ }
469
+ return -1;
470
+ }
471
+ function closeDeclaredFrame(kind) {
472
+ const at = declaredFrameIndex(kind);
473
+ if (at === -1)
474
+ return false;
475
+ while (declaredFrames.length > at) {
476
+ const frame = declaredFrames.pop();
477
+ out.push(new state.Token("layout_component_close", frame.tag, -1));
478
+ }
479
+ return true;
480
+ }
481
+ function drainDeclaredFrames() {
482
+ while (declaredFrames.length) {
483
+ const frame = declaredFrames.pop();
484
+ out.push(new state.Token("layout_component_close", frame.tag, -1));
485
+ }
486
+ }
487
+ function drainDeclaredFramesAtEof() {
488
+ while (declaredFrames.length) {
489
+ const frame = declaredFrames.pop();
490
+ if (!frame.decl.autoCloseAtEof) {
491
+ warn(state.env, frame.line || 0, "declared_marker_eof_close", `An open @${frame.kind} reached end-of-document; closing it automatically. Add ` + `@end-${frame.kind} to close it explicitly, or declare autoCloseAt: ["eof"] on this ` + `marker if running to end-of-document is expected.`, null);
492
+ }
493
+ out.push(new state.Token("layout_component_close", frame.tag, -1));
494
+ }
495
+ }
496
+ function openDeclaredMarker(meta, decl) {
497
+ const t = new state.Token("layout_component_open", decl.tag, 1);
498
+ t.meta = { line: meta.__line };
499
+ const variant = meta.name || decl.presetVariant || null;
500
+ const variantClass = variant && decl.variants && decl.variants[variant] || "";
501
+ const baseClass = [decl.classBase, variantClass].filter(Boolean).join(" ");
502
+ addClasses(t, baseClass, meta.attrs && meta.attrs.class ? meta.attrs.class : "");
503
+ attachDataAttrs(t, decl.baseKind, variant, meta.attrs || {});
504
+ out.push(t);
505
+ declaredFrames.push({ kind: decl.baseKind, tag: decl.tag, decl, line: meta.__line });
506
+ if (decl.label) {
507
+ const value = meta.attrs && meta.attrs[decl.label.attr];
508
+ if (value) {
509
+ const labelToken = new state.Token("html_block", "", 0);
510
+ labelToken.content = `<${decl.label.tag} class="${escapeAttr(decl.label.class)}">${escapeHtml(value)}</${decl.label.tag}>
511
+ `;
512
+ out.push(labelToken);
513
+ }
514
+ }
515
+ }
283
516
  const stack = {
284
517
  frames: [],
285
518
  get(kind) {
@@ -429,6 +662,7 @@ function plugin(md, pluginOptions = {}) {
429
662
  const kind = meta.kind;
430
663
  const line = meta.__line || 0;
431
664
  if (kind === "chapter") {
665
+ drainDeclaredFrames();
432
666
  stack.close("chapter");
433
667
  openChapter(meta);
434
668
  continue;
@@ -437,16 +671,19 @@ function plugin(md, pluginOptions = {}) {
437
671
  if (stack.has("spread")) {
438
672
  warn(state.env, line, "nested_spread", "@spread encountered while another spread is open; closing the previous spread automatically.", meta);
439
673
  }
674
+ drainDeclaredFrames();
440
675
  stack.close("spread");
441
676
  openSpread(meta);
442
677
  continue;
443
678
  }
444
679
  if (kind === "page") {
680
+ drainDeclaredFrames();
445
681
  stack.close("page");
446
682
  openPage(meta);
447
683
  continue;
448
684
  }
449
685
  if (kind === "section") {
686
+ drainDeclaredFrames();
450
687
  warnIfEmptyDecoratedSection("section", line);
451
688
  stack.close("section");
452
689
  const spread = stack.get("spread");
@@ -462,6 +699,7 @@ function plugin(md, pluginOptions = {}) {
462
699
  warn(state.env, line, "continue_without_section", "@continue used without an open @section; ignoring marker.", meta);
463
700
  continue;
464
701
  }
702
+ drainDeclaredFrames();
465
703
  const contMeta = {
466
704
  name: section.meta.name,
467
705
  attrs: { ...section.meta.attrs || {} }
@@ -494,15 +732,42 @@ function plugin(md, pluginOptions = {}) {
494
732
  continue;
495
733
  }
496
734
  if (kind === "end-section") {
735
+ drainDeclaredFrames();
497
736
  warnIfEmptyDecoratedSection("end-section", line);
498
737
  stack.close("section");
499
738
  continue;
500
739
  }
740
+ if (declaredMarkers) {
741
+ const openDecl = declaredMarkers.get(kind);
742
+ if (openDecl) {
743
+ if (openDecl.deprecated) {
744
+ warn(state.env, line, "deprecated_marker", `@${kind} is deprecated: ${openDecl.deprecated}`, meta);
745
+ continue;
746
+ }
747
+ closeDeclaredFrame(openDecl.baseKind);
748
+ openDeclaredMarker(meta, openDecl);
749
+ continue;
750
+ }
751
+ if (kind.startsWith("end-")) {
752
+ const closeDecl = declaredMarkers.get(kind.slice(4));
753
+ if (closeDecl) {
754
+ if (closeDecl.deprecated) {
755
+ warn(state.env, line, "deprecated_marker", `@${kind} is deprecated: ${closeDecl.deprecated}`, meta);
756
+ continue;
757
+ }
758
+ if (!closeDeclaredFrame(closeDecl.baseKind)) {
759
+ warn(state.env, line, "declared_marker_close_without_open", `@${kind} used without an open @${closeDecl.baseKind}; ignoring marker.`, meta);
760
+ }
761
+ continue;
762
+ }
763
+ }
764
+ }
501
765
  }
502
766
  const eofSpread = stack.get("spread");
503
767
  if (eofSpread && !eofSpread.sawAnyPage) {
504
768
  warn(state.env, 0, "spread_eof_close", "An open @spread reached end-of-document; closing it automatically.", null);
505
769
  }
770
+ drainDeclaredFramesAtEof();
506
771
  stack.closeAll();
507
772
  state.tokens = out;
508
773
  });
@@ -1538,7 +1803,9 @@ function createMarkdownRenderer(customPlugins) {
1538
1803
  md.use(unwrapPlugin(markdownItFootnote));
1539
1804
  md.use(unwrapPlugin(markdownItDeflist));
1540
1805
  md.use(unwrapPlugin(markdownItSourceMap));
1541
- md.use(plugin);
1806
+ const declaredMarkerSources = (customPlugins ?? []).filter((p) => !!p.markers).map((p) => ({ pluginName: p.name, markers: p.markers }));
1807
+ const declaredMarkers = buildDeclaredMarkerRegistry(declaredMarkerSources);
1808
+ md.use(plugin, { declaredMarkers });
1542
1809
  md.use(gpPinScope);
1543
1810
  registerImageRule(md);
1544
1811
  if (customPlugins && customPlugins.length > 0) {