gutterpress 0.11.15 → 0.11.16-alpha.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.
Files changed (32) hide show
  1. package/README.md +6 -0
  2. package/dist/{README-8b33qbjw.md → README-vsnsc1b3.md} +14 -0
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/api/index.js +4 -6
  5. package/dist/{build-cbvapvgv.js → build-46bb205t.js} +4 -4
  6. package/dist/{cli-bhs0ssm2.js → cli-6tfc4ed6.js} +1 -1
  7. package/dist/{cli-mc3waxaw.js → cli-d55z7rtk.js} +3 -3
  8. package/dist/{cli-ar54jmc5.js → cli-p8xwsknv.js} +1 -1
  9. package/dist/{cli-s99rpea3.js → cli-sq0awkbf.js} +161 -7
  10. package/dist/cli.js +12 -12
  11. package/dist/{doctor-avq8vjva.js → doctor-tjatz81j.js} +2 -2
  12. package/dist/{engine-h8zdtxhp.js → engine-nv2xddnk.js} +1 -1
  13. package/dist/{engine-s18wy689.js → engine-szx34415.js} +2 -2
  14. package/dist/{ext-9tjaavrx.js → ext-ph0770mx.js} +4 -4
  15. package/dist/{index-ypxhxvwv.js → index-9ydc9q9q.js} +1 -1
  16. package/dist/{index-wj2dxga6.js → index-azdfgy3t.js} +245 -53
  17. package/dist/{index-w6tqhafk.js → index-eawjxgkn.js} +3 -3
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +5 -7
  20. package/dist/lib/markdown/assemble.d.ts +7 -0
  21. package/dist/lib/markdown/plugins.d.ts +1 -1
  22. package/dist/lib/markdown/renderer.d.ts +91 -0
  23. package/dist/lib/snippets.d.ts +34 -92
  24. package/dist/{new-cmmshb37.js → new-daeh9p9p.js} +4 -4
  25. package/dist/{plugin.js-aszz0zgh.tpl → plugin.js-z492w01r.tpl} +44 -0
  26. package/dist/{plugin.test.js-nw7c84s6.tpl → plugin.test.js-6n8ct5e3.tpl} +35 -1
  27. package/dist/{preflight-93xdp9jk.js → preflight-bfvw2qe5.js} +4 -4
  28. package/dist/{preview-dhjma9fn.js → preview-00298rvf.js} +4 -4
  29. package/dist/{publish-a28ajgqt.js → publish-jn9t18mr.js} +4 -4
  30. package/dist/render.js +156 -2
  31. package/dist/{validate-any9f0df.js → validate-cm1m5bbf.js} +4 -4
  32. package/package.json +1 -1
@@ -1,23 +1,11 @@
1
1
  /** Folder (relative to the project root) snippets live in. */
2
2
  export declare const SNIPPETS_DIR = "snippets";
3
3
  /**
4
- * Where a merged-list entry came from (#242).
5
- *
6
- * `{ kind: "project" }` — the author's own snippet, from `<projectDir>/
7
- * snippets/`. This is the ONLY provenance `saveSnippet`/`deleteSnippet` ever
8
- * produce or touch, and therefore the ONLY provenance the picker may offer to
9
- * edit or delete — a `source` this shape is the single flag the UI needs to
10
- * gate those actions, so "can this be deleted" never drifts out of sync with
11
- * "where did this come from" (one field, not two that could disagree).
12
- *
13
- * `{ kind: "extension", ref, name }` — a READ-ONLY snippet merged in from an
14
- * installed, ENABLED extension (see {@link listInstalledExtensions}). `name`
15
- * is the extension's display name — the picker's group label, so an author
16
- * always sees WHICH extension a snippet came from, never just "not mine".
17
- * `ref` is the extension's manifest specifier (`ProjectExtensionEntry.use`);
18
- * it is round-tripped back into {@link readExtensionSnippet} so that function
19
- * can re-derive the extension's folder itself from a small, validated
20
- * identifier instead of trusting a filesystem path a caller could construct.
4
+ * Which level a snippet comes from. `project` is the book's own (the only
5
+ * level `saveSnippet`/`deleteSnippet` touch, so the only one the picker offers
6
+ * to delete); `extension` is read-only, from an enabled extension — `name` is
7
+ * its display name, `ref` its manifest specifier; `core` is reserved for
8
+ * snippets Gutterpress itself will ship.
21
9
  */
