gutterpress 0.10.7 → 0.10.8-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -3
- package/dist/README-aaqapam1.md +186 -0
- package/dist/README-mdq34cmb.md +155 -0
- package/dist/api/index.d.ts +6 -2
- package/dist/api/index.js +30 -2
- package/dist/{audit-19e2431j.js → audit-ckbmgzpk.js} +4 -4
- package/dist/base-ypqh9n7f.css +123 -0
- package/dist/book-ted3tzrp.css +58 -0
- package/dist/{build-gqqew2vr.js → build-sgpyjx5n.js} +4 -4
- package/dist/callout-hv56m16h.md +7 -0
- package/dist/{cli-s8q42x3r.js → cli-4vtgt2ng.js} +78 -2
- package/dist/{cli-pvk2s9d7.js → cli-pw6z15yv.js} +1 -1
- package/dist/{cli-h8ejbtyr.js → cli-v1ma3v2s.js} +1 -1
- package/dist/{cli-kzf64c2g.js → cli-wvw2msvn.js} +946 -401
- package/dist/cli.js +16 -16
- package/dist/components-0mmswvvm.yaml +51 -0
- package/dist/components-aam0fe2z.css +94 -0
- package/dist/{doctor-3ccz35yz.js → doctor-59dwzq4r.js} +2 -2
- package/dist/{engine-pb6h089e.js → engine-daad33qn.js} +1 -1
- package/dist/{engine-88mynx9a.js → engine-db8632w6.js} +2 -2
- package/dist/expected-ycmacem8.html +9 -0
- package/dist/fixture-2qnyqgb5.md +10 -0
- package/dist/gutterpress-m98e2w2d.json +8 -0
- package/dist/gutterpress-x2ycdsb8.json +17 -0
- package/dist/{index-837htyjy.js → index-9pbaznww.js} +78 -2
- package/dist/{index-r4ny4fyr.js → index-m00n6sz4.js} +1147 -523
- package/dist/{index-a0xpm188.js → index-rpt4vtby.js} +1 -1
- package/dist/index.js +31 -3
- package/dist/lib/build-runner.d.ts +52 -0
- package/dist/lib/extension-manifest.d.ts +164 -0
- package/dist/lib/extension-scaffold.d.ts +65 -0
- package/dist/lib/lint-runner.d.ts +12 -0
- package/dist/lib/markdown/assemble.d.ts +12 -0
- package/dist/lib/markdown/markers.d.ts +20 -0
- package/dist/lib/markdown/plugins.d.ts +1 -1
- package/dist/lib/markdown/renderer.d.ts +99 -0
- package/dist/lib/snippets.d.ts +121 -5
- package/dist/lib/theme-import.d.ts +8 -4
- package/dist/lib/theme-manager.d.ts +52 -39
- package/dist/lib/validation-exec.d.ts +12 -0
- package/dist/{lint-mpz7vfc8.js → lint-9xhz0bsw.js} +4 -4
- package/dist/{manifest.schema-kwnrwv8b.json → manifest.schema-1q35yxs9.json} +2 -2
- package/dist/{new-nbrpsw7t.js → new-5drcm5am.js} +111 -6
- package/dist/package-rm9d2tht.json +29 -0
- package/dist/page-rules-nsj3aw42.css +128 -0
- package/dist/page-templates-7md4ev7d.css +60 -0
- package/dist/plugin-m2w7g47v.css +114 -0
- package/dist/{plugin-gg5mk26h.js → plugin-sc6zfwre.js} +4 -4
- package/dist/plugin.js-aszz0zgh.tpl +172 -0
- package/dist/plugin.test.js-mv0ghv5n.tpl +232 -0
- package/dist/{preflight-jnmd4mg2.js → preflight-g31c4060.js} +4 -4
- package/dist/{preview-hsr9d12d.js → preview-w1pqpq2g.js} +4 -4
- package/dist/{publish-pcrrn6fd.js → publish-es72yhd8.js} +4 -4
- package/dist/render.js +275 -8
- package/dist/term-box-4y0w7y32.md +7 -0
- package/dist/{theme-6fa0yjzq.js → theme-msr4ctky.js} +4 -4
- package/dist/tokens-e4xk3x4v.css +86 -0
- package/dist/{validate-7n25fx0g.js → validate-tsgprng7.js} +4 -4
- 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-
|
|
5
|
+
} from "./cli-wvw2msvn.js";
|
|
6
6
|
import {
|
|
7
7
|
log
|
|
8
|
-
} from "./cli-
|
|
8
|
+
} from "./cli-pw6z15yv.js";
|
|
9
9
|
import {
|
|
10
10
|
UsageError,
|
|
11
11
|
rejectExtraPositionals,
|
|
12
12
|
rejectUnknownFlags
|
|
13
|
-
} from "./cli-
|
|
14
|
-
import"./cli-
|
|
13
|
+
} from "./cli-v1ma3v2s.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-
|
|
9
|
+
} from "./cli-wvw2msvn.js";
|
|
10
10
|
import {
|
|
11
11
|
log
|
|
12
|
-
} from "./cli-
|
|
12
|
+
} from "./cli-pw6z15yv.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-
|
|
22
|
-
import"./cli-
|
|
21
|
+
} from "./cli-v1ma3v2s.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-
|
|
14
|
+
} from "./cli-wvw2msvn.js";
|
|
15
15
|
import {
|
|
16
16
|
FileTokenStore,
|
|
17
17
|
log
|
|
18
|
-
} from "./cli-
|
|
18
|
+
} from "./cli-pw6z15yv.js";
|
|
19
19
|
import {
|
|
20
20
|
UsageError,
|
|
21
21
|
rejectExtraPositionals,
|
|
22
22
|
rejectUnknownFlags
|
|
23
|
-
} from "./cli-
|
|
24
|
-
import"./cli-
|
|
23
|
+
} from "./cli-v1ma3v2s.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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
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 === "
|
|
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
|
-
|
|
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) {
|