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 +9 -4
- package/dist/rehype/draft.d.ts +39 -0
- package/dist/rehype/draft.js +36 -0
- package/dist/rehype/headings.d.ts +9 -0
- package/dist/rehype/headings.js +127 -0
- package/dist/rehype/index.d.ts +4 -4
- package/dist/rehype/index.js +26 -36
- package/dist/remark/index.d.ts +4 -5
- package/dist/remark/index.js +26 -11
- package/dist/shared/structure.d.ts +23 -0
- package/dist/shared/structure.js +22 -0
- package/package.json +1 -1
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.
|
|
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
|
-
//
|
|
64
|
-
//
|
|
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
|
+
}
|
package/dist/rehype/index.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/rehype/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
38
|
-
function blockSpecOf(marker) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
title:
|
|
42
|
-
|
|
43
|
-
|
|
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,
|
|
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
|
-
|
|
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(
|
|
163
|
-
const taken = ids(
|
|
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
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
};
|
package/dist/remark/index.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/remark/index.js
CHANGED
|
@@ -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
|
|
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 (
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|