22
10
  export type SnippetSource = {
23
11
  kind: "project";
@@ -25,20 +13,24 @@ export type SnippetSource = {
25
13
  kind: "extension";
26
14
  ref: string;
27
15
  name: string;
16
+ } | {
17
+ kind: "core";
28
18
  };
29
- /** One snippet's metadata for the picker (no body — read lazily). */
19
+ /** One snippet, as the picker lists it. */
30
20
  export interface SnippetEntry {
31
- /** Display name (derived from the `.md` filename stem, prettified). */
21
+ /** Display name (derived from the filename stem, prettified). */
32
22
  name: string;
33
- /** The on-disk filename, e.g. `callout.md`. Stable id for read/delete
34
- * WITHIN its own source — an extension entry's `fileName` is only ever
35
- * resolved back to a file via {@link readExtensionSnippet} (which also
36
- * needs `source`), never via the project-only {@link readSnippet}. */
23
+ /** The file, relative to its level's snippets folder (an extension
24
+ * component's explicit `snippet:` path is relative to the extension). */
37
25
  fileName: string;
38
26
  /** Distinct `{{variable}}` names parsed from the body, in first-seen order. */
39
27
  variables: string[];
40
- /** Provenance (#242) — see {@link SnippetSource}. */
28
+ /** The snippet's text. */
29
+ body: string;
30
+ /** Which level it comes from — see {@link SnippetSource}. */
41
31
  source: SnippetSource;
32
+ /** Set when this is the example snippet for that component (marker name). */
33
+ component?: string;
42
34
  }
43
35
  /**
44
36
  * Parse the distinct `{{variable}}` placeholder names from a template, in the
@@ -52,19 +44,8 @@ export declare function extractVariables(template: string): string[];
52
44
  * Pure.
53
45
  */
54
46
  export declare function substituteVariables(template: string, values: Record<string, string>): string;
55
- /**
56
- * List the project's OWN snippets only — `<projectDir>/snippets/`, exactly as
57
- * before #242. The picker itself now calls {@link listMergedSnippets} (which
58
- * calls this as its first step); this stays exported and unchanged in
59
- * behavior because it is independently useful (and independently tested) as
60
- * "just the author's own snippets", with no extension-discovery cost paid by
61
- * a caller that doesn't need it.
62
- */
47
+ /** The book's own snippets only — `<projectDir>/snippets/`. */
63
48
  export declare function listSnippets(projectDir: string): Promise<SnippetEntry[]>;
64
- /** Read one snippet's raw body. Refuses path traversal. Project snippets
65
- * only — see {@link readExtensionSnippet} for the merged-list counterpart
66
- * that reads an extension-provided entry instead. */
67
- export declare function readSnippet(projectDir: string, fileName: string): Promise<string>;
68
49
  /**
69
50
  * Save a snippet body under `snippets/<slug(name)>.md`, creating the folder when
70
51
  * absent. Returns the stored entry (with its filename + parsed variables). The
@@ -91,61 +72,22 @@ export declare function saveSnippet(projectDir: string, name: string, body: stri
91
72
  */
92
73
  export declare function deleteSnippet(projectDir: string, fileName: string): Promise<void>;
93
74
  /**
94
- * The project's own snippets, merged with every installed-and-active
95
- * extension's (#242) — this is "the snippet host" the picker actually calls;
96
- * `listSnippets` above is now just its first ingredient.
97
- *
98
- * PRECEDENCE / collision (issue's suggested shape, point 2): when an
99
- * extension snippet's FILENAME — the slugified identity `saveSnippet` itself
100
- * derives a name into, so two different-cased spellings of the same name
101
- * collide exactly as they would on a real re-save — matches a project
102
- * snippet's, the project one wins outright and the extension's copy is
103
- * dropped from this call's result. It is not renamed, not kept reachable
104
- * under a second key, and nothing on disk is touched: the comparison and the
105
- * drop happen freshly on every call, so the instant the author renames (or
106
- * deletes) their colliding snippet, the extension's becomes visible again
107
- * with no separate "restore" step. Rationale: the moment an author saves
108
- * their own snippet under a name an extension already used, the natural
109
- * reading is "I'm overriding this one for my project" — a picker entry that
110
- * silently stays inserted from the extension forever after would contradict
111
- * that, and a picker entry that just isn't there is a far smaller surprise
112
- * than two identically-named rows the author has to guess between.
113
- *
114
- * This precedence rule is PROJECT-vs-EXTENSION only. Two different
115
- * extensions that each happen to ship a same-named snippet are NOT
116
- * deduplicated against each other — both survive, each under its own group
117
- * header (see GROUPING below), because there is no ambiguity to resolve:
118
- * unlike the project-vs-extension case, neither copy could be mistaken for
119
- * "the author's own", so there is nothing here for one to silently win over.
120
- *
121
- * GROUPING: the result is ordered project-first (`listSnippets`'s own
122
- * alphabetical order), then one contiguous run per extension — extensions
123
- * alphabetical by display name, each run alphabetical by snippet name. The
124
- * picker groups purely by noticing `source` change between consecutive
125
- * entries; there is no separate grouped/tree shape to keep in sync with this
126
- * flat list.
75
+ * One plugin-declared marker (a "component", `export const markers`) the
76
+ * project can use, with the example snippet the editor inserts for it.
77
+ * Unrelated to the `gutterpress.components` catalog file, which nothing reads.
127
78
  */
79
+ export interface MarkerComponent {
80
+ /** The marker name, without `@` — e.g. `term-box`. */
81
+ name: string;
82
+ /** The extension that declares it. */
83
+ source: Extract<SnippetSource, {
84
+ kind: "extension";
85
+ }>;
86
+ /** The winning example snippet's body (book, then extension, then core). */
87
+ snippet?: string;
88
+ }
89
+ /** Every snippet the project can insert, from every level — see {@link collectLibrary}. */
128
90
  export declare function listMergedSnippets(projectDir: string): Promise<SnippetEntry[]>;
129
- /**
130
- * Read one extension-provided snippet's raw body (#242) — the read-only
131
- * counterpart to `readSnippet` for entries `listMergedSnippets` tagged with
132
- * an extension `source`.
133
- *
134
- * Deliberately NOT a raw-path read: `source` carries only the same small,
135
- * stable `{ kind, ref }` pair `listMergedSnippets` already handed back (see
136
- * {@link SnippetSource}), and this function re-runs the EXACT SAME discovery
137
- * {@link listMergedSnippets} used ({@link listInstalledExtensions}) to find
138
- * the matching extension's folder again, rather than trusting any path a
139
- * caller could construct directly — the same defense-in-depth stance
140
- * `resolveSnippetPath` already takes for the project's own snippets, now
141
- * extended to a second, per-extension root instead of a single project one.
142
- *
143
- * Throws when `source` no longer resolves to an installed, active extension
144
- * (it was disabled or removed since the list was fetched — the picker's existing `error` display already handles a
145
- * thrown read the same way a vanished project snippet would) or when
146
- * `fileName` escapes that extension's snippets folder.
147
- */
148
- export declare function readExtensionSnippet(projectDir: string, source: {
149
- kind: "extension";
150
- ref: string;
151
- }, fileName: string): Promise<string>;
91
+ /** Every component the project's enabled extensions declare, each with its
92
+ * winning example snippet (book > extension > core) — see {@link collectLibrary}. */
93
+ export declare function listMarkerComponents(projectDir: string): Promise<MarkerComponent[]>;
@@ -7,16 +7,16 @@ import {
7
7
  TARGET_IDS,
8
8
  scaffoldExtension,
9
9
  scaffoldProject
10
- } from "./cli-s99rpea3.js";
10
+ } from "./cli-sq0awkbf.js";
11
11
  import {
12
12
  resolveGhostscript
13
- } from "./cli-bhs0ssm2.js";
13
+ } from "./cli-6tfc4ed6.js";
14
14
  import {
15
15
  UsageError,
16
16
  rejectExtraPositionals,
17
17
  rejectUnknownFlags
18
- } from "./cli-ar54jmc5.js";
19
- import"./cli-mc3waxaw.js";
18
+ } from "./cli-p8xwsknv.js";
19
+ import"./cli-d55z7rtk.js";
20
20
  import {
21
21
  EXIT_CODES
22
22
  } from "./cli-46ycxe6r.js";
