comark-footnotes 0.0.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,41 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 onmax
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.
22
+
23
+ ---
24
+
25
+ The block and inline rules in src/index.ts are ported from markdown-it-footnote,
26
+ https://github.com/markdown-it/markdown-it-footnote, under its license:
27
+
28
+ Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin.
29
+
30
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
31
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
32
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
33
+ persons to whom the Software is furnished to do so, subject to the following conditions:
34
+
35
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
36
+ Software.
37
+
38
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
39
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
40
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
41
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
1
  # comark-footnotes
2
2
 
3
- Placeholder. The first release is published from https://github.com/onmax/comark-plugins.
3
+ [![npm version](https://comark-ui.onmax.me/badge.svg?source=npm-version&package=comark-footnotes&icon=npm)](https://npmx.dev/package/comark-footnotes)
4
+ [![npm downloads](https://comark-ui.onmax.me/badge.svg?source=npm-downloads&package=comark-footnotes&period=month&icon=npm)](https://npmx.dev/package/comark-footnotes)
5
+ [![license](https://comark-ui.onmax.me/badge.svg?label=license&value=MIT)](https://npmx.dev/package/comark-footnotes)
6
+ [![docs](https://comark-ui.onmax.me/badge.svg?label=docs&value=comark-ui.onmax.me)](https://comark-ui.onmax.me/footnotes/)
7
+
8
+ Footnotes in [Comark](https://comark.dev), in place of `comark/plugins/footnotes`: `[^1]` references, `[^1]:` definitions that hold any Markdown over several lines and blocks, and `^[inline notes]`. A parse plugin with no components, so it works in any renderer.
9
+
10
+ ```sh
11
+ pnpm add comark comark-footnotes
12
+ ```
13
+
14
+ ```ts
15
+ import { parseMarkdown } from "comark";
16
+ import footnotes from "comark-footnotes";
17
+
18
+ const document = await parseMarkdown(markdown, {
19
+ plugins: [footnotes()],
20
+ });
21
+ ```
22
+
23
+ ```md
24
+ The deploy waits when the roof holds more than 12 pigeons[^count], unless it's a hotfix^[Two approvals.].
25
+
26
+ [^count]: Counted once, from the window,
27
+ at 9:00.
28
+
29
+ Pigeons that fly off mid-count still count.
30
+ ```
31
+
32
+ Notes are numbered in the order they're first referenced and listed in a section at the end. It is named `footnotes`, like Comark's plugin, and takes the same options (`label`, `hr`, `backRef`) and writes the same nodes, so it replaces it. When a plugin list holds both, Comark keeps the first.
33
+
34
+ Compared with Comark's plugin, a definition inside fenced code stays in the code, Markdown in a note renders, and a note can run over several lines and blocks. A reference used twice gets its own id, `fnref-label-2`.
35
+
36
+ ## Issues
37
+
38
+ A reference with no definition stays as text, and a definition nothing references is dropped. Each adds a sentence to `meta.issues`:
39
+
40
+ ```txt
41
+ footnotes: [^gone] has no definition; add a "[^gone]: …" line, or remove the reference.
42
+ footnotes: [^spare]: is never referenced; add [^spare] where it applies, or remove the definition.
43
+ ```
44
+
45
+ ## Credits
46
+
47
+ The parser is ported from [markdown-it-footnote](https://github.com/markdown-it/markdown-it-footnote) by Vitaly Puzrin and Alex Kocharin, MIT.
@@ -0,0 +1,23 @@
1
+ //#region src/index.d.ts
2
+ export type FootnotesMeta = {
3
+ /** One readable sentence per problem with a footnote. Empty when the document has none. Other plugins add theirs too. */
4
+ issues: string[];
5
+ /** The comark-linter rule of each issue, by its sentence, so the linter reports it under its own code. */
6
+ issueRules: Record<string, string>;
7
+ };
8
+ export interface FootnotesOptions {
9
+ /** The section's heading. Default `"Footnotes"`; an empty string leaves the heading out. */
10
+ label?: string;
11
+ /** Whether a horizontal rule comes before the section. Default `true`. */
12
+ hr?: boolean;
13
+ /** The link from a note back to its reference. Default `"↩"`. */
14
+ backRef?: string;
15
+ }
16
+ /**
17
+ * Footnotes: `[^label]` references with `[^label]: …` definitions, which can run over indented lines and hold any
18
+ * blocks, and `^[inline notes]`. They're numbered in the order they're first referenced and listed at the end.
19
+ * It is named `footnotes`, so it takes the place of `comark/plugins/footnotes`, with the same nodes.
20
+ */
21
+ declare const _default: import("comark").ComarkPluginFactory<FootnotesOptions, FootnotesMeta, {}>;
22
+ //#endregion
23
+ export { _default as default };
package/dist/index.mjs ADDED
@@ -0,0 +1,306 @@
1
+ import { defineComarkPlugin } from "comark";
2
+ //#region ../../shared/markdown-it.ts
3
+ /**
4
+ * An inline element and a block element, by the prefix of their `_open` and `_close` types. Not `mdc_block_slot` for
5
+ * blocks: directly inside a `::component` Comark reads those as named slots and splits the block apart.
6
+ */
7
+ const SPAN = "mdc_inline_span";
8
+ const BLOCK = "blockquote";
9
+ /**
10
+ * Adds a rule before or after the first of `anchors` the parser has, else last. Comark's own rules exist only with
11
+ * its default plugins, and markdown-exit throws on a missing anchor, which would make every parse throw.
12
+ */
13
+ function addRule(ruler, place, anchors, name, fn, options) {
14
+ for (const anchor of anchors) try {
15
+ ruler[place](anchor, name, fn, options);
16
+ return;
17
+ } catch {}
18
+ ruler.push(name, fn, options);
19
+ }
20
+ //#endregion
21
+ //#region src/index.ts
22
+ const DEF = "cfn-def:";
23
+ const REF = "cfn-ref:";
24
+ const NOTE = "cfn-note";
25
+ /** `[^label]: text`, with the lines indented under it, as a block that holds the definition's own blocks. */
26
+ function definition(state, startLine, endLine, silent) {
27
+ const start = (state.bMarks[startLine] ?? 0) + (state.tShift[startLine] ?? 0);
28
+ const max = state.eMarks[startLine] ?? 0;
29
+ if (start + 4 > max) return false;
30
+ if (state.src.charCodeAt(start) !== 91 || state.src.charCodeAt(start + 1) !== 94) return false;
31
+ let pos = start + 2;
32
+ for (; pos < max; pos++) {
33
+ const ch = state.src.charCodeAt(pos);
34
+ if (ch === 32) return false;
35
+ if (ch === 93) break;
36
+ }
37
+ if (pos === start + 2) return false;
38
+ if (pos + 1 >= max || state.src.charCodeAt(++pos) !== 58) return false;
39
+ if (silent) return true;
40
+ pos++;
41
+ const label = state.src.slice(start + 2, pos - 2);
42
+ const open = state.push(`${BLOCK}_open`, DEF + label, 1);
43
+ open.map = [startLine, 0];
44
+ const saved = {
45
+ bMark: state.bMarks[startLine] ?? 0,
46
+ tShift: state.tShift[startLine] ?? 0,
47
+ sCount: state.sCount[startLine] ?? 0,
48
+ parentType: state.parentType
49
+ };
50
+ const afterColon = pos;
51
+ const initial = saved.sCount + pos - (saved.bMark + saved.tShift);
52
+ let offset = initial;
53
+ while (pos < max) {
54
+ const ch = state.src.charCodeAt(pos);
55
+ if (ch === 9) offset += 4 - offset % 4;
56
+ else if (ch === 32) offset++;
57
+ else break;
58
+ pos++;
59
+ }
60
+ state.tShift[startLine] = pos - afterColon;
61
+ state.sCount[startLine] = offset - initial;
62
+ state.bMarks[startLine] = afterColon;
63
+ state.blkIndent += 4;
64
+ state.parentType = "footnote";
65
+ if ((state.sCount[startLine] ?? 0) < state.blkIndent) state.sCount[startLine] = (state.sCount[startLine] ?? 0) + state.blkIndent;
66
+ state.md.block.tokenize(state, startLine, endLine);
67
+ state.parentType = saved.parentType;
68
+ state.blkIndent -= 4;
69
+ state.tShift[startLine] = saved.tShift;
70
+ state.sCount[startLine] = saved.sCount;
71
+ state.bMarks[startLine] = saved.bMark;
72
+ open.map[1] = state.line;
73
+ state.push(`${BLOCK}_close`, DEF + label, -1);
74
+ return true;
75
+ }
76
+ /** `[^label]`, as an empty span with the label. Whether a definition exists is decided in `post`, which reports it. */
77
+ function reference(state, silent) {
78
+ const start = state.pos;
79
+ const max = state.posMax;
80
+ if (start + 3 > max) return false;
81
+ if (state.src.charCodeAt(start) !== 91 || state.src.charCodeAt(start + 1) !== 94) return false;
82
+ let pos = start + 2;
83
+ for (; pos < max; pos++) {
84
+ const ch = state.src.charCodeAt(pos);
85
+ if (ch === 32 || ch === 10) return false;
86
+ if (ch === 93) break;
87
+ }
88
+ if (pos === start + 2 || pos >= max) return false;
89
+ const end = skipIncomplete(state, pos + 1);
90
+ const next = state.src.charCodeAt(end);
91
+ if (end === pos + 1 && (next === 40 || next === 91)) return false;
92
+ if (!silent) {
93
+ const tag = REF + state.src.slice(start + 2, pos);
94
+ state.push(`${SPAN}_open`, tag, 1);
95
+ state.push(`${SPAN}_close`, tag, -1);
96
+ }
97
+ state.pos = end;
98
+ return true;
99
+ }
100
+ /** `^[text]`, a note written where it applies, as a span holding the note's inline content. */
101
+ function inlineNote(state, silent) {
102
+ const start = state.pos;
103
+ const max = state.posMax;
104
+ if (start + 2 >= max) return false;
105
+ if (state.src.charCodeAt(start) !== 94 || state.src.charCodeAt(start + 1) !== 91) return false;
106
+ const end = state.md.helpers.parseLinkLabel(state, start + 1);
107
+ if (end < 0) return false;
108
+ if (!silent) {
109
+ state.push(`${SPAN}_open`, NOTE, 1);
110
+ state.pos = start + 2;
111
+ state.posMax = end;
112
+ state.md.inline.tokenize(state);
113
+ state.push(`${SPAN}_close`, NOTE, -1);
114
+ }
115
+ state.pos = skipIncomplete(state, end + 1);
116
+ state.posMax = max;
117
+ return true;
118
+ }
119
+ const INCOMPLETE = "(comark:incomplete-link)";
120
+ const skipIncomplete = (state, pos) => state.src.startsWith(INCOMPLETE, pos) ? pos + 24 : pos;
121
+ const markdownIt = (md) => {
122
+ addRule(md.block.ruler, "before", ["reference"], "footnote_def", definition, { alt: ["paragraph", "reference"] });
123
+ addRule(md.inline.ruler, "before", ["comark_inline_span", "link"], "footnote_inline", inlineNote);
124
+ addRule(md.inline.ruler, "before", ["comark_inline_span", "link"], "footnote_ref", reference);
125
+ };
126
+ const sanitizeLabel = (label) => label.replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.charCodeAt(0).toString(16)}-`);
127
+ const isElement = (node) => Array.isArray(node) && node[0] !== null;
128
+ /** The label after a marker's tag, or nothing for another element. */
129
+ const labelAfter = (marker, [tag]) => tag.startsWith(marker) ? tag.slice(marker.length) : void 0;
130
+ const childrenOf = ([, , ...children]) => children;
131
+ const built = /* @__PURE__ */ new WeakMap();
132
+ const pending = /* @__PURE__ */ new WeakMap();
133
+ /** Takes every definition out of the tree, wherever it sits, keeping the first for each label and noting the repeats. */
134
+ function takeDefinitions(nodes, start, found, repeated) {
135
+ for (let index = nodes.length - 1; index >= start; index--) {
136
+ const node = nodes[index];
137
+ if (!isElement(node)) continue;
138
+ const label = labelAfter(DEF, node);
139
+ if (label !== void 0) {
140
+ nodes.splice(index, 1);
141
+ const content = childrenOf(node);
142
+ takeDefinitions(content, 0, found, repeated);
143
+ if (found.has(label)) repeated.add(label);
144
+ found.set(label, content);
145
+ continue;
146
+ }
147
+ takeDefinitions(node, 2, found, repeated);
148
+ }
149
+ }
150
+ /**
151
+ * Footnotes: `[^label]` references with `[^label]: …` definitions, which can run over indented lines and hold any
152
+ * blocks, and `^[inline notes]`. They're numbered in the order they're first referenced and listed at the end.
153
+ * It is named `footnotes`, so it takes the place of `comark/plugins/footnotes`, with the same nodes.
154
+ */
155
+ var src_default = defineComarkPlugin((options) => {
156
+ const { label = "Footnotes", hr = true, backRef = "↩" } = options ?? {};
157
+ return {
158
+ name: "footnotes",
159
+ markdownItPlugins: [markdownIt],
160
+ post(state) {
161
+ const { tree } = state;
162
+ const issues = [];
163
+ const definitions = /* @__PURE__ */ new Map();
164
+ const repeated = /* @__PURE__ */ new Set();
165
+ takeDefinitions(tree.nodes, 0, definitions, repeated);
166
+ const notes = /* @__PURE__ */ new Map();
167
+ const missing = /* @__PURE__ */ new Set();
168
+ const ids = new Set([...definitions.keys()].map(sanitizeLabel));
169
+ let inline = 0;
170
+ const autoLabel = () => {
171
+ let next;
172
+ do
173
+ next = `note-${++inline}`;
174
+ while (definitions.has(next) || notes.has(next) || ids.has(sanitizeLabel(next)));
175
+ ids.add(sanitizeLabel(next));
176
+ return next;
177
+ };
178
+ const refTo = (key, content) => {
179
+ let note = notes.get(key);
180
+ if (!note) {
181
+ note = {
182
+ label: key,
183
+ id: sanitizeLabel(key),
184
+ content: content(),
185
+ refs: 0
186
+ };
187
+ notes.set(key, note);
188
+ }
189
+ const count = ++note.refs;
190
+ const sup = [
191
+ "sup",
192
+ { class: "footnote-ref" },
193
+ [
194
+ "a",
195
+ {
196
+ href: `#fn-${note.id}`,
197
+ id: count === 1 ? `fnref-${note.id}` : `fnref-${note.id}-${count}`
198
+ },
199
+ `[${[...notes.keys()].indexOf(key) + 1}]`
200
+ ]
201
+ ];
202
+ built.set(sup, {
203
+ label: key,
204
+ content: note.content
205
+ });
206
+ return sup;
207
+ };
208
+ const replace = (nodes, start) => {
209
+ for (let index = start; index < nodes.length; index++) {
210
+ const node = nodes[index];
211
+ const waiting = pending.get(nodes)?.get(index);
212
+ if (waiting !== void 0 && node === `[^${waiting}]`) {
213
+ const content = definitions.get(waiting);
214
+ if (content) {
215
+ nodes[index] = refTo(waiting, () => content);
216
+ pending.get(nodes)?.delete(index);
217
+ continue;
218
+ }
219
+ missing.add(waiting);
220
+ continue;
221
+ }
222
+ if (!isElement(node)) continue;
223
+ const [tag] = node;
224
+ const key = labelAfter(REF, node);
225
+ const previous = tag === "sup" ? built.get(node) : void 0;
226
+ if (key !== void 0) {
227
+ const content = definitions.get(key);
228
+ if (content) nodes[index] = refTo(key, () => content);
229
+ else {
230
+ nodes[index] = `[^${key}]`;
231
+ let entries = pending.get(nodes);
232
+ if (!entries) pending.set(nodes, entries = /* @__PURE__ */ new Map());
233
+ entries.set(index, key);
234
+ missing.add(key);
235
+ }
236
+ } else if (tag === NOTE) {
237
+ const content = childrenOf(node);
238
+ nodes[index] = refTo(autoLabel(), () => content);
239
+ } else if (previous) nodes[index] = refTo(previous.label, () => previous.content);
240
+ if (nodes[index] !== node) continue;
241
+ replace(node, 2);
242
+ }
243
+ };
244
+ replace(tree.nodes, 0);
245
+ for (const note of notes.values()) replace(note.content, 0);
246
+ for (const key of missing) issues.push({
247
+ rule: "comark/footnotes/missing-definition",
248
+ message: `footnotes: [^${key}] has no definition; add a "[^${key}]: …" line, or remove the reference.`
249
+ });
250
+ for (const key of definitions.keys()) if (!notes.has(key)) issues.push({
251
+ rule: "comark/footnotes/unused-definition",
252
+ message: `footnotes: [^${key}]: is never referenced; add [^${key}] where it applies, or remove the definition.`
253
+ });
254
+ for (const key of repeated) issues.push({
255
+ rule: "comark/footnotes/duplicate-definition",
256
+ message: `footnotes: [^${key}]: is defined more than once, and only the first shows; give the others their own labels, or remove them.`
257
+ });
258
+ if (issues.length) {
259
+ tree.meta.issues = [...tree.meta.issues ?? [], ...issues.map((issue) => issue.message)];
260
+ tree.meta.issueRules = {
261
+ ...tree.meta.issueRules,
262
+ ...Object.fromEntries(issues.map((issue) => [issue.message, issue.rule]))
263
+ };
264
+ }
265
+ if (!notes.size) return;
266
+ const items = [...notes.values()].map((note) => {
267
+ const backrefs = Array.from({ length: note.refs }, (_, index) => [" ", [
268
+ "a",
269
+ {
270
+ href: index ? `#fnref-${note.id}-${index + 1}` : `#fnref-${note.id}`,
271
+ class: "footnote-backref"
272
+ },
273
+ backRef
274
+ ]]).flat();
275
+ const content = [...note.content];
276
+ const last = content.at(-1);
277
+ if (isElement(last) && last[0] === "p") content[content.length - 1] = [...last, ...backrefs];
278
+ else content.push(...backrefs);
279
+ return [
280
+ "li",
281
+ { id: `fn-${note.id}` },
282
+ ...content
283
+ ];
284
+ });
285
+ const rule = hr ? [["hr", {}]] : [];
286
+ const heading = label ? [[
287
+ "h2",
288
+ { id: "footnotes" },
289
+ label
290
+ ]] : [];
291
+ tree.nodes.push([
292
+ "section",
293
+ { class: "footnotes" },
294
+ ...rule,
295
+ ...heading,
296
+ [
297
+ "ol",
298
+ { class: "footnotes-list" },
299
+ ...items
300
+ ]
301
+ ]);
302
+ }
303
+ };
304
+ });
305
+ //#endregion
306
+ export { src_default as default };
package/package.json CHANGED
@@ -1,12 +1,50 @@
1
1
  {
2
2
  "name": "comark-footnotes",
3
- "version": "0.0.0",
4
- "description": "Placeholder. The first release is published from https://github.com/onmax/comark-plugins.",
3
+ "version": "0.7.1",
4
+ "description": "Footnotes in Comark: [^1] references, [^1]: definitions over several lines and blocks, and ^[inline notes], in place of comark/plugins/footnotes.",
5
+ "keywords": [
6
+ "comark",
7
+ "endnotes",
8
+ "footnotes",
9
+ "markdown",
10
+ "mdc",
11
+ "plugin"
12
+ ],
13
+ "homepage": "https://comark-ui.onmax.me/footnotes",
14
+ "bugs": "https://github.com/onmax/comark-ui/issues",
5
15
  "license": "MIT",
6
16
  "author": "onmax",
7
17
  "repository": {
8
18
  "type": "git",
9
- "url": "git+https://github.com/onmax/comark-plugins.git",
19
+ "url": "git+https://github.com/onmax/comark-ui.git",
10
20
  "directory": "packages/footnotes"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ ".": "./dist/index.mjs",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ },
35
+ "devDependencies": {
36
+ "@comark/html": "^0.8.1",
37
+ "comark": "^0.8.1"
38
+ },
39
+ "peerDependencies": {
40
+ "comark": "^0.8.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.12.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "dev": "tsdown --watch",
48
+ "doctor": "vite-doctor . --no-cache --max-warnings 0 --format text"
11
49
  }
12
- }
50
+ }