contour-deck 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -13,7 +13,7 @@ Outline-first slide decks on real DOM.
13
13
  A deck is blocks, slides, and pages: it opens on an outline of every block, a block opens into a left-right track of slides, and each slide scrolls down one page at a time.
14
14
  The core is plain TypeScript with no runtime dependency (7 KB of JavaScript and 3 KB of CSS, gzipped); the phone remote is a separate module that loads on first use.
15
15
 
16
- Status: 0.1.0 on npm (M1). The M2 client is on `main`, not yet released: phone remote, laser pointer, drawing, tilt pointing, and a presenter window ([Remote](#remote)); the relay it talks to is a separate small service ([docs/PROTOCOL.md](docs/PROTOCOL.md)). Live demo: https://wayne930242.github.io/contour-deck/
16
+ Status: 0.3.0 on npm. It holds the deck (M1), the M2 client (phone remote, laser pointer, drawing, tilt pointing, and a presenter window, see [Remote](#remote); the relay it talks to is a separate small service, [docs/PROTOCOL.md](docs/PROTOCOL.md)), and, new in 0.3.0, an opt-in structure for the Markdown plugins that reads blocks, slides, and pages from headings ([docs/PLUGINS.md](docs/PLUGINS.md#headings-mode)). Live demo: https://wayne930242.github.io/contour-deck/
17
17
  The design is in [docs/DESIGN.md](docs/DESIGN.md) (Traditional Chinese); the HTML the core reads is in [docs/CONTRACT.md](docs/CONTRACT.md).
18
18
 
19
19
  ## 看看它怎麼動
@@ -60,10 +60,15 @@ import { remarkDeck } from "contour-deck/remark";
60
60
  import { rehypeDeck } from "contour-deck/rehype";
61
61
 
62
62
  const isDeck = file => file.data.astro?.frontmatter?.type === "slide";
63
- // remarkPlugins: [remarkDirective, [remarkDeck, { match: isDeck }], …]
64
- // rehypePlugins: […, [rehypeDeck, { match: isDeck }]]
63
+ // Optional. A deck with `slideOptions: { structure: headings }` in its frontmatter is read by its headings:
64
+ // `---` starts a block, `##` a slide, `###` a page. Every other deck keeps the default rules.
65
+ const structure = file => file.data.astro?.frontmatter?.slideOptions?.structure;
66
+ // remarkPlugins: [remarkDirective, [remarkDeck, { match: isDeck, structure }], …]
67
+ // rehypePlugins: […, [rehypeDeck, { match: isDeck, structure }]]
65
68
  ```
66
69
 
70
+ By default a `---` starts a slide and `::vertical` a page; [docs/PLUGINS.md](docs/PLUGINS.md) has both sets of rules.
71
+
67
72
  ```astro
68
73
  ---
69
74
  import Deck from "contour-deck/astro";
@@ -228,7 +233,7 @@ The core removes both parameters in `destroy()` and leaves the others alone.
228
233
  src/core/ the core: model, stops, gestures, navigator, outline, rail, notes, transitions, deck
229
234
  src/remote/ contour-deck/remote: protocol, transports, pairing, clock, tilt math, QR, and the three views
230
235
  src/remark/ Markdown directives (block, vertical, summary, build)
231
- src/rehype/ builds the deck > block > slide > page HTML
236
+ src/rehype/ builds the deck > block > slide > page HTML; headings.ts is the reader of `structure: "headings"`
232
237
  src/astro/ Deck.astro and the host script it runs (also contour-deck/host)
233
238
  styles/ index.css is the entry of contour-deck/style.css
234
239
  demo/ a plain-HTML deck that follows the contract, and media.html (clips and stills)
@@ -0,0 +1,39 @@
1
+ /** What the two readers (`---` slides in index.ts, headings.ts) share and hand to the assembler. */
2
+ import { type Element, type HastNode } from "./hast.js";
3
+ export declare const ORIGIN = "contour-deck:rehype";
4
+ /** A `::block` as written: under `structure: "headings"` it may come without a title. */
5
+ export interface BlockMark {
6
+ title?: string;
7
+ short?: string;
8
+ minutes?: string;
9
+ kind?: string;
10
+ id?: string;
11
+ }
12
+ export interface BlockSpec extends BlockMark {
13
+ title: string;
14
+ }
15
+ export interface SlideDraft {
16
+ pages: HastNode[][];
17
+ id?: string;
18
+ block?: BlockSpec;
19
+ /** Text of the first `h2` of the slide's first page: the key of the slide/page rule. */
20
+ h2?: string;
21
+ /** Old reveal.js indexes of the slide's first page: `?slide=h.v`. The headings reader has none. */
22
+ legacy?: number;
23
+ legacyV?: number;
24
+ }
25
+ export interface Group {
26
+ spec: BlockSpec;
27
+ slides: SlideDraft[];
28
+ }
29
+ /** Splits at breaks matching `isSplit`, dropping the breaks and the groups `keep` rejects. */
30
+ export declare function splitAt(nodes: HastNode[], isSplit: (node: HastNode) => boolean, keep: (group: HastNode[]) => boolean): HastNode[][];
31
+ /** A `---` of the author's, not the `<hr>` of a `::vertical` marker. */
32
+ export declare const isRule: (node: HastNode) => boolean;
33
+ export declare const hasContent: (group: HastNode[]) => boolean;
34
+ export declare function blockMarkOf(marker: Element): BlockMark;
35
+ /**
36
+ * The title of a block that has none of its own: the deck title, else the deck's first `h1`,
37
+ * else the title of the block's first slide.
38
+ */
39
+ export declare function leadTitle(deckTitle: string | undefined, drafts: SlideDraft[], first: SlideDraft): string;
@@ -0,0 +1,36 @@
1
+ /** What the two readers (`---` slides in index.ts, headings.ts) share and hand to the assembler. */
2
+ import { isBlank, isElement, markOf } from "./hast.js";
3
+ import { firstHeading, slideTitle } from "./titles.js";
4
+ export const ORIGIN = "contour-deck:rehype";
5
+ /** Splits at breaks matching `isSplit`, dropping the breaks and the groups `keep` rejects. */
6
+ export function splitAt(nodes, isSplit, keep) {
7
+ const groups = [[]];
8
+ for (const node of nodes) {
9
+ if (isSplit(node))
10
+ groups.push([]);
11
+ else
12
+ groups[groups.length - 1].push(node);
13
+ }
14
+ return groups.filter(keep);
15
+ }
16
+ /** A `---` of the author's, not the `<hr>` of a `::vertical` marker. */
17
+ export const isRule = (node) => isElement(node) && node.tagName === "hr" && markOf(node) === undefined;
18
+ export const hasContent = (group) => group.some(node => !isBlank(node));
19
+ export function blockMarkOf(marker) {
20
+ const text = (value) => (value === undefined ? undefined : String(value));
21
+ const { dataTitle, dataShort, dataMinutes, dataKind, id } = marker.properties;
22
+ return {
23
+ title: text(dataTitle),
24
+ short: text(dataShort),
25
+ minutes: text(dataMinutes),
26
+ kind: text(dataKind),
27
+ id: typeof id === "string" ? id : undefined,
28
+ };
29
+ }
30
+ /**
31
+ * The title of a block that has none of its own: the deck title, else the deck's first `h1`,
32
+ * else the title of the block's first slide.
33
+ */
34
+ export function leadTitle(deckTitle, drafts, first) {
35
+ return deckTitle || firstHeading(drafts.flatMap(item => item.pages.flat()), 1) || slideTitle(first.pages, "Slides");
36
+ }
@@ -0,0 +1,9 @@
1
+ import { type DeckFile } from "../shared/structure.js";
2
+ import { type Group } from "./draft.js";
3
+ import { type HastNode } from "./hast.js";
4
+ /**
5
+ * Reads the blocks out of the flow. A block with a title of its own (`::block` title, else its
6
+ * heading) derives its label and the 附錄 kind from it unless `::block` gives them; a block
7
+ * without one takes the deck's title and derives nothing.
8
+ */
9
+ export declare function readHeadings(flow: HastNode[], deckTitle: string | undefined, file: DeckFile): Group[];
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The reader of `structure: "headings"`: the deck's outline is the Markdown's own.
3
+ *
4
+ * - A top-level `---` starts a block.
5
+ * - A top-level `##` starts a slide. What stands before a block's first `##` is the block's first
6
+ * slide (a title, a `# 拆解` divider), unless it is nothing but a `####` kicker, notes, and
7
+ * directives: those open the first `##` slide.
8
+ * - A top-level `###` starts a further page of the slide, unless it stands right under the `##`
9
+ * with no content between them: then both head the first page.
10
+ * - A block is titled by the first `#` or `####` before its first `##` or `###`; the heading stays
11
+ * where it is. `::block{…}` anywhere at the block's top level overrides what it names.
12
+ * - Headings inside other elements, `####` and deeper, and a `#` after the first `##` are content.
13
+ */
14
+ import { MARK_BLOCK, MARK_SLIDE, MARK_VERTICAL, NOTES } from "../shared/markers.js";
15
+ import { NO_VERTICAL } from "../shared/structure.js";
16
+ import { blockMarkOf, isRule, leadTitle, ORIGIN, splitAt } from "./draft.js";
17
+ import { isBlank, isElement, markOf, plainText } from "./hast.js";
18
+ import { kindOf, shortOf } from "./titles.js";
19
+ function readBlock(nodes, file) {
20
+ let slide = { pages: [[]] };
21
+ const slides = [slide];
22
+ const idMarkers = new Map();
23
+ let bare = "opening";
24
+ let kicker = false;
25
+ /** No `##` or `###` yet: the part of the block its title heading stands in. */
26
+ let opening = true;
27
+ let heading;
28
+ let blockMarker;
29
+ /** The slide holds more than headings. */
30
+ let body = false;
31
+ /** A `::slide` written after the slide's body, with nothing after it yet. */
32
+ let trailing;
33
+ for (const node of nodes) {
34
+ if (!isElement(node)) {
35
+ if (!isBlank(node)) {
36
+ slide.pages[slide.pages.length - 1].push(node);
37
+ bare = trailing = undefined;
38
+ body = true;
39
+ }
40
+ continue;
41
+ }
42
+ const mark = markOf(node);
43
+ if (mark === MARK_VERTICAL)
44
+ file.fail(NO_VERTICAL, node, ORIGIN);
45
+ if (mark === MARK_BLOCK) {
46
+ if (blockMarker)
47
+ file.fail("a block holds two ::block directives", node, ORIGIN);
48
+ blockMarker = node;
49
+ continue;
50
+ }
51
+ if (mark === MARK_SLIDE) {
52
+ if (idMarkers.has(slide))
53
+ file.fail("a slide holds two ::slide directives", node, ORIGIN);
54
+ idMarkers.set(slide, node);
55
+ slide.id = String(node.properties.id);
56
+ trailing = body ? node : undefined;
57
+ continue;
58
+ }
59
+ if (node.tagName === "h2") {
60
+ if (bare === "opening") {
61
+ slide.pages[0].push(node);
62
+ }
63
+ else {
64
+ if (trailing) {
65
+ file.fail(`::slide{#${slide.id}} stands between a slide's content and the next ##: write it under the heading of its own slide`, trailing, ORIGIN);
66
+ }
67
+ slide = { pages: [[node]] };
68
+ slides.push(slide);
69
+ body = false;
70
+ }
71
+ bare = "title";
72
+ opening = false;
73
+ }
74
+ else if (node.tagName === "h3") {
75
+ if (bare)
76
+ slide.pages[slide.pages.length - 1].push(node);
77
+ else
78
+ slide.pages.push([node]);
79
+ bare = undefined;
80
+ opening = false;
81
+ }
82
+ else {
83
+ slide.pages[slide.pages.length - 1].push(node);
84
+ if (NOTES in node.properties)
85
+ continue;
86
+ if (opening && (node.tagName === "h1" || node.tagName === "h4"))
87
+ heading ??= plainText(node) || undefined;
88
+ if (bare === "opening" && node.tagName === "h4" && !kicker)
89
+ kicker = true;
90
+ else
91
+ bare = undefined;
92
+ body ||= !/^h[1-6]$/.test(node.tagName);
93
+ }
94
+ trailing = undefined;
95
+ }
96
+ // Only the opening can be left empty: every other slide starts with its `##`.
97
+ if (!slides[0].pages[0].length) {
98
+ const marker = idMarkers.get(slides[0]);
99
+ if (marker)
100
+ file.fail("::slide without any content in its slide", marker, ORIGIN);
101
+ slides.shift();
102
+ }
103
+ if (!slides.length) {
104
+ if (blockMarker)
105
+ file.fail("::block stands in a block with no slide", blockMarker, ORIGIN);
106
+ return undefined;
107
+ }
108
+ return { mark: blockMarker && blockMarkOf(blockMarker), heading, slides };
109
+ }
110
+ /**
111
+ * Reads the blocks out of the flow. A block with a title of its own (`::block` title, else its
112
+ * heading) derives its label and the 附錄 kind from it unless `::block` gives them; a block
113
+ * without one takes the deck's title and derives nothing.
114
+ */
115
+ export function readHeadings(flow, deckTitle, file) {
116
+ const blocks = splitAt(flow, isRule, () => true)
117
+ .map(nodes => readBlock(nodes, file))
118
+ .filter(block => block !== undefined);
119
+ const drafts = blocks.flatMap(block => block.slides);
120
+ return blocks.map(({ mark = {}, heading, slides }) => {
121
+ const own = mark.title ?? heading;
122
+ const spec = own
123
+ ? { ...mark, title: own, short: mark.short ?? shortOf(own), kind: mark.kind ?? kindOf(own) }
124
+ : { ...mark, title: leadTitle(deckTitle, drafts, slides[0]) };
125
+ return { spec, slides };
126
+ });
127
+ }
@@ -1,12 +1,12 @@
1
+ import { type DeckFile, type Structure, type StructureOption } from "../shared/structure.js";
1
2
  import { type HastNode } from "./hast.js";
2
3
  export interface DeckOptions {
3
4
  /** Deck title for `data-title`. Default: the `title` frontmatter field under Astro, else none. */
4
5
  title?: string;
5
6
  /** Whether to build a deck for this file. Default: every file. */
6
7
  match?: (file: DeckFile) => boolean;
8
+ /** `"slides"` (default) or `"headings"`, or a function of the file. Pass the same one to `contour-deck/remark`. */
9
+ structure?: StructureOption;
7
10
  }
8
- export interface DeckFile {
9
- data: object;
10
- fail(reason: string, place?: unknown, origin?: string): never;
11
- }
11
+ export type { DeckFile, Structure, StructureOption };
12
12
  export declare function rehypeDeck(options?: DeckOptions): (tree: HastNode, file: DeckFile) => void;
@@ -11,39 +11,29 @@
11
11
  * starting with 附錄 gets `data-kind="appendix"`. Without any of these the deck is one block.
12
12
  * - `<section>` inside page content is left alone: the contract reads data attributes only.
13
13
  *
14
+ * These are the rules of `structure: "slides"`, the default. With `structure: "headings"` the
15
+ * slides come from headings.ts instead: `---` starts a block, `##` a slide, `###` a page.
16
+ *
14
17
  * Run it after `contour-deck/remark`, which writes the markers this plugin reads.
15
18
  */
16
19
  import { MARK_BLOCK, MARK_SLIDE, MARK_VERTICAL } from "../shared/markers.js";
17
- import { element, isBlank, isElement, markOf, plainText, walkElements, } from "./hast.js";
20
+ import { structureOf } from "../shared/structure.js";
21
+ import { blockMarkOf, hasContent, isRule, leadTitle, ORIGIN, splitAt, } from "./draft.js";
22
+ import { element, isElement, markOf, plainText, walkElements } from "./hast.js";
23
+ import { readHeadings } from "./headings.js";
18
24
  import { buildPage } from "./page.js";
19
25
  import { dividerOf, firstHeading, kickerOf, kindOf, shortOf, slideTitle } from "./titles.js";
20
- const ORIGIN = "contour-deck:rehype";
21
26
  function isFootnotes(node) {
22
27
  return isElement(node) && node.tagName === "section" && node.properties.dataFootnotes !== undefined;
23
28
  }
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
29
  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
- };
30
+ /** A `::block` of this mode always has a title: `contour-deck/remark` lets one through without only in headings mode. */
31
+ function blockSpecOf(marker, file) {
32
+ const mark = blockMarkOf(marker);
33
+ if (mark.title === undefined) {
34
+ file.fail('::block has no title: remarkDeck ran with structure "headings" and rehypeDeck with "slides"; give both the same structure option', marker, ORIGIN);
35
+ }
36
+ return { ...mark, title: mark.title };
47
37
  }
48
38
  /**
49
39
  * Reads the slides out of the flow. A `---` always starts a slide. Inside a `---` group each
@@ -55,7 +45,7 @@ function readSlides(flow, file) {
55
45
  const drafts = [];
56
46
  let pending;
57
47
  let legacy = -1;
58
- for (const group of splitAt(flow, isSlideBreak, group => group.length > 0)) {
48
+ for (const group of splitAt(flow, isRule, group => group.length > 0)) {
59
49
  let current;
60
50
  let vertical = 0;
61
51
  for (const chunk of splitAt(group, isPageBreak, chunk => chunk.length > 0)) {
@@ -66,7 +56,7 @@ function readSlides(flow, file) {
66
56
  file.fail("a page holds two ::block directives", blocks[1], ORIGIN);
67
57
  if (slides.length > 1)
68
58
  file.fail("a page holds two ::slide directives", slides[1], ORIGIN);
69
- const spec = blocks[0] && blockSpecOf(blocks[0]);
59
+ const spec = blocks[0] && blockSpecOf(blocks[0], file);
70
60
  const twice = () => file.fail(`::block "${spec?.title}" follows ::block "${pending?.title}" with no slide between`, blocks[0], ORIGIN);
71
61
  if (!hasContent(rest)) {
72
62
  if (slides.length)
@@ -144,8 +134,7 @@ function groupBlocks(drafts, deckTitle) {
144
134
  spec = { ...spec, short: spec.short ?? shortOf(spec.title) };
145
135
  }
146
136
  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: [] });
137
+ groups.push({ spec: spec ?? { title: leadTitle(deckTitle, drafts, draft) }, slides: [] });
149
138
  }
150
139
  groups[groups.length - 1].slides.push(draft);
151
140
  }
@@ -159,15 +148,15 @@ function ids(nodes) {
159
148
  });
160
149
  return found;
161
150
  }
162
- function assemble(drafts, deckTitle, file) {
163
- const taken = ids(drafts.flatMap(draft => draft.pages.flat()));
151
+ function assemble(groups, file) {
152
+ const taken = ids(groups.flatMap(group => group.slides).flatMap(draft => draft.pages.flat()));
164
153
  const claim = (id, what) => {
165
154
  if (taken.has(id))
166
155
  file.fail(`${what} id "${id}" is already used in the document`, undefined, ORIGIN);
167
156
  taken.add(id);
168
157
  return id;
169
158
  };
170
- return groupBlocks(drafts, deckTitle).map(({ spec, slides }, blockIndex) => {
159
+ return groups.map(({ spec, slides }, blockIndex) => {
171
160
  const properties = { dataCtBlock: true, dataTitle: spec.title };
172
161
  if (spec.short)
173
162
  properties.dataShort = spec.short;
@@ -184,8 +173,7 @@ function assemble(drafts, deckTitle, file) {
184
173
  dataCtSlide: true,
185
174
  id,
186
175
  dataTitle: slideTitle(draft.pages, `Slide ${ordinal.replace("-", ".")}`),
187
- dataLegacy: draft.legacy,
188
- dataLegacyV: draft.legacyV,
176
+ ...(draft.legacy === undefined ? {} : { dataLegacy: draft.legacy, dataLegacyV: draft.legacyV }),
189
177
  }, draft.pages.map(buildPage));
190
178
  });
191
179
  return element("section", properties, sections);
@@ -212,9 +200,11 @@ export function rehypeDeck(options = {}) {
212
200
  else
213
201
  flow.push(node);
214
202
  }
215
- const drafts = readSlides(flow, file);
216
- rejectNestedMarkers(drafts, file);
217
- const blocks = assemble(drafts, deckTitle, file);
203
+ const groups = structureOf(options.structure, file, ORIGIN) === "headings"
204
+ ? readHeadings(flow, deckTitle, file)
205
+ : groupBlocks(readSlides(flow, file), deckTitle);
206
+ rejectNestedMarkers(groups.flatMap(group => group.slides), file);
207
+ const blocks = assemble(groups, file);
218
208
  const deck = element("div", { dataCtDeck: true, ...(deckTitle ? { dataTitle: deckTitle } : {}) }, blocks);
219
209
  tree.children = [...before, deck, ...after];
220
210
  };
@@ -1,3 +1,5 @@
1
+ import { type DeckFile, type Structure, type StructureOption } from "../shared/structure.js";
2
+ export type { DeckFile, Structure, StructureOption };
1
3
  interface MdNode {
2
4
  type: string;
3
5
  name?: string;
@@ -8,13 +10,10 @@ interface MdNode {
8
10
  hProperties?: Record<string, unknown>;
9
11
  };
10
12
  }
11
- export interface DeckFile {
12
- data: object;
13
- fail(reason: string, place?: unknown, origin?: string): never;
14
- }
15
13
  export interface RemarkDeckOptions {
16
14
  /** Whether this file is a Contour deck. Default: every file. Pass the same test to `contour-deck/rehype`. */
17
15
  match?: (file: DeckFile) => boolean;
16
+ /** `"slides"` (default) or `"headings"`, or a function of the file. Pass the same one to `contour-deck/rehype`. */
17
+ structure?: StructureOption;
18
18
  }
19
19
  export declare function remarkDeck(options?: RemarkDeckOptions): (tree: MdNode, file: DeckFile) => void;
20
- export {};
@@ -12,8 +12,12 @@
12
12
  *
13
13
  * A directive written in the wrong form (`::summary` for `:::summary`) is an error here,
14
14
  * not an empty box, and so is an unknown attribute.
15
+ *
16
+ * With `structure: "headings"` the pages come from `###` headings, so `::vertical` is an error,
17
+ * and `::block` needs no `title`: the block has its heading.
15
18
  */
16
19
  import { MARK, MARK_BLOCK, MARK_BUILD, MARK_SLIDE, MARK_VERTICAL, NOTES } from "../shared/markers.js";
20
+ import { NO_VERTICAL, structureOf } from "../shared/structure.js";
17
21
  const ORIGIN = "contour-deck:remark";
18
22
  const DIRECTIVES = {
19
23
  summary: {
@@ -29,7 +33,11 @@ const DIRECTIVES = {
29
33
  vertical: {
30
34
  form: "leaf",
31
35
  attributes: [],
32
- hast: () => ({ hName: "hr", hProperties: { [MARK]: MARK_VERTICAL } }),
36
+ hast(node, _attributes, file, structure) {
37
+ if (structure === "headings")
38
+ file.fail(NO_VERTICAL, node, ORIGIN);
39
+ return { hName: "hr", hProperties: { [MARK]: MARK_VERTICAL } };
40
+ },
33
41
  },
34
42
  slide: {
35
43
  form: "leaf",
@@ -43,11 +51,18 @@ const DIRECTIVES = {
43
51
  block: {
44
52
  form: "leaf",
45
53
  attributes: ["title", "short", "minutes", "kind", "id"],
46
- hast(node, attributes, file) {
54
+ hast(node, attributes, file, structure) {
47
55
  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 };
56
+ if (structure === "slides") {
57
+ if (!title)
58
+ file.fail('::block needs a title: ::block{title="三、主要問題"}', node, ORIGIN);
59
+ }
60
+ else if (attributes.title !== undefined && !title) {
61
+ file.fail("::block title is empty: leave it out to title the block by its heading", node, ORIGIN);
62
+ }
63
+ const properties = { [MARK]: MARK_BLOCK };
64
+ if (title)
65
+ properties.dataTitle = title;
51
66
  if (attributes.short !== undefined) {
52
67
  const short = attributes.short.trim();
53
68
  if (!short || [...short].length > 3) {
@@ -83,7 +98,7 @@ const EXAMPLE = {
83
98
  container: name => `:::${name} … :::`,
84
99
  leaf: name => `::${name}`,
85
100
  };
86
- function apply(node, file) {
101
+ function apply(node, file, structure) {
87
102
  const directive = node.name ? DIRECTIVES[node.name] : undefined;
88
103
  if (!directive || !node.name)
89
104
  return;
@@ -99,19 +114,19 @@ function apply(node, file) {
99
114
  }
100
115
  attributes[key] = value ?? "";
101
116
  }
102
- node.data = { ...node.data, ...directive.hast(node, attributes, file) };
117
+ node.data = { ...node.data, ...directive.hast(node, attributes, file, structure) };
103
118
  if (directive.form === "leaf")
104
119
  node.children = [];
105
120
  }
106
- function visit(node, file) {
107
- apply(node, file);
121
+ function visit(node, file, structure) {
122
+ apply(node, file, structure);
108
123
  for (const child of node.children ?? [])
109
- visit(child, file);
124
+ visit(child, file, structure);
110
125
  }
111
126
  export function remarkDeck(options = {}) {
112
127
  return (tree, file) => {
113
128
  if (options.match && !options.match(file))
114
129
  return;
115
- visit(tree, file);
130
+ visit(tree, file, structureOf(options.structure, file, ORIGIN));
116
131
  };
117
132
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How a deck's Markdown maps to blocks, slides, and pages. `contour-deck/remark` and
3
+ * `contour-deck/rehype` take the same `structure` option and resolve it here, per file.
4
+ *
5
+ * - `"slides"` (default): `---` starts a slide, `::vertical` a page, and blocks come from `::block`,
6
+ * `####` kickers, or `#` dividers.
7
+ * - `"headings"`: `---` starts a block, `##` a slide, `###` a page.
8
+ */
9
+ /** The file both plugins receive: a `VFile`, as far as they use it. */
10
+ export interface DeckFile {
11
+ data: object;
12
+ fail(reason: string, place?: unknown, origin?: string): never;
13
+ }
14
+ export type Structure = "slides" | "headings";
15
+ /** A structure for every file, or a function that picks one per file (nothing means `"slides"`). */
16
+ export type StructureOption = Structure | ((file: DeckFile) => Structure | null | undefined);
17
+ /** The one message for `::vertical` under `"headings"`, whichever plugin meets it. */
18
+ export declare const NO_VERTICAL = "::vertical has no meaning with structure: \"headings\": start the next page with a ### heading";
19
+ /**
20
+ * The structure of `file`. The value often comes from frontmatter, so anything other than the two
21
+ * names is an error here, not a silent fallback to the default.
22
+ */
23
+ export declare function structureOf(option: StructureOption | undefined, file: DeckFile, origin: string): Structure;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * How a deck's Markdown maps to blocks, slides, and pages. `contour-deck/remark` and
3
+ * `contour-deck/rehype` take the same `structure` option and resolve it here, per file.
4
+ *
5
+ * - `"slides"` (default): `---` starts a slide, `::vertical` a page, and blocks come from `::block`,
6
+ * `####` kickers, or `#` dividers.
7
+ * - `"headings"`: `---` starts a block, `##` a slide, `###` a page.
8
+ */
9
+ /** The one message for `::vertical` under `"headings"`, whichever plugin meets it. */
10
+ export const NO_VERTICAL = '::vertical has no meaning with structure: "headings": start the next page with a ### heading';
11
+ /**
12
+ * The structure of `file`. The value often comes from frontmatter, so anything other than the two
13
+ * names is an error here, not a silent fallback to the default.
14
+ */
15
+ export function structureOf(option, file, origin) {
16
+ const value = typeof option === "function" ? option(file) : option;
17
+ if (value === undefined || value === null)
18
+ return "slides";
19
+ if (value === "slides" || value === "headings")
20
+ return value;
21
+ return file.fail(`structure must be "slides" or "headings", got ${JSON.stringify(value)}`, undefined, origin);
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "contour-deck",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Outline-first slide decks on real DOM: blocks, slides, pages.",
5
5
  "keywords": [
6
6
  "slides",