@@ -67,6 +67,34 @@ const PREFIX = "{{PREFIX}}";
67
67
  * variants extra classes keyed by the marker's bare word
68
68
  * label a label element built from one of the marker's attributes
69
69
  * autoCloseAt ["eof"] closes an unclosed container at end of file
70
+ * snippet example content the editor inserts for this component, as a
71
+ * path inside this folder (default: snippets/<name>.md — so
72
+ * `snippets/term-box.md` is term-box's example already)
73
+ * validate a function that checks what an author put inside the
74
+ * component and returns problems — see below
75
+ *
76
+ * VALIDATE — opt-in structure checks
77
+ *
78
+ * Gutterpress calls `validate(component)` for every use of the component
79
+ * whenever it checks a book (the desktop Problems panel, `gutterpress
80
+ * validate`, builds). `component` is a plain object — no markdown-it, no
81
+ * Gutterpress internals:
82
+ *
83
+ * component.name "term-box"
84
+ * component.variant the bare word after the marker ("note"), or null
85
+ * component.attrs the marker's attributes, e.g. { label: "…" }
86
+ * component.line the marker's line in the file
87
+ * component.text the raw markdown inside the component
88
+ * component.blocks its content, in order, each with `type` and `line`:
89
+ * heading {level, text} · paragraph {text} ·
90
+ * image {alt, src} · list {ordered, items} ·
91
+ * quote {text} · code {lang, text} · table · rule ·
92
+ * html {text} · component {name}
93
+ *
94
+ * Return nothing when it is fine, or a list of problems. A problem is a
95
+ * message string, or `{ message, line, severity }` to point at a line or
96
+ * choose "error" / "warning" (the default) / "info". The function must be
97
+ * synchronous; if it throws, the author sees that as a problem too.
70
98
  *
71
99
  * Names are validated when the book loads: lower-case letters, digits and
72
100
  * hyphens; they may not start with `end-`, and they may not shadow a core
@@ -88,6 +116,22 @@ export const markers = {
88
116
  from: "attr:label",
89
117
  },
90
118
  autoCloseAt: ["eof"],
119
+ // snippet: "examples/term-box.md", // only needed to override snippets/term-box.md
120
+ validate(box) {
121
+ const problems = [];
122
+ if (!box.attrs.label) {
123
+ problems.push('Give the term box a label: @term-box note label="Your term"');
124
+ }
125
+ if (!box.blocks.some((block) => block.type === "paragraph")) {
126
+ problems.push("Add a paragraph explaining the term.");
127
+ }
128
+ for (const block of box.blocks) {
129
+ if (block.type === "heading") {
130
+ problems.push({ message: "Use the label instead of a heading.", line: block.line });
131
+ }
132
+ }
133
+ return problems;
134
+ },
91
135
  },
