contour-deck 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +159 -2
  3. package/dist/astro/Deck.astro +61 -0
  4. package/dist/astro/host.d.ts +13 -0
  5. package/dist/astro/host.js +116 -0
  6. package/dist/astro/legacy.d.ts +22 -0
  7. package/dist/astro/legacy.js +42 -0
  8. package/dist/core/deck.d.ts +2 -0
  9. package/dist/core/deck.js +587 -0
  10. package/dist/core/dom.d.ts +8 -0
  11. package/dist/core/dom.js +22 -0
  12. package/dist/core/gestures.d.ts +6 -0
  13. package/dist/core/gestures.js +24 -0
  14. package/dist/core/index.d.ts +3 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/labels.d.ts +2 -0
  17. package/dist/core/labels.js +19 -0
  18. package/dist/core/model.d.ts +37 -0
  19. package/dist/core/model.js +51 -0
  20. package/dist/core/navigator.d.ts +6 -0
  21. package/dist/core/navigator.js +12 -0
  22. package/dist/core/notes.d.ts +11 -0
  23. package/dist/core/notes.js +34 -0
  24. package/dist/core/overview.d.ts +11 -0
  25. package/dist/core/overview.js +104 -0
  26. package/dist/core/rail.d.ts +8 -0
  27. package/dist/core/rail.js +26 -0
  28. package/dist/core/stops.d.ts +17 -0
  29. package/dist/core/stops.js +40 -0
  30. package/dist/core/transitions.d.ts +9 -0
  31. package/dist/core/transitions.js +37 -0
  32. package/dist/core/types.d.ts +70 -0
  33. package/dist/core/types.js +1 -0
  34. package/dist/rehype/hast.d.ts +26 -0
  35. package/dist/rehype/hast.js +48 -0
  36. package/dist/rehype/index.d.ts +12 -0
  37. package/dist/rehype/index.js +221 -0
  38. package/dist/rehype/page.d.ts +2 -0
  39. package/dist/rehype/page.js +84 -0
  40. package/dist/rehype/reading.d.ts +11 -0
  41. package/dist/rehype/reading.js +9 -0
  42. package/dist/rehype/titles.d.ts +19 -0
  43. package/dist/rehype/titles.js +63 -0
  44. package/dist/remark/index.d.ts +20 -0
  45. package/dist/remark/index.js +117 -0
  46. package/dist/shared/markers.d.ts +15 -0
  47. package/dist/shared/markers.js +15 -0
  48. package/dist/style.css +629 -0
  49. package/docs/CONTRACT.md +164 -0
  50. package/package.json +82 -4
  51. package/styles/base.css +3 -0
  52. package/styles/deck.css +151 -0
  53. package/styles/index.css +3 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Assembles the deck of docs/CONTRACT.md from the hast of a slide post:
3
+ * deck → block → slide → page, with notes, steps, reading times, legacy indexes and slide ids.
4
+ *
5
+ * - A top-level `<hr>` (`---`) starts a slide. Inside it, each `::vertical` child is a further page
6
+ * of the slide, unless its first `h2` differs from the slide's own: then it starts a new slide
7
+ * in the same block (a child without an `h2` is always a page).
8
+ * - Blocks: `::block{…}` starts one. A deck with no `::block` starts a block at every slide that
9
+ * opens with a `####` kicker, titled by the kicker. A deck with neither uses section dividers:
10
+ * a slide whose first page is just an `h1` (`# 拆解`) starts a block titled by it. A title
11
+ * starting with 附錄 gets `data-kind="appendix"`. Without any of these the deck is one block.
12
+ * - `<section>` inside page content is left alone: the contract reads data attributes only.
13
+ *
14
+ * Run it after `contour-deck/remark`, which writes the markers this plugin reads.
15
+ */
16
+ import { MARK_BLOCK, MARK_SLIDE, MARK_VERTICAL } from "../shared/markers.js";
17
+ import { element, isBlank, isElement, markOf, plainText, walkElements, } from "./hast.js";
18
+ import { buildPage } from "./page.js";
19
+ import { dividerOf, firstHeading, kickerOf, kindOf, shortOf, slideTitle } from "./titles.js";
20
+ const ORIGIN = "contour-deck:rehype";
21
+ function isFootnotes(node) {
22
+ return isElement(node) && node.tagName === "section" && node.properties.dataFootnotes !== undefined;
23
+ }
24
+ /** Splits at breaks matching `isSplit`, dropping the breaks and the groups `keep` rejects. */
25
+ function splitAt(nodes, isSplit, keep) {
26
+ const groups = [[]];
27
+ for (const node of nodes) {
28
+ if (isSplit(node))
29
+ groups.push([]);
30
+ else
31
+ groups[groups.length - 1].push(node);
32
+ }
33
+ return groups.filter(keep);
34
+ }
35
+ const isSlideBreak = (node) => isElement(node) && node.tagName === "hr" && markOf(node) === undefined;
36
+ const isPageBreak = (node) => markOf(node) === MARK_VERTICAL;
37
+ const hasContent = (group) => group.some(node => !isBlank(node));
38
+ function blockSpecOf(marker) {
39
+ const { dataTitle, dataShort, dataMinutes, dataKind, id } = marker.properties;
40
+ return {
41
+ title: String(dataTitle),
42
+ short: dataShort === undefined ? undefined : String(dataShort),
43
+ minutes: dataMinutes === undefined ? undefined : String(dataMinutes),
44
+ kind: dataKind === undefined ? undefined : String(dataKind),
45
+ id: typeof id === "string" ? id : undefined,
46
+ };
47
+ }
48
+ /**
49
+ * Reads the slides out of the flow. A `---` always starts a slide. Inside a `---` group each
50
+ * `::vertical` child is a further page of the current slide, unless it starts a new slide:
51
+ * it has an `h2` that differs from the slide's own `h2`, or it carries `::block` or `::slide`.
52
+ * A child without an `h2` (only `h3`, or body text) is always a page.
53
+ */
54
+ function readSlides(flow, file) {
55
+ const drafts = [];
56
+ let pending;
57
+ let legacy = -1;
58
+ for (const group of splitAt(flow, isSlideBreak, group => group.length > 0)) {
59
+ let current;
60
+ let vertical = 0;
61
+ for (const chunk of splitAt(group, isPageBreak, chunk => chunk.length > 0)) {
62
+ const blocks = chunk.filter(node => markOf(node) === MARK_BLOCK);
63
+ const slides = chunk.filter(node => markOf(node) === MARK_SLIDE);
64
+ const rest = chunk.filter(node => !blocks.includes(node) && !slides.includes(node));
65
+ if (blocks.length > 1)
66
+ file.fail("a page holds two ::block directives", blocks[1], ORIGIN);
67
+ if (slides.length > 1)
68
+ file.fail("a page holds two ::slide directives", slides[1], ORIGIN);
69
+ const spec = blocks[0] && blockSpecOf(blocks[0]);
70
+ const twice = () => file.fail(`::block "${spec?.title}" follows ::block "${pending?.title}" with no slide between`, blocks[0], ORIGIN);
71
+ if (!hasContent(rest)) {
72
+ if (slides.length)
73
+ file.fail("::slide without any content in its slide", slides[0], ORIGIN);
74
+ if (spec) {
75
+ if (pending)
76
+ twice();
77
+ pending = spec;
78
+ }
79
+ continue;
80
+ }
81
+ if (spec && pending)
82
+ twice();
83
+ const block = spec ?? pending;
84
+ const h2 = firstHeading(rest, 2);
85
+ const startsSlide = !current || block || slides.length || (h2 !== undefined && h2 !== current.h2);
86
+ if (startsSlide) {
87
+ if (!current)
88
+ legacy += 1;
89
+ current = {
90
+ pages: [rest],
91
+ id: slides[0] && String(slides[0].properties.id),
92
+ block,
93
+ h2,
94
+ legacy,
95
+ legacyV: vertical,
96
+ };
97
+ drafts.push(current);
98
+ }
99
+ else {
100
+ current.pages.push(rest);
101
+ }
102
+ pending = undefined;
103
+ vertical += 1;
104
+ }
105
+ }
106
+ if (pending)
107
+ file.fail(`::block "${pending.title}" has no slide after it`, undefined, ORIGIN);
108
+ return drafts;
109
+ }
110
+ /** Fails on a block or slide marker that is not at the top level of a slide. */
111
+ function rejectNestedMarkers(drafts, file) {
112
+ for (const draft of drafts) {
113
+ for (const page of draft.pages) {
114
+ walkElements(page, node => {
115
+ const mark = markOf(node);
116
+ if (mark === MARK_BLOCK || mark === MARK_SLIDE) {
117
+ file.fail(`::${mark} must stand at the top level of a slide, not inside another element`, node, ORIGIN);
118
+ }
119
+ });
120
+ }
121
+ }
122
+ }
123
+ /**
124
+ * Groups slides into blocks by the rules in the header. Where a block gets no title of its own
125
+ * (slides before the first marker) it is titled by the deck title, else the deck's first `h1`,
126
+ * else its first slide.
127
+ */
128
+ function groupBlocks(drafts, deckTitle) {
129
+ const explicit = drafts.some(draft => draft.block);
130
+ const kickers = !explicit && drafts.some(draft => kickerOf(draft.pages[0]));
131
+ const groups = [];
132
+ const heading = (draft) => {
133
+ const node = kickers ? kickerOf(draft.pages[0]) : dividerOf(draft.pages[0]);
134
+ return node && plainText(node);
135
+ };
136
+ for (const draft of drafts) {
137
+ let spec = draft.block;
138
+ if (!explicit) {
139
+ const title = heading(draft);
140
+ if (title)
141
+ spec = { title, short: shortOf(title), kind: kindOf(title) };
142
+ }
143
+ else if (spec) {
144
+ spec = { ...spec, short: spec.short ?? shortOf(spec.title) };
145
+ }
146
+ if (spec || !groups.length) {
147
+ const lead = deckTitle || firstHeading(drafts.flatMap(item => item.pages.flat()), 1) || slideTitle(draft.pages, "Slides");
148
+ groups.push({ spec: spec ?? { title: lead }, slides: [] });
149
+ }
150
+ groups[groups.length - 1].slides.push(draft);
151
+ }
152
+ return groups;
153
+ }
154
+ function ids(nodes) {
155
+ const found = new Set();
156
+ walkElements(nodes, node => {
157
+ if (typeof node.properties.id === "string")
158
+ found.add(node.properties.id);
159
+ });
160
+ return found;
161
+ }
162
+ function assemble(drafts, deckTitle, file) {
163
+ const taken = ids(drafts.flatMap(draft => draft.pages.flat()));
164
+ const claim = (id, what) => {
165
+ if (taken.has(id))
166
+ file.fail(`${what} id "${id}" is already used in the document`, undefined, ORIGIN);
167
+ taken.add(id);
168
+ return id;
169
+ };
170
+ return groupBlocks(drafts, deckTitle).map(({ spec, slides }, blockIndex) => {
171
+ const properties = { dataCtBlock: true, dataTitle: spec.title };
172
+ if (spec.short)
173
+ properties.dataShort = spec.short;
174
+ if (spec.minutes)
175
+ properties.dataMinutes = spec.minutes;
176
+ if (spec.kind)
177
+ properties.dataKind = spec.kind;
178
+ if (spec.id)
179
+ properties.id = claim(spec.id, "block");
180
+ const sections = slides.map((draft, slideIndex) => {
181
+ const ordinal = `${blockIndex + 1}-${slideIndex + 1}`;
182
+ const id = draft.id ? claim(draft.id, "slide") : claim(`s-${ordinal}`, "slide");
183
+ return element("section", {
184
+ dataCtSlide: true,
185
+ id,
186
+ dataTitle: slideTitle(draft.pages, `Slide ${ordinal.replace("-", ".")}`),
187
+ dataLegacy: draft.legacy,
188
+ dataLegacyV: draft.legacyV,
189
+ }, draft.pages.map(buildPage));
190
+ });
191
+ return element("section", properties, sections);
192
+ });
193
+ }
194
+ function frontmatterTitle(file) {
195
+ const { astro } = file.data;
196
+ const title = astro?.frontmatter?.title;
197
+ return typeof title === "string" ? title : undefined;
198
+ }
199
+ export function rehypeDeck(options = {}) {
200
+ return (tree, file) => {
201
+ if (options.match && !options.match(file))
202
+ return;
203
+ const deckTitle = options.title ?? frontmatterTitle(file);
204
+ const before = [];
205
+ const after = [];
206
+ const flow = [];
207
+ for (const node of tree.children ?? []) {
208
+ if (node.type === "mdxjsEsm")
209
+ before.push(node);
210
+ else if (isFootnotes(node))
211
+ after.push(node);
212
+ else
213
+ flow.push(node);
214
+ }
215
+ const drafts = readSlides(flow, file);
216
+ rejectNestedMarkers(drafts, file);
217
+ const blocks = assemble(drafts, deckTitle, file);
218
+ const deck = element("div", { dataCtDeck: true, ...(deckTitle ? { dataTitle: deckTitle } : {}) }, blocks);
219
+ tree.children = [...before, deck, ...after];
220
+ };
221
+ }
@@ -0,0 +1,2 @@
1
+ import { type Element, type HastNode } from "./hast.js";
2
+ export declare function buildPage(children: HastNode[]): Element;
@@ -0,0 +1,84 @@
1
+ /** One page: its notes, its steps, its reading time. */
2
+ import { NOTES, MARK_BUILD } from "../shared/markers.js";
3
+ import { element, hasClass, isBlank, isElement, markOf, textOf, } from "./hast.js";
4
+ import { PAGE_MS, readingMs, STEP_MS } from "./reading.js";
5
+ function isNotes(node) {
6
+ return isElement(node) && NOTES in node.properties;
7
+ }
8
+ function isStep(node) {
9
+ return isElement(node) && "dataCtStep" in node.properties;
10
+ }
11
+ /** Removes every notes element, at any depth, and returns them in document order. */
12
+ function takeNotes(children, notes) {
13
+ return children.filter(node => {
14
+ if (isNotes(node)) {
15
+ notes.push(node);
16
+ return false;
17
+ }
18
+ if (node.children)
19
+ node.children = takeNotes(node.children, notes);
20
+ return true;
21
+ });
22
+ }
23
+ /** What a `:::build` reveals one at a time: list items, table body rows, or other blocks. */
24
+ function stepTargets(build) {
25
+ const targets = [];
26
+ for (const child of build.children) {
27
+ if (!isElement(child))
28
+ continue;
29
+ if (child.tagName === "ul" || child.tagName === "ol") {
30
+ targets.push(...child.children.filter((item) => isElement(item) && item.tagName === "li"));
31
+ }
32
+ else if (child.tagName === "table" || hasClass(child, "table-scroll")) {
33
+ const visit = (node) => {
34
+ if (!isElement(node) || node.tagName === "thead")
35
+ return;
36
+ if (node.tagName === "tr")
37
+ targets.push(node);
38
+ else
39
+ node.children.forEach(visit);
40
+ };
41
+ visit(child);
42
+ }
43
+ else {
44
+ targets.push(child);
45
+ }
46
+ }
47
+ return targets;
48
+ }
49
+ /** Marks the children of each build marker as steps and replaces the marker by its children. */
50
+ function applyBuilds(nodes) {
51
+ return nodes.flatMap(node => {
52
+ if (!isElement(node))
53
+ return [node];
54
+ node.children = applyBuilds(node.children);
55
+ if (markOf(node) !== MARK_BUILD)
56
+ return [node];
57
+ for (const target of stepTargets(node)) {
58
+ target.properties = {
59
+ ...target.properties,
60
+ dataCtStep: true,
61
+ dataMs: readingMs(textOf(target), STEP_MS),
62
+ };
63
+ }
64
+ return node.children;
65
+ });
66
+ }
67
+ /** `--i: n` on each content child, the order of the entrance animation. */
68
+ function staggerIndex(content) {
69
+ content.filter(isElement).forEach((node, order) => {
70
+ const style = node.properties.style;
71
+ node.properties.style =
72
+ typeof style === "string" && style.trim() ? `--i: ${order}; ${style}` : `--i: ${order}`;
73
+ });
74
+ }
75
+ export function buildPage(children) {
76
+ const notes = [];
77
+ const content = applyBuilds(takeNotes(children, notes)).filter(node => !isBlank(node));
78
+ staggerIndex(content);
79
+ const withoutSteps = content.map(node => textOf(node, isStep)).join("");
80
+ return element("section", { dataCtPage: true, dataMs: readingMs(withoutSteps, PAGE_MS) }, [
81
+ ...content,
82
+ ...notes,
83
+ ]);
84
+ }
@@ -0,0 +1,11 @@
1
+ /** Reading time for autoplay: the blog's formula (`rehype-slides.ts`), unchanged. */
2
+ export interface Budget {
3
+ base: number;
4
+ perChar: number;
5
+ min: number;
6
+ max: number;
7
+ }
8
+ export declare const PAGE_MS: Budget;
9
+ export declare const STEP_MS: Budget;
10
+ /** Whole milliseconds to read `text`, whitespace not counted, rounded to 100. */
11
+ export declare function readingMs(text: string, budget: Budget): number;
@@ -0,0 +1,9 @@
1
+ /** Reading time for autoplay: the blog's formula (`rehype-slides.ts`), unchanged. */
2
+ export const PAGE_MS = { base: 3000, perChar: 100, min: 3000, max: 20000 };
3
+ export const STEP_MS = { base: 1500, perChar: 100, min: 1500, max: 12000 };
4
+ /** Whole milliseconds to read `text`, whitespace not counted, rounded to 100. */
5
+ export function readingMs(text, budget) {
6
+ const chars = text.replace(/\s+/g, "").length;
7
+ const ms = budget.base + chars * budget.perChar;
8
+ return Math.round(Math.min(budget.max, Math.max(budget.min, ms)) / 100) * 100;
9
+ }
@@ -0,0 +1,19 @@
1
+ import { type Element, type HastNode } from "./hast.js";
2
+ /** `#### 三、主要問題` style kicker: the first content element of the page is an `h4`. */
3
+ export declare function kickerOf(page: HastNode[]): Element | undefined;
4
+ /** A section divider: the page holds an `h1` and nothing else, such as `# 拆解`. */
5
+ export declare function dividerOf(page: HastNode[]): Element | undefined;
6
+ /** The first heading of `level` (1–6) in `nodes`, as plain text. */
7
+ export declare function firstHeading(nodes: HastNode[], level: number): string | undefined;
8
+ /**
9
+ * A slide's title: its first `h1`–`h3` heading (the kicker is an `h4`, so it never wins over the
10
+ * slide's own heading), else its first heading of any level, else a text snippet, else `fallback`.
11
+ */
12
+ export declare function slideTitle(pages: HastNode[][], fallback: string): string;
13
+ /**
14
+ * The locator label of a block title: `三、主要問題` gives `三`, `附錄 A|…` gives `A`.
15
+ * Undefined when the title has no such lead, or the lead is longer than 3 characters.
16
+ */
17
+ export declare function shortOf(title: string): string | undefined;
18
+ /** Under the `####` fallback rule, a block titled `附錄 …` is an appendix. */
19
+ export declare function kindOf(title: string): string | undefined;
@@ -0,0 +1,63 @@
1
+ /** Titles and short labels the plugin derives when the author gives none. */
2
+ import { NOTES } from "../shared/markers.js";
3
+ import { isElement, plainText, walkElements } from "./hast.js";
4
+ const SNIPPET_CHARS = 40;
5
+ /** The element children of a page, not counting its notes. */
6
+ function contentElements(page) {
7
+ return page.filter(isElement).filter(node => !(NOTES in node.properties));
8
+ }
9
+ /** `#### 三、主要問題` style kicker: the first content element of the page is an `h4`. */
10
+ export function kickerOf(page) {
11
+ const [first] = contentElements(page);
12
+ return first?.tagName === "h4" ? first : undefined;
13
+ }
14
+ /** A section divider: the page holds an `h1` and nothing else, such as `# 拆解`. */
15
+ export function dividerOf(page) {
16
+ const content = contentElements(page);
17
+ return content.length === 1 && content[0].tagName === "h1" ? content[0] : undefined;
18
+ }
19
+ /** The first heading of `level` (1–6) in `nodes`, as plain text. */
20
+ export function firstHeading(nodes, level) {
21
+ let found;
22
+ walkElements(nodes, node => {
23
+ if (found === undefined && node.tagName === `h${level}`)
24
+ found = plainText(node);
25
+ });
26
+ return found || undefined;
27
+ }
28
+ /**
29
+ * A slide's title: its first `h1`–`h3` heading (the kicker is an `h4`, so it never wins over the
30
+ * slide's own heading), else its first heading of any level, else a text snippet, else `fallback`.
31
+ */
32
+ export function slideTitle(pages, fallback) {
33
+ const headings = [];
34
+ for (const page of pages) {
35
+ walkElements(page, node => {
36
+ if (/^h[1-6]$/.test(node.tagName))
37
+ headings.push(node);
38
+ });
39
+ }
40
+ const preferred = headings.find(node => Number(node.tagName[1]) <= 3) ?? headings[0];
41
+ const heading = preferred && plainText(preferred);
42
+ if (heading)
43
+ return heading;
44
+ const text = plainText({ type: "root", children: pages.flat() });
45
+ if (!text)
46
+ return fallback;
47
+ const chars = [...text];
48
+ return chars.length > SNIPPET_CHARS ? `${chars.slice(0, SNIPPET_CHARS).join("")}…` : text;
49
+ }
50
+ const NUMERAL = /^([一二三四五六七八九十百零〇]+|\d+)\s*[、..]/;
51
+ const APPENDIX = /^附錄\s*(\S+?)\s*[||::、]/;
52
+ /**
53
+ * The locator label of a block title: `三、主要問題` gives `三`, `附錄 A|…` gives `A`.
54
+ * Undefined when the title has no such lead, or the lead is longer than 3 characters.
55
+ */
56
+ export function shortOf(title) {
57
+ const lead = NUMERAL.exec(title)?.[1] ?? APPENDIX.exec(title)?.[1];
58
+ return lead && [...lead].length <= 3 ? lead : undefined;
59
+ }
60
+ /** Under the `####` fallback rule, a block titled `附錄 …` is an appendix. */
61
+ export function kindOf(title) {
62
+ return title.startsWith("附錄") ? "appendix" : undefined;
63
+ }
@@ -0,0 +1,20 @@
1
+ interface MdNode {
2
+ type: string;
3
+ name?: string;
4
+ attributes?: Record<string, string | null | undefined> | null;
5
+ children?: MdNode[];
6
+ data?: {
7
+ hName?: string;
8
+ hProperties?: Record<string, unknown>;
9
+ };
10
+ }
11
+ export interface DeckFile {
12
+ data: object;
13
+ fail(reason: string, place?: unknown, origin?: string): never;
14
+ }
15
+ export interface RemarkDeckOptions {
16
+ /** Whether this file is a Contour deck. Default: every file. Pass the same test to `contour-deck/rehype`. */
17
+ match?: (file: DeckFile) => boolean;
18
+ }
19
+ export declare function remarkDeck(options?: RemarkDeckOptions): (tree: MdNode, file: DeckFile) => void;
20
+ export {};
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Turns the slide directives of the blog's Markdown into marker elements for
3
+ * `contour-deck/rehype`. The host registers `remark-directive` before this plugin.
4
+ *
5
+ * | Markdown | Becomes |
6
+ * | ------------------------------------------------ | ---------------------------------------- |
7
+ * | `:::summary` … `:::` | `<aside data-ct-notes>` |
8
+ * | `:::build` … `:::` | build marker (rehype marks the steps) |
9
+ * | `::vertical` | page break marker |
10
+ * | `::block{title="…" minutes=5 kind=appendix}` | block marker |
11
+ * | `::slide{#id}` | marker that gives the slide its id |
12
+ *
13
+ * A directive written in the wrong form (`::summary` for `:::summary`) is an error here,
14
+ * not an empty box, and so is an unknown attribute.
15
+ */
16
+ import { MARK, MARK_BLOCK, MARK_BUILD, MARK_SLIDE, MARK_VERTICAL, NOTES } from "../shared/markers.js";
17
+ const ORIGIN = "contour-deck:remark";
18
+ const DIRECTIVES = {
19
+ summary: {
20
+ form: "container",
21
+ attributes: [],
22
+ hast: () => ({ hName: "aside", hProperties: { [NOTES]: true } }),
23
+ },
24
+ build: {
25
+ form: "container",
26
+ attributes: [],
27
+ hast: () => ({ hName: "div", hProperties: { [MARK]: MARK_BUILD } }),
28
+ },
29
+ vertical: {
30
+ form: "leaf",
31
+ attributes: [],
32
+ hast: () => ({ hName: "hr", hProperties: { [MARK]: MARK_VERTICAL } }),
33
+ },
34
+ slide: {
35
+ form: "leaf",
36
+ attributes: ["id"],
37
+ hast(node, attributes, file) {
38
+ if (!attributes.id)
39
+ file.fail("::slide needs an id: ::slide{#dilemma}", node, ORIGIN);
40
+ return { hName: "div", hProperties: { [MARK]: MARK_SLIDE, id: attributes.id } };
41
+ },
42
+ },
43
+ block: {
44
+ form: "leaf",
45
+ attributes: ["title", "short", "minutes", "kind", "id"],
46
+ hast(node, attributes, file) {
47
+ const title = attributes.title?.trim();
48
+ if (!title)
49
+ file.fail('::block needs a title: ::block{title="三、主要問題"}', node, ORIGIN);
50
+ const properties = { [MARK]: MARK_BLOCK, dataTitle: title };
51
+ if (attributes.short !== undefined) {
52
+ const short = attributes.short.trim();
53
+ if (!short || [...short].length > 3) {
54
+ file.fail(`::block short must be 1 to 3 characters, got "${attributes.short}"`, node, ORIGIN);
55
+ }
56
+ properties.dataShort = short;
57
+ }
58
+ if (attributes.minutes !== undefined) {
59
+ const minutes = Number(attributes.minutes);
60
+ if (!/^\d+(\.\d+)?$/.test(attributes.minutes) || !(minutes > 0)) {
61
+ file.fail(`::block minutes must be a positive number, got "${attributes.minutes}"`, node, ORIGIN);
62
+ }
63
+ properties.dataMinutes = String(minutes);
64
+ }
65
+ if (attributes.kind !== undefined) {
66
+ if (!/^\S+$/.test(attributes.kind)) {
67
+ file.fail(`::block kind must be one token, got "${attributes.kind}"`, node, ORIGIN);
68
+ }
69
+ properties.dataKind = attributes.kind;
70
+ }
71
+ if (attributes.id)
72
+ properties.id = attributes.id;
73
+ return { hName: "div", hProperties: properties };
74
+ },
75
+ },
76
+ };
77
+ const FORM_OF_TYPE = {
78
+ containerDirective: "container",
79
+ leafDirective: "leaf",
80
+ textDirective: "text",
81
+ };
82
+ const EXAMPLE = {
83
+ container: name => `:::${name} … :::`,
84
+ leaf: name => `::${name}`,
85
+ };
86
+ function apply(node, file) {
87
+ const directive = node.name ? DIRECTIVES[node.name] : undefined;
88
+ if (!directive || !node.name)
89
+ return;
90
+ const form = FORM_OF_TYPE[node.type];
91
+ if (form !== directive.form) {
92
+ file.fail(`${node.name} is a ${directive.form} directive: write ${EXAMPLE[directive.form](node.name)}`, node, ORIGIN);
93
+ }
94
+ const attributes = {};
95
+ for (const [key, value] of Object.entries(node.attributes ?? {})) {
96
+ if (!directive.attributes.includes(key)) {
97
+ const known = directive.attributes.length ? directive.attributes.join(", ") : "none";
98
+ file.fail(`${node.name} has no attribute "${key}" (known: ${known})`, node, ORIGIN);
99
+ }
100
+ attributes[key] = value ?? "";
101
+ }
102
+ node.data = { ...node.data, ...directive.hast(node, attributes, file) };
103
+ if (directive.form === "leaf")
104
+ node.children = [];
105
+ }
106
+ function visit(node, file) {
107
+ apply(node, file);
108
+ for (const child of node.children ?? [])
109
+ visit(child, file);
110
+ }
111
+ export function remarkDeck(options = {}) {
112
+ return (tree, file) => {
113
+ if (options.match && !options.match(file))
114
+ return;
115
+ visit(tree, file);
116
+ };
117
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The hand-off between `contour-deck/remark` and `contour-deck/rehype`.
3
+ *
4
+ * The remark plugin writes these marker elements; the rehype plugin consumes them and
5
+ * removes every one, so none appears in the final HTML except `data-ct-notes`, which is
6
+ * already the contract's own attribute.
7
+ */
8
+ /** hast property that names a marker (`data-ct-mark` in HTML). */
9
+ export declare const MARK = "dataCtMark";
10
+ /** hast property of a notes element (`data-ct-notes` in HTML). */
11
+ export declare const NOTES = "dataCtNotes";
12
+ export declare const MARK_BLOCK = "block";
13
+ export declare const MARK_SLIDE = "slide";
14
+ export declare const MARK_VERTICAL = "vertical";
15
+ export declare const MARK_BUILD = "build";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The hand-off between `contour-deck/remark` and `contour-deck/rehype`.
3
+ *
4
+ * The remark plugin writes these marker elements; the rehype plugin consumes them and
5
+ * removes every one, so none appears in the final HTML except `data-ct-notes`, which is
6
+ * already the contract's own attribute.
7
+ */
8
+ /** hast property that names a marker (`data-ct-mark` in HTML). */
9
+ export const MARK = "dataCtMark";
10
+ /** hast property of a notes element (`data-ct-notes` in HTML). */
11
+ export const NOTES = "dataCtNotes";
12
+ export const MARK_BLOCK = "block";
13
+ export const MARK_SLIDE = "slide";
14
+ export const MARK_VERTICAL = "vertical";
15
+ export const MARK_BUILD = "build";