slidev-addon-counter 0.1.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 inaku
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # slidev-addon-counter
2
+
3
+ LaTeX-like multi-level counters for Slidev.
4
+
5
+ ## Usage
6
+
7
+ Install the addon:
8
+
9
+ ```bash
10
+ pnpm add -D slidev-addon-counter
11
+ ```
12
+
13
+ For prerelease versions, install the matching dist-tag:
14
+
15
+ ```bash
16
+ pnpm add -D slidev-addon-counter@alpha
17
+ ```
18
+
19
+ Enable the addon in your Slidev deck:
20
+
21
+ ```md
22
+ ---
23
+ addons:
24
+ - slidev-addon-counter
25
+ ---
26
+ ```
27
+
28
+ Define counters in `slidev-addon-counter.config.ts` next to your deck entry:
29
+
30
+ ```ts
31
+ import { defineCounterConfig } from "slidev-addon-counter/config";
32
+
33
+ export default defineCounterConfig({
34
+ counters: {
35
+ section: {
36
+ levels: [
37
+ {
38
+ level: 1,
39
+ alias: "chapter",
40
+ style: "decimal",
41
+ format: "第 %{:value} 章",
42
+ },
43
+ {
44
+ level: 2,
45
+ alias: "section",
46
+ style: "decimal",
47
+ format: "%{@-1:full}.%{:value}",
48
+ },
49
+ ],
50
+ },
51
+ },
52
+ });
53
+ ```
54
+
55
+ Use counters in slides:
56
+
57
+ ```md
58
+ <Counter id="section" level="chapter" />
59
+
60
+ <Counter id="section" level="section" />
61
+
62
+ <CounterIncrement id="theorem" level="theorem" />
63
+ <CounterDisplay id="theorem" level="theorem" />
64
+ ```
65
+
66
+ `action="step"` increments and displays, `action="increment"` only increments,
67
+ and `action="display"` only displays the current value.
68
+
69
+ `<Counter>` renders plain text, not an HTML wrapper element. To style a counter,
70
+ wrap it yourself:
71
+
72
+ ```md
73
+ <span class="text-red-500">
74
+ <Counter id="section" level="section" />
75
+ </span>
76
+ ```
77
+
78
+ Formats use `%{ref:kind}` placeholders. The `ref` may be empty for the
79
+ current level, so `%{:value}` is equivalent to `%{@0:value}`. Use relative
80
+ refs such as `%{@-1:full}` for parent levels, numeric refs such as
81
+ `%{2:value}`, and aliases such as `%{chapter:raw}`.
82
+
83
+ The first version supports `value`, `raw`, and `full`. A `full` placeholder may
84
+ only reference a shallower level.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ source ~/.nvm/nvm.sh
90
+ nvm use
91
+ pnpm install
92
+ pnpm check
93
+ ```
94
+
95
+ Preview the local addon:
96
+
97
+ ```bash
98
+ pnpm dev
99
+ ```
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+
4
+ import { snapshots } from "virtual:slidev-addon-counter/snapshots";
5
+
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ action?: "step" | "increment" | "display";
9
+ id?: string;
10
+ level: number | string;
11
+ op?: string;
12
+ }>(),
13
+ {
14
+ action: "step",
15
+ id: "default",
16
+ op: "",
17
+ },
18
+ );
19
+
20
+ const displayText = computed(() => {
21
+ if (props.action === "increment") {
22
+ return "";
23
+ }
24
+
25
+ return props.op ? (snapshots[props.op]?.display ?? "") : "";
26
+ });
27
+ </script>
28
+
29
+ <template>{{ displayText }}</template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ id?: string;
4
+ level: number | string;
5
+ op?: string;
6
+ }>();
7
+ </script>
8
+
9
+ <template>
10
+ <Counter :id="id" :level="level" :op="op" action="display" />
11
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ id?: string;
4
+ level: number | string;
5
+ op?: string;
6
+ }>();
7
+ </script>
8
+
9
+ <template>
10
+ <Counter :id="id" :level="level" :op="op" action="increment" />
11
+ </template>
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ id?: string;
4
+ level: number | string;
5
+ op?: string;
6
+ }>();
7
+ </script>
8
+
9
+ <template>
10
+ <Counter :id="id" :level="level" :op="op" action="step" />
11
+ </template>
package/config.ts ADDED
@@ -0,0 +1,9 @@
1
+ export type {
2
+ CounterConfig,
3
+ CounterDefinition,
4
+ CounterLevelConfig,
5
+ CounterReset,
6
+ CounterStyle,
7
+ } from "./src/counter";
8
+
9
+ export { defineCounterConfig } from "./src/counter";
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "slidev-addon-counter",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "LaTeX-like multi-level counters for Slidev.",
5
+ "type": "module",
6
+ "private": false,
7
+ "license": "MIT",
8
+ "packageManager": "pnpm@11.5.3",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "registry": "https://registry.npmjs.org/"
12
+ },
13
+ "keywords": [
14
+ "slidev-addon",
15
+ "slidev",
16
+ "counter",
17
+ "toc"
18
+ ],
19
+ "homepage": "https://github.com/inaku-Gyan/slidev-addon-counter#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/inaku-Gyan/slidev-addon-counter/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/inaku-Gyan/slidev-addon-counter.git"
26
+ },
27
+ "engines": {
28
+ "node": ">=24.18.0",
29
+ "slidev": ">=52.0.0"
30
+ },
31
+ "files": [
32
+ "components",
33
+ "config.ts",
34
+ "src/counter.ts",
35
+ "setup",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "exports": {
40
+ "./config": "./config.ts",
41
+ "./src/counter": "./src/counter.ts"
42
+ },
43
+ "scripts": {
44
+ "dev": "slidev demo/slides.md --open",
45
+ "build": "slidev build demo/slides.md",
46
+ "typecheck": "vue-tsc --noEmit",
47
+ "test": "vitest run",
48
+ "lint": "oxlint",
49
+ "lint:fix": "oxlint --fix",
50
+ "fmt": "prettier --write .",
51
+ "fmt:check": "prettier --check .",
52
+ "check": "pnpm typecheck && pnpm lint && pnpm fmt:check && pnpm test && pnpm build"
53
+ },
54
+ "peerDependencies": {
55
+ "vue": "^3.5.0"
56
+ },
57
+ "dependencies": {
58
+ "@slidev/client": "^52.16.0",
59
+ "htmlparser2": "^12.0.0",
60
+ "jiti": "^2.7.0",
61
+ "markdown-it": "^14.2.0"
62
+ },
63
+ "devDependencies": {
64
+ "@slidev/cli": "^52.16.0",
65
+ "@slidev/theme-default": "^0.25.0",
66
+ "@types/markdown-it": "^14.1.2",
67
+ "@types/node": "^26.0.0",
68
+ "oxlint": "^1.71.0",
69
+ "prettier": "^3.8.4",
70
+ "prettier-plugin-slidev": "^1.0.5",
71
+ "typescript": "^6.0.3",
72
+ "vitest": "^4.1.9",
73
+ "vue": "^3.5.38",
74
+ "vue-tsc": "^3.3.5"
75
+ }
76
+ }
@@ -0,0 +1,22 @@
1
+ import { injectCounterOperationIds } from "../src/counter";
2
+
3
+ export default function counterTransformers() {
4
+ return {
5
+ pre: [
6
+ (ctx: {
7
+ s: {
8
+ appendLeft: (index: number, content: string) => void;
9
+ original?: string;
10
+ toString: () => string;
11
+ };
12
+ slide?: { index?: number };
13
+ }) => {
14
+ const slideNo = (ctx.slide?.index ?? 0) + 1;
15
+ const content = ctx.s.original ?? ctx.s.toString();
16
+ for (const edit of injectCounterOperationIds(content, slideNo)) {
17
+ ctx.s.appendLeft(edit.index, edit.value);
18
+ }
19
+ },
20
+ ],
21
+ };
22
+ }
@@ -0,0 +1,241 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, normalize } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ import { createJiti } from "jiti";
6
+
7
+ import {
8
+ buildCounterTimeline,
9
+ extractCounterOperations,
10
+ normalizeCounterConfig,
11
+ type CounterConfig,
12
+ } from "../src/counter";
13
+
14
+ const CONFIG_FILE = "slidev-addon-counter.config.ts";
15
+ const VIRTUAL_ID = "virtual:slidev-addon-counter/snapshots";
16
+ const RESOLVED_VIRTUAL_ID = "\0virtual:slidev-addon-counter/snapshots";
17
+ const DIRECT_VIRTUAL_PATHS = [
18
+ "/virtual:slidev-addon-counter/snapshots",
19
+ "/@slidev-addon-counter/snapshots",
20
+ ];
21
+
22
+ interface CounterPluginOptions {
23
+ userRoot: string;
24
+ data: {
25
+ slides: SlideSource[];
26
+ };
27
+ }
28
+
29
+ interface SlideSource {
30
+ index: number;
31
+ title?: string;
32
+ source: {
33
+ content: string;
34
+ filepath: string;
35
+ };
36
+ }
37
+
38
+ interface ViteDevServerLike {
39
+ middlewares: {
40
+ use: (
41
+ handler: (
42
+ req: { url?: string },
43
+ res: {
44
+ end: (chunk?: string) => void;
45
+ setHeader: (name: string, value: string) => void;
46
+ statusCode: number;
47
+ },
48
+ next: (error?: unknown) => void,
49
+ ) => void,
50
+ ) => void;
51
+ };
52
+ moduleGraph: {
53
+ getModuleById: (id: string) => unknown;
54
+ getModuleByUrl?: (url: string) => Promise<unknown>;
55
+ invalidateModule: (module: unknown) => void;
56
+ };
57
+ watcher: {
58
+ add: (path: string | string[]) => void;
59
+ };
60
+ ws: {
61
+ send: (payload: {
62
+ path?: string;
63
+ timestamp?: number;
64
+ type: string;
65
+ }) => void;
66
+ };
67
+ }
68
+
69
+ export default function counterVitePlugins(
70
+ options: CounterPluginOptions,
71
+ ): unknown[] {
72
+ return [
73
+ {
74
+ name: "slidev-addon-counter",
75
+ async buildStart(this: { addWatchFile: (id: string) => void }) {
76
+ const configPath = getConfigPath(options.userRoot);
77
+ if (configPath) {
78
+ this.addWatchFile(configPath);
79
+ }
80
+ for (const slide of options.data.slides) {
81
+ this.addWatchFile(slide.source.filepath);
82
+ }
83
+ },
84
+ resolveId(id: string) {
85
+ return id === VIRTUAL_ID ? RESOLVED_VIRTUAL_ID : undefined;
86
+ },
87
+ configureServer(server: ViteDevServerLike) {
88
+ watchCounterDependencies(server, options);
89
+
90
+ server.middlewares.use((req, res, next) => {
91
+ if (!isDirectVirtualRequest(req.url)) {
92
+ next();
93
+ return;
94
+ }
95
+
96
+ createSnapshotModule(options)
97
+ .then((code) => {
98
+ res.statusCode = 200;
99
+ res.setHeader("Content-Type", "text/javascript");
100
+ res.end(code);
101
+ })
102
+ .catch(next);
103
+ });
104
+ },
105
+ async load(id: string) {
106
+ if (id !== RESOLVED_VIRTUAL_ID) {
107
+ return undefined;
108
+ }
109
+
110
+ return createSnapshotModule(options);
111
+ },
112
+ async handleHotUpdate(ctx: { file: string; server: ViteDevServerLike }) {
113
+ if (!isCounterDependency(ctx.file, options)) {
114
+ return undefined;
115
+ }
116
+
117
+ await invalidateSnapshotModule(ctx.server);
118
+ queueFullReload(ctx.server);
119
+
120
+ return undefined;
121
+ },
122
+ },
123
+ ];
124
+ }
125
+
126
+ async function createSnapshotModule(
127
+ options: CounterPluginOptions,
128
+ ): Promise<string> {
129
+ const rawConfig = await loadUserConfig(options.userRoot);
130
+ const config = normalizeCounterConfig(rawConfig);
131
+ const operations = options.data.slides.flatMap((slide) =>
132
+ extractCounterOperations(
133
+ slide.source.content,
134
+ slide.index + 1,
135
+ slide.title,
136
+ ),
137
+ );
138
+ const timeline = buildCounterTimeline(operations, config);
139
+
140
+ return [
141
+ `export const snapshots = ${JSON.stringify(timeline.snapshots, null, 2)}`,
142
+ `export const operations = ${JSON.stringify(timeline.operations, null, 2)}`,
143
+ ].join("\n");
144
+ }
145
+
146
+ function watchCounterDependencies(
147
+ server: ViteDevServerLike,
148
+ options: CounterPluginOptions,
149
+ ): void {
150
+ const configPath = getConfigPath(options.userRoot);
151
+ const paths = [
152
+ ...(configPath ? [configPath] : []),
153
+ ...options.data.slides.map((slide) => slide.source.filepath),
154
+ ];
155
+
156
+ server.watcher.add([...new Set(paths)]);
157
+ }
158
+
159
+ async function invalidateSnapshotModule(
160
+ server: ViteDevServerLike,
161
+ ): Promise<void> {
162
+ const modules = [
163
+ server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID),
164
+ server.moduleGraph.getModuleById(VIRTUAL_ID),
165
+ ...(await Promise.all(
166
+ DIRECT_VIRTUAL_PATHS.map((path) =>
167
+ server.moduleGraph.getModuleByUrl?.(path),
168
+ ),
169
+ )),
170
+ ].filter((module): module is object => Boolean(module));
171
+
172
+ for (const module of modules) {
173
+ server.moduleGraph.invalidateModule(module);
174
+ }
175
+ }
176
+
177
+ async function loadUserConfig(
178
+ userRoot: string,
179
+ ): Promise<CounterConfig | undefined> {
180
+ const configPath = getConfigPath(userRoot);
181
+ if (!configPath) {
182
+ return undefined;
183
+ }
184
+
185
+ const jiti = createJiti(import.meta.url, { moduleCache: false });
186
+ const configModule = await jiti.import<{ default?: CounterConfig }>(
187
+ pathToFileURL(configPath).href,
188
+ );
189
+ return configModule.default;
190
+ }
191
+
192
+ function getConfigPath(userRoot: string): string | undefined {
193
+ const configPath = join(userRoot, CONFIG_FILE);
194
+ return existsSync(configPath) ? configPath : undefined;
195
+ }
196
+
197
+ function isDirectVirtualRequest(url: string | undefined): boolean {
198
+ if (!url) {
199
+ return false;
200
+ }
201
+
202
+ const pathname = url.split("?", 1)[0];
203
+ return DIRECT_VIRTUAL_PATHS.includes(pathname);
204
+ }
205
+
206
+ function isCounterDependency(
207
+ file: string,
208
+ options: CounterPluginOptions,
209
+ ): boolean {
210
+ const normalizedFile = normalize(file);
211
+ if (isCounterConfigFile(normalizedFile, options)) {
212
+ return true;
213
+ }
214
+
215
+ return options.data.slides.some(
216
+ (slide) => normalize(slide.source.filepath) === normalizedFile,
217
+ );
218
+ }
219
+
220
+ function isCounterConfigFile(
221
+ file: string,
222
+ options: CounterPluginOptions,
223
+ ): boolean {
224
+ const normalizedFile = normalize(file);
225
+ const configPath = getConfigPath(options.userRoot);
226
+ if (configPath && normalize(configPath) === normalizedFile) {
227
+ return true;
228
+ }
229
+
230
+ return false;
231
+ }
232
+
233
+ function queueFullReload(server: ViteDevServerLike): void {
234
+ setTimeout(() => {
235
+ server.ws.send({
236
+ type: "full-reload",
237
+ path: "*",
238
+ timestamp: Date.now(),
239
+ });
240
+ }, 50);
241
+ }
package/src/counter.ts ADDED
@@ -0,0 +1,763 @@
1
+ import { parseDocument } from "htmlparser2";
2
+ import MarkdownIt from "markdown-it";
3
+
4
+ export type CounterStyle =
5
+ | "decimal"
6
+ | "zero"
7
+ | "lower-alpha"
8
+ | "upper-alpha"
9
+ | "lower-roman"
10
+ | "upper-roman"
11
+ | "cjk";
12
+
13
+ export type CounterReset = "lower" | "none";
14
+ export type CounterAction = "step" | "increment" | "display";
15
+ export type LevelRef = number | string;
16
+
17
+ export interface CounterLevelConfig {
18
+ level: number;
19
+ alias?: string;
20
+ style?: CounterStyle;
21
+ format?: string;
22
+ reset?: CounterReset;
23
+ }
24
+
25
+ export interface CounterDefinition {
26
+ levels?: CounterLevelConfig[];
27
+ }
28
+
29
+ export interface CounterConfig {
30
+ counters?: Record<string, CounterDefinition>;
31
+ }
32
+
33
+ export interface NormalizedCounterLevel {
34
+ level: number;
35
+ alias?: string;
36
+ style: CounterStyle;
37
+ format: string;
38
+ reset: CounterReset;
39
+ }
40
+
41
+ export interface NormalizedCounterDefinition {
42
+ id: string;
43
+ levels: Map<number, NormalizedCounterLevel>;
44
+ aliases: Map<string, number>;
45
+ }
46
+
47
+ export interface NormalizedCounterConfig {
48
+ counters: Map<string, NormalizedCounterDefinition>;
49
+ }
50
+
51
+ export interface CounterOperation {
52
+ id: string;
53
+ counter: string;
54
+ level: LevelRef;
55
+ action: CounterAction;
56
+ slideNo: number;
57
+ order: number;
58
+ title?: string;
59
+ }
60
+
61
+ export interface CounterSnapshot {
62
+ id: string;
63
+ counter: string;
64
+ level: number;
65
+ action: CounterAction;
66
+ counts: number[];
67
+ display: string;
68
+ }
69
+
70
+ export interface CounterTimeline {
71
+ snapshots: Record<string, CounterSnapshot>;
72
+ operations: CounterOperation[];
73
+ }
74
+
75
+ interface RenderContext {
76
+ counts: readonly number[];
77
+ counter: NormalizedCounterDefinition;
78
+ currentLevel: number;
79
+ stack: number[];
80
+ }
81
+
82
+ interface CounterTagMatch {
83
+ component: string;
84
+ attrs: Record<string, string>;
85
+ index: number;
86
+ }
87
+
88
+ interface HtmlNode {
89
+ type?: string;
90
+ name?: string;
91
+ attribs?: Record<string, string>;
92
+ children?: HtmlNode[];
93
+ startIndex?: number | null;
94
+ }
95
+
96
+ const BUILTIN_STYLES = new Set<CounterStyle>([
97
+ "decimal",
98
+ "zero",
99
+ "lower-alpha",
100
+ "upper-alpha",
101
+ "lower-roman",
102
+ "upper-roman",
103
+ "cjk",
104
+ ]);
105
+
106
+ const BUILTIN_RESETS = new Set<CounterReset>(["lower", "none"]);
107
+ const BUILTIN_PLACEHOLDER_KINDS = new Set(["value", "raw", "full"]);
108
+ const PLACEHOLDER_RE = /%\{([^}]*)\}/g;
109
+ const COUNTER_COMPONENTS = new Set([
110
+ "Counter",
111
+ "CounterStep",
112
+ "CounterIncrement",
113
+ "CounterDisplay",
114
+ ]);
115
+ const markdownIt = new MarkdownIt({ html: true });
116
+
117
+ export function defineCounterConfig<T extends CounterConfig>(config: T): T {
118
+ return config;
119
+ }
120
+
121
+ export function normalizeCounterConfig(
122
+ config: CounterConfig | undefined,
123
+ ): NormalizedCounterConfig {
124
+ const inputCounters = config?.counters ?? { default: {} };
125
+ const counters = new Map<string, NormalizedCounterDefinition>();
126
+
127
+ for (const [id, definition] of Object.entries(inputCounters)) {
128
+ if (!id) {
129
+ throw new Error("counters contains an empty counter id.");
130
+ }
131
+
132
+ const levels = new Map<number, NormalizedCounterLevel>();
133
+ const aliases = new Map<string, number>();
134
+
135
+ for (const [index, levelConfig] of (definition.levels ?? []).entries()) {
136
+ const path = `counters.${id}.levels[${index}]`;
137
+
138
+ if (!Number.isInteger(levelConfig.level) || levelConfig.level < 1) {
139
+ throw new Error(`${path}.level must be a positive integer.`);
140
+ }
141
+
142
+ if (levels.has(levelConfig.level)) {
143
+ throw new Error(`${path}.level duplicates level ${levelConfig.level}.`);
144
+ }
145
+
146
+ if (levelConfig.alias != null) {
147
+ validateAlias(levelConfig.alias, `${path}.alias`);
148
+ if (aliases.has(levelConfig.alias)) {
149
+ throw new Error(
150
+ `${path}.alias duplicates alias "${levelConfig.alias}".`,
151
+ );
152
+ }
153
+ aliases.set(levelConfig.alias, levelConfig.level);
154
+ }
155
+
156
+ const style = levelConfig.style ?? "decimal";
157
+ if (!BUILTIN_STYLES.has(style)) {
158
+ throw new Error(`${path}.style "${style}" is not supported.`);
159
+ }
160
+
161
+ const reset = levelConfig.reset ?? "lower";
162
+ if (!BUILTIN_RESETS.has(reset)) {
163
+ throw new Error(`${path}.reset "${reset}" is not supported.`);
164
+ }
165
+
166
+ levels.set(levelConfig.level, {
167
+ level: levelConfig.level,
168
+ alias: levelConfig.alias,
169
+ style,
170
+ format: levelConfig.format ?? getDefaultFormat(levelConfig.level),
171
+ reset,
172
+ });
173
+ }
174
+
175
+ counters.set(id, { id, levels, aliases });
176
+ }
177
+
178
+ if (counters.size === 0) {
179
+ counters.set("default", {
180
+ id: "default",
181
+ levels: new Map(),
182
+ aliases: new Map(),
183
+ });
184
+ }
185
+
186
+ return { counters };
187
+ }
188
+
189
+ export function getCounterDefinition(
190
+ config: NormalizedCounterConfig,
191
+ counterId: string,
192
+ ): NormalizedCounterDefinition {
193
+ const definition = config.counters.get(counterId);
194
+ if (definition) {
195
+ return definition;
196
+ }
197
+
198
+ throw new Error(`Counter "${counterId}" is not defined.`);
199
+ }
200
+
201
+ export function getLevelConfig(
202
+ counter: NormalizedCounterDefinition,
203
+ level: number,
204
+ ): NormalizedCounterLevel {
205
+ return (
206
+ counter.levels.get(level) ?? {
207
+ level,
208
+ style: "decimal",
209
+ format: getDefaultFormat(level),
210
+ reset: "lower",
211
+ }
212
+ );
213
+ }
214
+
215
+ export function resolveLevelRef(
216
+ counter: NormalizedCounterDefinition,
217
+ ref: LevelRef | undefined,
218
+ currentLevel: number,
219
+ ): number {
220
+ if (ref == null || ref === "") {
221
+ return currentLevel;
222
+ }
223
+
224
+ if (typeof ref === "number") {
225
+ validateLevel(ref, `level reference "${ref}"`);
226
+ return ref;
227
+ }
228
+
229
+ if (/^@[+-]?\d+$/.test(ref)) {
230
+ const level = currentLevel + Number(ref.slice(1));
231
+ validateLevel(level, `relative level reference "${ref}"`);
232
+ return level;
233
+ }
234
+
235
+ if (ref.startsWith("@")) {
236
+ throw new Error(`Relative level reference "${ref}" is not valid.`);
237
+ }
238
+
239
+ if (/^\d+$/.test(ref)) {
240
+ const level = Number(ref);
241
+ validateLevel(level, `level reference "${ref}"`);
242
+ return level;
243
+ }
244
+
245
+ const aliasLevel = counter.aliases.get(ref);
246
+ if (aliasLevel != null) {
247
+ return aliasLevel;
248
+ }
249
+
250
+ throw new Error(
251
+ `format reference "${ref}" references unknown level "${ref}" in counter "${counter.id}".`,
252
+ );
253
+ }
254
+
255
+ export function formatCounterValue(
256
+ value: number,
257
+ style: CounterStyle = "decimal",
258
+ ): string {
259
+ if (style === "zero") {
260
+ return String(value).padStart(2, "0");
261
+ }
262
+
263
+ if (style === "lower-alpha" || style === "upper-alpha") {
264
+ const alphabetic = toAlphabetic(value);
265
+ return style === "upper-alpha" ? alphabetic.toUpperCase() : alphabetic;
266
+ }
267
+
268
+ if (style === "lower-roman" || style === "upper-roman") {
269
+ const roman = toRoman(value);
270
+ return style === "upper-roman" ? roman.toUpperCase() : roman;
271
+ }
272
+
273
+ if (style === "cjk") {
274
+ return toCjk(value);
275
+ }
276
+
277
+ return String(value);
278
+ }
279
+
280
+ export function renderCounterFormat(
281
+ counter: NormalizedCounterDefinition,
282
+ counts: readonly number[],
283
+ targetLevel: number,
284
+ ): string {
285
+ validateLevel(targetLevel, `target level "${targetLevel}"`);
286
+
287
+ return renderFullLevel({
288
+ counts,
289
+ counter,
290
+ currentLevel: targetLevel,
291
+ stack: [],
292
+ });
293
+ }
294
+
295
+ export function buildCounterTimeline(
296
+ operations: readonly CounterOperation[],
297
+ config: CounterConfig | NormalizedCounterConfig | undefined,
298
+ ): CounterTimeline {
299
+ const normalized = isNormalizedCounterConfig(config)
300
+ ? config
301
+ : normalizeCounterConfig(config);
302
+
303
+ const sorted = [...operations].sort((a, b) => {
304
+ return a.slideNo - b.slideNo || a.order - b.order;
305
+ });
306
+
307
+ const states = new Map<string, number[]>();
308
+ const snapshots: Record<string, CounterSnapshot> = {};
309
+
310
+ for (const operation of sorted) {
311
+ const counter = getCounterDefinition(normalized, operation.counter);
312
+ const level = resolveLevelRef(counter, operation.level, 1);
313
+ const counts = states.get(operation.counter) ?? [];
314
+
315
+ if (operation.action === "step" || operation.action === "increment") {
316
+ for (let i = 0; i < level - 1; i += 1) {
317
+ counts[i] ??= 0;
318
+ }
319
+
320
+ counts[level - 1] = (counts[level - 1] ?? 0) + 1;
321
+
322
+ if (getLevelConfig(counter, level).reset === "lower") {
323
+ counts.length = level;
324
+ }
325
+
326
+ states.set(operation.counter, counts);
327
+ }
328
+
329
+ const snapshotCounts = [...counts];
330
+ snapshots[operation.id] = {
331
+ id: operation.id,
332
+ counter: operation.counter,
333
+ level,
334
+ action: operation.action,
335
+ counts: snapshotCounts,
336
+ display: renderCounterFormat(counter, snapshotCounts, level),
337
+ };
338
+ }
339
+
340
+ return {
341
+ snapshots,
342
+ operations: sorted,
343
+ };
344
+ }
345
+
346
+ function isNormalizedCounterConfig(
347
+ config: CounterConfig | NormalizedCounterConfig | undefined,
348
+ ): config is NormalizedCounterConfig {
349
+ return config?.counters instanceof Map;
350
+ }
351
+
352
+ export function extractCounterOperations(
353
+ content: string,
354
+ slideNo: number,
355
+ title?: string,
356
+ ): CounterOperation[] {
357
+ const operations: CounterOperation[] = [];
358
+
359
+ for (const match of findCounterTags(content)) {
360
+ const { component, attrs } = match;
361
+ const id =
362
+ readStringAttribute(attrs, "op") ??
363
+ getCounterOperationId(slideNo, operations.length);
364
+ const counter = readStringAttribute(attrs, "id") ?? "default";
365
+ const level = readLevelAttribute(attrs);
366
+ const action = getAction(component, readStringAttribute(attrs, "action"));
367
+
368
+ if (level == null) {
369
+ throw new Error(
370
+ `<${component}> on slide ${slideNo} is missing required prop "level".`,
371
+ );
372
+ }
373
+
374
+ operations.push({
375
+ id,
376
+ counter,
377
+ level,
378
+ action,
379
+ slideNo,
380
+ order: operations.length,
381
+ title,
382
+ });
383
+ }
384
+
385
+ return operations;
386
+ }
387
+
388
+ export function getCounterOperationId(slideNo: number, order: number): string {
389
+ return `counter-s${slideNo}-o${order}`;
390
+ }
391
+
392
+ export function injectCounterOperationIds(
393
+ content: string,
394
+ slideNo: number,
395
+ ): Array<{ index: number; value: string }> {
396
+ const edits: Array<{ index: number; value: string }> = [];
397
+ let order = 0;
398
+
399
+ for (const match of findCounterTags(content)) {
400
+ if (readStringAttribute(match.attrs, "op") != null) {
401
+ order += 1;
402
+ continue;
403
+ }
404
+
405
+ edits.push({
406
+ index: match.index + 1 + match.component.length,
407
+ value: ` op="${getCounterOperationId(slideNo, order)}"`,
408
+ });
409
+ order += 1;
410
+ }
411
+
412
+ return edits;
413
+ }
414
+
415
+ function findCounterTags(content: string): CounterTagMatch[] {
416
+ const lineOffsets = getLineOffsets(content);
417
+ const tags: CounterTagMatch[] = [];
418
+
419
+ for (const token of markdownIt.parse(content, {})) {
420
+ if (token.type === "html_block" && token.map) {
421
+ const baseIndex = lineOffsets[token.map[0]] ?? 0;
422
+ tags.push(...parseCounterTagsFromHtml(token.content, baseIndex));
423
+ continue;
424
+ }
425
+
426
+ if (token.type === "inline" && token.map && token.children) {
427
+ const segmentStart = lineOffsets[token.map[0]] ?? 0;
428
+ const segmentEnd = lineOffsets[token.map[1]] ?? content.length;
429
+ const segment = content.slice(segmentStart, segmentEnd);
430
+ let cursor = 0;
431
+
432
+ for (const child of token.children) {
433
+ if (child.type !== "html_inline") {
434
+ continue;
435
+ }
436
+
437
+ const relativeIndex = segment.indexOf(child.content, cursor);
438
+ if (relativeIndex < 0) {
439
+ continue;
440
+ }
441
+
442
+ tags.push(
443
+ ...parseCounterTagsFromHtml(
444
+ child.content,
445
+ segmentStart + relativeIndex,
446
+ ),
447
+ );
448
+ cursor = relativeIndex + child.content.length;
449
+ }
450
+ }
451
+ }
452
+
453
+ return tags.sort((a, b) => a.index - b.index);
454
+ }
455
+
456
+ function parseCounterTagsFromHtml(
457
+ html: string,
458
+ baseIndex: number,
459
+ ): CounterTagMatch[] {
460
+ const document = parseDocument(html, {
461
+ lowerCaseAttributeNames: false,
462
+ lowerCaseTags: false,
463
+ withStartIndices: true,
464
+ });
465
+ const tags: CounterTagMatch[] = [];
466
+
467
+ walkHtmlNodes(document.children as HtmlNode[], (node) => {
468
+ if (
469
+ node.type !== "tag" ||
470
+ !node.name ||
471
+ !COUNTER_COMPONENTS.has(node.name) ||
472
+ node.startIndex == null
473
+ ) {
474
+ return;
475
+ }
476
+
477
+ tags.push({
478
+ component: node.name,
479
+ attrs: node.attribs ?? {},
480
+ index: baseIndex + node.startIndex,
481
+ });
482
+ });
483
+
484
+ return tags;
485
+ }
486
+
487
+ function walkHtmlNodes(
488
+ nodes: readonly HtmlNode[],
489
+ visit: (node: HtmlNode) => void,
490
+ ): void {
491
+ for (const node of nodes) {
492
+ visit(node);
493
+ if (node.children) {
494
+ walkHtmlNodes(node.children, visit);
495
+ }
496
+ }
497
+ }
498
+
499
+ function getLineOffsets(content: string): number[] {
500
+ const offsets = [0];
501
+ for (let i = 0; i < content.length; i += 1) {
502
+ if (content[i] === "\n") {
503
+ offsets.push(i + 1);
504
+ }
505
+ }
506
+ return offsets;
507
+ }
508
+
509
+ function renderFullLevel(context: RenderContext): string {
510
+ const { counter, currentLevel, stack } = context;
511
+ if (stack.includes(currentLevel)) {
512
+ throw new Error(
513
+ `format for counter "${counter.id}" level ${currentLevel} recursively references itself.`,
514
+ );
515
+ }
516
+
517
+ const levelConfig = getLevelConfig(counter, currentLevel);
518
+ const nextContext = {
519
+ ...context,
520
+ stack: [...stack, currentLevel],
521
+ };
522
+
523
+ return levelConfig.format.replace(
524
+ PLACEHOLDER_RE,
525
+ (placeholder: string, body: string) => {
526
+ const { ref, kind } = parsePlaceholder(
527
+ placeholder,
528
+ body,
529
+ levelConfig.format,
530
+ );
531
+
532
+ if (kind === "value") {
533
+ return renderLevelValue(nextContext, ref);
534
+ }
535
+
536
+ if (kind === "raw") {
537
+ const level = resolveLevelRef(counter, ref, currentLevel);
538
+ return String(context.counts[level - 1] ?? 0);
539
+ }
540
+
541
+ if (kind === "full") {
542
+ return renderRefFull(nextContext, ref);
543
+ }
544
+
545
+ throw new Error(`Unknown counter format placeholder "${placeholder}".`);
546
+ },
547
+ );
548
+ }
549
+
550
+ function renderLevelValue(context: RenderContext, ref: string): string {
551
+ const level = resolveLevelRef(context.counter, ref, context.currentLevel);
552
+ const levelConfig = getLevelConfig(context.counter, level);
553
+ return formatCounterValue(context.counts[level - 1] ?? 0, levelConfig.style);
554
+ }
555
+
556
+ function renderRefFull(context: RenderContext, ref: string): string {
557
+ const level = resolveLevelRef(context.counter, ref, context.currentLevel);
558
+ if (level === context.currentLevel) {
559
+ throw new Error(
560
+ `format for counter "${context.counter.id}" level ${context.currentLevel} recursively references itself.`,
561
+ );
562
+ }
563
+
564
+ if (level > context.currentLevel) {
565
+ throw new Error(
566
+ `format for counter "${context.counter.id}" level ${context.currentLevel} cannot use full reference to deeper level ${level}.`,
567
+ );
568
+ }
569
+
570
+ return renderFullLevel({
571
+ ...context,
572
+ currentLevel: level,
573
+ });
574
+ }
575
+
576
+ function getDefaultFormat(level: number): string {
577
+ validateLevel(level, `level "${level}"`);
578
+ return level === 1 ? "%{:value}" : "%{@-1:full}.%{:value}";
579
+ }
580
+
581
+ function validateLevel(level: number, label: string): void {
582
+ if (!Number.isInteger(level) || level < 1) {
583
+ throw new Error(`${label} must be a positive integer.`);
584
+ }
585
+ }
586
+
587
+ function validateAlias(alias: string, path: string): void {
588
+ if (!/^[A-Za-z_][\w-]*$/.test(alias)) {
589
+ throw new Error(`${path} must be an identifier-like string.`);
590
+ }
591
+
592
+ if (/^\d+$/.test(alias) || alias.startsWith("@") || alias.includes(":")) {
593
+ throw new Error(`${path} "${alias}" is reserved.`);
594
+ }
595
+ }
596
+
597
+ function parsePlaceholder(
598
+ placeholder: string,
599
+ body: string,
600
+ format: string,
601
+ ): { ref: string; kind: string } {
602
+ const parts = body.split(":");
603
+ if (parts.length !== 2) {
604
+ throw new Error(
605
+ `format "${format}" uses placeholder "${placeholder}" without required ref:kind syntax.`,
606
+ );
607
+ }
608
+
609
+ const [ref, kind] = parts;
610
+ if (!BUILTIN_PLACEHOLDER_KINDS.has(kind)) {
611
+ throw new Error(`Unknown counter format placeholder "${placeholder}".`);
612
+ }
613
+
614
+ return { ref, kind };
615
+ }
616
+
617
+ function toAlphabetic(value: number): string {
618
+ if (!Number.isInteger(value) || value < 1) {
619
+ throw new RangeError(
620
+ `Alphabetic counter value must be a positive integer, got ${value}.`,
621
+ );
622
+ }
623
+
624
+ let remaining = value;
625
+ let result = "";
626
+
627
+ while (remaining > 0) {
628
+ remaining -= 1;
629
+ result = String.fromCharCode(97 + (remaining % 26)) + result;
630
+ remaining = Math.floor(remaining / 26);
631
+ }
632
+
633
+ return result;
634
+ }
635
+
636
+ function toRoman(value: number): string {
637
+ if (!Number.isInteger(value) || value < 1 || value > 3999) {
638
+ throw new RangeError(
639
+ `Roman counter value must be an integer between 1 and 3999, got ${value}.`,
640
+ );
641
+ }
642
+
643
+ const parts: Array<[number, string]> = [
644
+ [1000, "m"],
645
+ [900, "cm"],
646
+ [500, "d"],
647
+ [400, "cd"],
648
+ [100, "c"],
649
+ [90, "xc"],
650
+ [50, "l"],
651
+ [40, "xl"],
652
+ [10, "x"],
653
+ [9, "ix"],
654
+ [5, "v"],
655
+ [4, "iv"],
656
+ [1, "i"],
657
+ ];
658
+
659
+ let remaining = value;
660
+ let result = "";
661
+
662
+ for (const [amount, numeral] of parts) {
663
+ while (remaining >= amount) {
664
+ result += numeral;
665
+ remaining -= amount;
666
+ }
667
+ }
668
+
669
+ return result;
670
+ }
671
+
672
+ function toCjk(value: number): string {
673
+ if (!Number.isInteger(value) || value < 0 || value > 9999) {
674
+ throw new RangeError(
675
+ `CJK counter value must be an integer between 0 and 9999, got ${value}.`,
676
+ );
677
+ }
678
+
679
+ const digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"];
680
+ if (value < 10) {
681
+ return digits[value];
682
+ }
683
+
684
+ const units = ["", "十", "百", "千"];
685
+ const chars = String(value).split("").map(Number).reverse();
686
+
687
+ let result = "";
688
+ let pendingZero = false;
689
+
690
+ chars.forEach((digit, index) => {
691
+ if (digit === 0) {
692
+ pendingZero = result.length > 0;
693
+ return;
694
+ }
695
+
696
+ const part = `${digits[digit]}${units[index]}`;
697
+ result = pendingZero ? `${part}零${result}` : `${part}${result}`;
698
+ pendingZero = false;
699
+ });
700
+
701
+ return result.replace(/^一十/, "十");
702
+ }
703
+
704
+ function readStringAttribute(
705
+ attrs: Record<string, string>,
706
+ name: string,
707
+ ): string | undefined {
708
+ return attrs[name];
709
+ }
710
+
711
+ function readLevelAttribute(
712
+ attrs: Record<string, string>,
713
+ ): LevelRef | undefined {
714
+ const staticLevel = readStringAttribute(attrs, "level");
715
+ if (staticLevel != null) {
716
+ return /^\d+$/.test(staticLevel) ? Number(staticLevel) : staticLevel;
717
+ }
718
+
719
+ const boundLevel = readStringAttribute(attrs, ":level");
720
+ if (boundLevel == null) {
721
+ return undefined;
722
+ }
723
+
724
+ if (/^\d+$/.test(boundLevel.trim())) {
725
+ return Number(boundLevel.trim());
726
+ }
727
+
728
+ const stringLiteral = boundLevel.trim().match(/^['"]([^'"]+)['"]$/)?.[1];
729
+ if (stringLiteral != null) {
730
+ return /^\d+$/.test(stringLiteral) ? Number(stringLiteral) : stringLiteral;
731
+ }
732
+
733
+ throw new Error(
734
+ `Counter level binding ":level=\\"${boundLevel}\\"" must be a string or number literal.`,
735
+ );
736
+ }
737
+
738
+ function getAction(
739
+ component: string,
740
+ actionAttribute: string | undefined,
741
+ ): CounterAction {
742
+ if (actionAttribute != null) {
743
+ if (
744
+ actionAttribute === "step" ||
745
+ actionAttribute === "increment" ||
746
+ actionAttribute === "display"
747
+ ) {
748
+ return actionAttribute;
749
+ }
750
+
751
+ throw new Error(`Counter action "${actionAttribute}" is not supported.`);
752
+ }
753
+
754
+ if (component === "CounterIncrement") {
755
+ return "increment";
756
+ }
757
+
758
+ if (component === "CounterDisplay") {
759
+ return "display";
760
+ }
761
+
762
+ return "step";
763
+ }