92
136
  };
93
137
 
@@ -30,6 +30,8 @@
30
30
  * package cannot import core (see README.md, "Why you cannot import
31
31
  * gutterpress"). So the table's CONTRACT is checked here, and the rendered
32
32
  * container is checked by running `gutterpress preview` on a real book.
33
+ * A component's `validate` needs no core at all — it takes a plain object —
34
+ * so it is tested directly, with hand-written components.
33
35
  */
34
36
  import { describe, expect, test } from "bun:test";
35
37
  import { existsSync, readFileSync } from "node:fs";
@@ -212,6 +214,37 @@ describe("conventions", () => {
212
214
  }
213
215
  });
214
216
 
217
+ test("each component's snippet opens and closes its own marker", () => {
218
+ // The editor inserts this file when an author picks the component, so it
219
+ // is the example of the structure the component expects.
220
+ for (const [name, decl] of Object.entries(markers)) {
221
+ if (decl.deprecated !== undefined || decl.alias !== undefined) continue;
222
+ const rel = decl.snippet ?? `${pkg.gutterpress?.snippets ?? "snippets"}/${name}.md`;
223
+ if (!existsSync(path.join(root, rel))) continue;
224
+ const snippet = read(rel);
225
+ expect(snippet.trimStart().startsWith(`@${name}`)).toBe(true);
226
+ expect(snippet).toContain(`@end-${name}`);
227
+ }
228
+ });
229
+
230
+ test("term-box's validate accepts a well-formed box and explains what a bad one is missing", () => {
231
+ // A component exactly as Gutterpress hands it to `validate`.
232
+ const good = {
233
+ name: "term-box",
234
+ variant: "note",
235
+ attrs: { label: "Gutter" },
236
+ line: 1,
237
+ text: "The space between two facing pages.",
238
+ blocks: [{ type: "paragraph", text: "The space between two facing pages.", line: 2 }],
239
+ };
240
+ expect(markers["term-box"].validate(good)).toEqual([]);
241
+
242
+ const bad = { ...good, attrs: {}, blocks: [{ type: "heading", level: 3, text: "Gutter", line: 2 }] };
243
+ const problems = markers["term-box"].validate(bad);
244
+ expect(problems).toHaveLength(3);
245
+ expect(problems).toContainEqual({ message: "Use the label instead of a heading.", line: 2 });
246
+ });
247
+
215
248
  test("every public custom property carries the prefix too", () => {
216
249
  // `--x` declared at :root is global. An unprefixed one would collide with
217
250
  // the book's own tokens exactly as an unprefixed class would.
@@ -226,6 +259,7 @@ describe("conventions", () => {
226
259
  // Gutterpress wraps this extension's CSS in `@layer ext.<name>` itself,
227
260
  // in `extensions:` list order — see the header comment in
228
261
  // styles/plugin.css. A layer declared here would only nest inside it.
229
- expect(css).not.toMatch(/@layer/);
262
+ // Rules only: that header comment itself mentions `@layer`.
263
+ expect(cssRules).not.toMatch(/@layer/);
230
264
  });
231
265
  });
@@ -2,16 +2,16 @@ import {
2
2
  executeValidation,
3
3
  publishTargetFor,
4
4
  reportMissingTools
5
- } from "./cli-s99rpea3.js";
5
+ } from "./cli-sq0awkbf.js";
6
6
  import {
7
7
  log
8
- } from "./cli-bhs0ssm2.js";
8
+ } from "./cli-6tfc4ed6.js";
9
9
  import {
10
10
  UsageError,
11
11
  rejectExtraPositionals,
12
12
  rejectUnknownFlags
13
- } from "./cli-ar54jmc5.js";
14
- import"./cli-mc3waxaw.js";
13
+ } from "./cli-p8xwsknv.js";
14
+ import"./cli-d55z7rtk.js";
15
15
  import {
16
16
  EXIT_CODES
17
17
  } from "./cli-46ycxe6r.js";
@@ -6,10 +6,10 @@ import {
6
6
  runBuild,
7
7
  splitOutPath,
8
8
  startPreviewServer
9
- } from "./cli-s99rpea3.js";
9
+ } from "./cli-sq0awkbf.js";
10
10
  import {
11
11
  log
12
- } from "./cli-bhs0ssm2.js";
12
+ } from "./cli-6tfc4ed6.js";
13
13
  import {
14
14
  UsageError,
15
15
  parseFormat,
@@ -17,8 +17,8 @@ import {
17
17
  rejectExtraPositionals,
18
18
  rejectUnknownFlags,
19
19
  resolvePort
20
- } from "./cli-ar54jmc5.js";
21
- import"./cli-mc3waxaw.js";
20
+ } from "./cli-p8xwsknv.js";
21
+ import"./cli-d55z7rtk.js";
22
22
  import {
23
23
  BuildError
24
24
  } from "./cli-46ycxe6r.js";
@@ -11,17 +11,17 @@ import {
11
11
  publishProviderFor,
12
12
  resolvePublishFormat,
13
13
  runPublish
14
- } from "./cli-s99rpea3.js";
14
+ } from "./cli-sq0awkbf.js";
15
15
  import {
16
16
  FileTokenStore,
17
17
  log
18
- } from "./cli-bhs0ssm2.js";
18
+ } from "./cli-6tfc4ed6.js";
19
19
  import {
20
20
  UsageError,
21
21
  rejectExtraPositionals,
22
22
  rejectUnknownFlags
23
- } from "./cli-ar54jmc5.js";
24
- import"./cli-mc3waxaw.js";
23
+ } from "./cli-p8xwsknv.js";
24
+ import"./cli-d55z7rtk.js";
25
25
  import {
26
26
  EXIT_CODES
27
27
  } from "./cli-46ycxe6r.js";
package/dist/render.js CHANGED
@@ -172,6 +172,133 @@ function warn(env, line, type, message, marker) {
172
172
  env.layoutWarnings = [];
173
173
  env.layoutWarnings.push({ line, type, message, marker });
174
174
  }
175
+ var COMPONENT_SEVERITIES = ["error", "warning", "info"];
176
+ function blockEnd(tokens, i) {
177
+ if (tokens[i].nesting !== 1)
178
+ return i;
179
+ let depth = 0;
180
+ for (let j = i;j < tokens.length; j++) {
181
+ depth += tokens[j].nesting;
182
+ if (depth === 0)
183
+ return j;
184
+ }
185
+ return tokens.length - 1;
186
+ }
187
+ function inlineText(tokens, from, to) {
188
+ const parts = [];
189
+ for (let k = from;k <= to; k++)
190
+ if (tokens[k].type === "inline")
191
+ parts.push(tokens[k].content);
192
+ return parts.join(`
193
+ `);
194
+ }
195
+ function soleImage(inline) {
196
+ const parts = (inline && inline.children || []).filter((c) => c.type !== "softbreak" && !(c.type === "text" && !c.content.trim()));
197
+ return parts.length === 1 && parts[0].type === "image" ? parts[0] : null;
198
+ }
199
+ function toComponentBlocks(tokens, fallbackLine) {
200
+ const blocks = [];
201
+ for (let i = 0;i < tokens.length; i++) {
202
+ const tok = tokens[i];
203
+ const end = blockEnd(tokens, i);
204
+ const line = tok.map ? tok.map[0] + 1 : tok.meta && tok.meta.line || fallbackLine;
205
+ switch (tok.type) {
206
+ case "heading_open":
207
+ blocks.push({ type: "heading", level: Number(tok.tag.slice(1)), text: inlineText(tokens, i, end), line });
208
+ break;
209
+ case "paragraph_open": {
210
+ const image = soleImage(tokens[i + 1]);
211
+ blocks.push(image ? { type: "image", alt: image.content, src: image.attrGet("src") || "", line } : { type: "paragraph", text: inlineText(tokens, i, end), line });
212
+ break;
213
+ }
214
+ case "bullet_list_open":
215
+ case "ordered_list_open": {
216
+ const items = [];
217
+ for (let k = i + 1;k < end; k++) {
218
+ if (tokens[k].type !== "list_item_open" || tokens[k].level !== tok.level + 1)
219
+ continue;
220
+ const itemEnd = blockEnd(tokens, k);
221
+ const first = tokens.slice(k + 1, itemEnd).find((t) => t.type === "inline");
222
+ items.push(first ? first.content : "");
223
+ k = itemEnd;
224
+ }
225
+ blocks.push({ type: "list", ordered: tok.type === "ordered_list_open", items, line });
226
+ break;
227
+ }
228
+ case "blockquote_open":
229
+ blocks.push({ type: "quote", text: inlineText(tokens, i, end), line });
230
+ break;
231
+ case "fence":
232
+ case "code_block":
233
+ blocks.push({ type: "code", lang: (tok.info || "").trim().split(/\s+/)[0] || "", text: tok.content, line });
234
+ break;
235
+ case "table_open":
236
+ blocks.push({ type: "table", line });
237
+ break;
238
+ case "hr":
239
+ blocks.push({ type: "rule", line });
240
+ break;
241
+ case "html_block":
242
+ blocks.push({ type: "html", text: tok.content, line });
243
+ break;
244
+ case "layout_component_open":
245
+ blocks.push({ type: "component", name: tok.meta.component, line });
246
+ break;
247
+ default:
248
+ blocks.push({ type: tok.type.replace(/_open$/, ""), line });
249
+ }
250
+ i = end;
251
+ }
252
+ return blocks;
253
+ }
254
+ function runComponentValidate(env, decl, meta, tokens, lines) {
255
+ const name = meta.component;
256
+ const line = meta.line || 0;
257
+ const report = (at, type, message, severity) => {
258
+ if (!env.layoutWarnings)
259
+ env.layoutWarnings = [];
260
+ env.layoutWarnings.push(severity ? { line: at, type, message, severity } : { line: at, type, message });
261
+ };
262
+ const failed = (why) => report(line, "component_validate_failed", `@${name}: plugin "${decl.validateOwner}"'s validate() ${why}`);
263
+ let lastLine = line;
264
+ for (const t of tokens)
265
+ if (t.map && t.map[1] > lastLine)
266
+ lastLine = t.map[1];
267
+ const component = {
268
+ name,
269
+ variant: meta.variant || null,
270
+ attrs: { ...meta.attrs },
271
+ line,
272
+ text: lines.slice(line, lastLine).join(`
273
+ `),
274
+ blocks: toComponentBlocks(tokens, line)
275
+ };
276
+ let result;
277
+ try {
278
+ result = decl.validate(component);
279
+ } catch (error) {
280
+ failed(`threw: ${error instanceof Error ? error.message : String(error)}`);
281
+ return;
282
+ }
283
+ if (result === undefined || result === null)
284
+ return;
285
+ if (typeof result === "string")
286
+ result = [result];
287
+ if (!Array.isArray(result)) {
288
+ failed("must return a message, an array of problems, or nothing (and must not be async).");
289
+ return;
290
+ }
291
+ for (const entry of result) {
292
+ const problem = typeof entry === "string" ? { message: entry } : entry;
293
+ if (!problem || typeof problem.message !== "string" || !problem.message.trim()) {
294
+ failed("returned a problem without a message.");
295
+ continue;
296
+ }
297
+ const at = Number.isInteger(problem.line) && problem.line > 0 ? problem.line : line;
298
+ const severity = COMPONENT_SEVERITIES.includes(problem.severity) ? problem.severity : undefined;
299
+ report(at, "component_invalid", `@${name}: ${problem.message}`, severity);
300
+ }
301
+ }
175
302
  function proseEscapeHint(kind) {
176
303
  return ` If this line is prose that happens to begin with "@${kind}" — a wrapped sentence, for` + ` instance — escape it as \\@${kind} or write it as \`@${kind}\` in backticks, and it stays text.`;
177
304
  }
@@ -274,13 +401,20 @@ function resolveContainerShape(name, pluginName, decl) {
274
401
  }
275
402
  autoCloseAtEof = decl.autoCloseAt.includes("eof");
276
403
  }
277
- return { tag, classBase, variants, label, autoCloseAtEof };
404
+ if (decl.validate !== undefined && typeof decl.validate !== "function") {
405
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`validate\` that is not a function.`);
406
+ }
407
+ const check = decl.validate ? { validate: decl.validate, validateOwner: pluginName } : {};
408
+ return { tag, classBase, variants, label, autoCloseAtEof, ...check };
278
409
  }
279
410
  function resolveMarkerDeclaration(name, rawDecl, rawRegistry, originOf) {
280
411
  const pluginName = originOf.get(name);
281
412
  if (typeof rawDecl !== "object" || rawDecl === null || Array.isArray(rawDecl)) {
282
413
  throw new Error(`Plugin "${pluginName}"'s marker "@${name}" is not a plain object.`);
283
414
  }
415
+ if (rawDecl.snippet !== undefined && (typeof rawDecl.snippet !== "string" || !rawDecl.snippet.trim())) {
416
+ throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`snippet\` that is not a non-empty path string.`);
417
+ }
284
418
  if (rawDecl.deprecated !== undefined) {
285
419
  if (typeof rawDecl.deprecated !== "string" || !rawDecl.deprecated) {
286
420
  throw new Error(`Plugin "${pluginName}"'s marker "@${name}" has a \`deprecated\` value that is not a non-empty string.`);
@@ -495,8 +629,8 @@ function plugin(md, pluginOptions = {}) {
495
629
  }
496
630
  function openDeclaredMarker(meta, decl) {
497
631
  const t = new state.Token("layout_component_open", decl.tag, 1);
498
- t.meta = { line: meta.__line };
499
632
  const variant = meta.name || decl.presetVariant || null;
633
+ t.meta = { line: meta.__line, component: meta.kind, variant, attrs: meta.attrs || {}, labelled: false };
500
634
  const variantClass = variant && decl.variants && decl.variants[variant] || "";
501
635
  const baseClass = [decl.classBase, variantClass].filter(Boolean).join(" ");
502
636
  addClasses(t, baseClass, meta.attrs && meta.attrs.class ? meta.attrs.class : "");
@@ -510,6 +644,7 @@ function plugin(md, pluginOptions = {}) {
510
644
  labelToken.content = `<${decl.label.tag} class="${escapeAttr(decl.label.class)}">${escapeHtml(value)}</${decl.label.tag}>
511
645
  `;
512
646
  out.push(labelToken);
647
+ t.meta.labelled = true;
513
648
  }
514
649
  }
515
650
  }
@@ -771,6 +906,25 @@ function plugin(md, pluginOptions = {}) {
771
906
  stack.closeAll();
772
907
  state.tokens = out;
773
908
  });
909
+ if (declaredMarkers && [...declaredMarkers.values()].some((d) => d.validate)) {
910
+ md.core.ruler.push("gp_component_validate", function(state) {
911
+ let lines = null;
912
+ const tokens = state.tokens;
913
+ for (let i = 0;i < tokens.length; i++) {
914
+ const tok = tokens[i];
915
+ if (tok.type !== "layout_component_open")
916
+ continue;
917
+ const decl = declaredMarkers.get(tok.meta.component);
918
+ if (!decl || !decl.validate)
919
+ continue;
920
+ if (!lines)
921
+ lines = state.src.split(`
922
+ `);
923
+ const from = i + (tok.meta.labelled ? 2 : 1);
924
+ runComponentValidate(state.env, decl, tok.meta, tokens.slice(from, blockEnd(tokens, i)), lines);
925
+ }
926
+ });
927
+ }
774
928
  md.renderer.rules.layout_page_break = (tokens, idx) => {
775
929
  const cls = tokens[idx].attrGet("class") || "gp-page-break";
776
930
  const rangeAttr = tokens[idx].attrGet("data-source-range");
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  executeAndReport
3
- } from "./cli-s99rpea3.js";
3
+ } from "./cli-sq0awkbf.js";
4
4
  import {
5
5
  log
6
- } from "./cli-bhs0ssm2.js";
6
+ } from "./cli-6tfc4ed6.js";
7
7
  import {
8
8
  UsageError,
9
9
  rejectExtraPositionals,
10
10
  rejectUnknownFlags
11
- } from "./cli-ar54jmc5.js";
12
- import"./cli-mc3waxaw.js";
11
+ } from "./cli-p8xwsknv.js";
12
+ import"./cli-d55z7rtk.js";
13
13
  import {
14
14
  EXIT_CODES
15
15
  } from "./cli-46ycxe6r.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gutterpress",
3
- "version": "0.11.15",
3
+ "version": "0.11.16-alpha.1",
4
4
  "description": "Markdown-to-PDF converter for professional print layout using a native Chromium print engine and Ghostscript.",
5
5
  "author": "itlackey",
6
6
  "license": "MPL-2.0",