wiki-formant 0.2.1 → 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 +19 -0
- package/dist/headings.d.ts +42 -0
- package/dist/headings.d.ts.map +1 -0
- package/dist/headings.js +81 -0
- package/dist/headings.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -47,6 +47,24 @@ Four behaviours worth knowing, because each replaces a plausible wrong answer:
|
|
|
47
47
|
|
|
48
48
|
One `href` builder is passed in and used by every chip, letter and sort button. A sort button that drops the active filters is the tell that a project grew a second one.
|
|
49
49
|
|
|
50
|
+
## Headings
|
|
51
|
+
|
|
52
|
+
Heading ids, permalink anchors, and the list an "on this page" rail renders — one pass over the HTML you already have.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { injectHeadingIds, headingsFrom } from 'wiki-formant/headings';
|
|
56
|
+
|
|
57
|
+
const html = injectHeadingIds(page.body); // ids + anchors, idempotent
|
|
58
|
+
const toc = headingsFrom(html); // [{ id, text, level }]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Two behaviours worth knowing:
|
|
62
|
+
|
|
63
|
+
- **The slug rule is a parameter.** A heading id is a live URL — readers link to `#the-shape-of-a-code`, and so does the page's own permalink anchor. The two wikis this was lifted from had drifted onto different rules, and unifying them would have silently moved every published anchor on whichever one lost. Pass `slug` to keep the rule you already ship.
|
|
64
|
+
- **Deduping is not a parameter.** Two headings with the same text otherwise mint the same id twice, and every link to the second lands on the first. The copy that lacked it had that bug.
|
|
65
|
+
|
|
66
|
+
`headingsFrom` reads the string, not the rendered DOM — possible only where the body IS a string at render time. A wiki whose content streams in as blocks after mount has to query the DOM, and uses only the injector.
|
|
67
|
+
|
|
50
68
|
## MCP
|
|
51
69
|
|
|
52
70
|
A minimal [Model Context Protocol](https://modelcontextprotocol.io) server over Streamable HTTP, with the transport edges most implementations get wrong.
|
|
@@ -135,6 +153,7 @@ export async function GET(request: Request) {
|
|
|
135
153
|
| Export | From |
|
|
136
154
|
|---|---|
|
|
137
155
|
| `createTaxonomy`, `defaultHref`, `firstLetter`, `toggleFilter` | `wiki-formant/taxonomy` |
|
|
156
|
+
| `injectHeadingIds`, `headingsFrom`, `slugifyHeading` | `wiki-formant/headings` |
|
|
138
157
|
| `mcpResponse`, `mcpGet`, `mcpOptions`, `handleMcp`, `withMcpCors`, `McpToolError`, `MCP_CORS`, `MCP_PROTOCOL_VERSION` | `wiki-formant/mcp` |
|
|
139
158
|
| `htmlToMarkdown`, `inlineToMarkdown`, `tableToMarkdown`, `frontmatter`, `markdownDocument`, `decodeEntities` | `wiki-formant/markdown` |
|
|
140
159
|
| `corpusEtag`, `notModified`, `textHeaders`, `markdownHeaders`, `cleanSnippet`, `pageLine` | `wiki-formant/http` |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A heading found in a page's HTML, in document order. */
|
|
2
|
+
export interface Heading {
|
|
3
|
+
id: string;
|
|
4
|
+
text: string;
|
|
5
|
+
/** 1 for `<h1>`, 2 for `<h2>`, and so on. */
|
|
6
|
+
level: number;
|
|
7
|
+
}
|
|
8
|
+
/** The default slug rule: lowercase words joined by hyphens. */
|
|
9
|
+
export declare function slugifyHeading(text: string): string;
|
|
10
|
+
export interface HeadingIdOptions {
|
|
11
|
+
/**
|
|
12
|
+
* How heading text becomes an id. Defaults to `slugifyHeading`. Pass the rule
|
|
13
|
+
* your wiki has already published ids under — see the note at the top of this
|
|
14
|
+
* file about why this is not standardised.
|
|
15
|
+
*/
|
|
16
|
+
slug?: (text: string) => string;
|
|
17
|
+
/**
|
|
18
|
+
* Emitted after the heading text so a reader can link to the section. Return
|
|
19
|
+
* `''` for no anchor. The default is deliberately empty of text — its glyph
|
|
20
|
+
* comes from CSS, so it never leaks into a heading's `textContent` and out
|
|
21
|
+
* into a TOC label.
|
|
22
|
+
*/
|
|
23
|
+
anchor?: (id: string) => string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Give every heading in `html` an id and a permalink anchor. Headings that
|
|
27
|
+
* already carry an id keep it, and a heading already carrying an anchor is left
|
|
28
|
+
* alone, so this is safe to run twice over the same string.
|
|
29
|
+
*/
|
|
30
|
+
export declare function injectHeadingIds(html: string, options?: HeadingIdOptions): string;
|
|
31
|
+
/**
|
|
32
|
+
* The headings in `html` that carry an id, in document order — the list an "on
|
|
33
|
+
* this page" rail renders. Run it over the output of `injectHeadingIds` and
|
|
34
|
+
* every heading is in it.
|
|
35
|
+
*
|
|
36
|
+
* Reading the string rather than the rendered DOM is only possible where the
|
|
37
|
+
* body IS a string at render time. A wiki whose content streams in as blocks
|
|
38
|
+
* after mount has to query the DOM instead, which is why two of the three
|
|
39
|
+
* consumers use only the injector above.
|
|
40
|
+
*/
|
|
41
|
+
export declare function headingsFrom(html: string): Heading[];
|
|
42
|
+
//# sourceMappingURL=headings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headings.d.ts","sourceRoot":"","sources":["../src/headings.ts"],"names":[],"mappings":"AAoBA,2DAA2D;AAC3D,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gEAAgE;AAChE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOnD;AAUD,MAAM,WAAW,gBAAgB;IAC/B;;;;OAIG;IACH,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAChC;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;CACjC;AAKD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,MAAM,CAkBrF;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,EAAE,CAQpD"}
|
package/dist/headings.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// headings.ts — stable ids on a wiki page's headings, and the list a table of
|
|
2
|
+
// contents is built from.
|
|
3
|
+
//
|
|
4
|
+
// Two of the three wikis had already written this out (caper's `injectHeadingIds`,
|
|
5
|
+
// radix-wiki's heading branch in `processHtml`), down to byte-identical
|
|
6
|
+
// `stripTags` and `getAttr` helpers — caper's file says "ported in spirit from
|
|
7
|
+
// radix-wiki" at the top, which is the drift admitting itself. What they had
|
|
8
|
+
// drifted on is below.
|
|
9
|
+
//
|
|
10
|
+
// The slug rule is a parameter, not a decision this module makes. A heading id
|
|
11
|
+
// is a live URL: readers link to `#the-shape-of-a-code`, and so does the page's
|
|
12
|
+
// own permalink anchor. Unifying two slug rules would silently move every
|
|
13
|
+
// existing anchor on whichever wiki lost, so the rule each has shipped is the
|
|
14
|
+
// rule it keeps, stated at its call site instead of buried in a helper.
|
|
15
|
+
//
|
|
16
|
+
// Deduping, by contrast, is not a choice: two headings with the same text
|
|
17
|
+
// otherwise mint the same id twice and every link to the second one lands on
|
|
18
|
+
// the first. That was already a bug in the copy that lacked it, so this always
|
|
19
|
+
// dedupes.
|
|
20
|
+
/** The default slug rule: lowercase words joined by hyphens. */
|
|
21
|
+
export function slugifyHeading(text) {
|
|
22
|
+
return text
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/[^\w\s-]/g, '')
|
|
26
|
+
.replace(/[\s_-]+/g, '-')
|
|
27
|
+
.replace(/^-+|-+$/g, '');
|
|
28
|
+
}
|
|
29
|
+
const HEADING = /<(h[1-6])([^>]*)>([\s\S]*?)<\/\1>/gi;
|
|
30
|
+
const stripTags = (s) => s.replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/&/g, '&').trim();
|
|
31
|
+
const getAttr = (attrs, name) => attrs.match(new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`, 'i'))?.[1] ?? null;
|
|
32
|
+
const defaultAnchor = (id) => `<a class="heading-anchor" href="#${id}" aria-label="Permalink to this section" tabindex="-1"></a>`;
|
|
33
|
+
/**
|
|
34
|
+
* Give every heading in `html` an id and a permalink anchor. Headings that
|
|
35
|
+
* already carry an id keep it, and a heading already carrying an anchor is left
|
|
36
|
+
* alone, so this is safe to run twice over the same string.
|
|
37
|
+
*/
|
|
38
|
+
export function injectHeadingIds(html, options = {}) {
|
|
39
|
+
if (!html.trim())
|
|
40
|
+
return html;
|
|
41
|
+
const slug = options.slug ?? slugifyHeading;
|
|
42
|
+
const anchor = options.anchor ?? defaultAnchor;
|
|
43
|
+
const used = new Set();
|
|
44
|
+
return html.replace(HEADING, (match, tag, attrs, content) => {
|
|
45
|
+
if (content.includes('heading-anchor'))
|
|
46
|
+
return match;
|
|
47
|
+
const existing = getAttr(attrs, 'id');
|
|
48
|
+
let id = existing || slug(stripTags(content));
|
|
49
|
+
if (!id)
|
|
50
|
+
return match;
|
|
51
|
+
if (!existing) {
|
|
52
|
+
const base = id;
|
|
53
|
+
let n = 2;
|
|
54
|
+
while (used.has(id))
|
|
55
|
+
id = `${base}-${n++}`;
|
|
56
|
+
}
|
|
57
|
+
used.add(id);
|
|
58
|
+
return `<${tag}${existing ? attrs : `${attrs} id="${id}"`}>${content}${anchor(id)}</${tag}>`;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The headings in `html` that carry an id, in document order — the list an "on
|
|
63
|
+
* this page" rail renders. Run it over the output of `injectHeadingIds` and
|
|
64
|
+
* every heading is in it.
|
|
65
|
+
*
|
|
66
|
+
* Reading the string rather than the rendered DOM is only possible where the
|
|
67
|
+
* body IS a string at render time. A wiki whose content streams in as blocks
|
|
68
|
+
* after mount has to query the DOM instead, which is why two of the three
|
|
69
|
+
* consumers use only the injector above.
|
|
70
|
+
*/
|
|
71
|
+
export function headingsFrom(html) {
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const [, tag, attrs, content] of html.matchAll(HEADING)) {
|
|
74
|
+
const id = getAttr(attrs ?? '', 'id');
|
|
75
|
+
const text = stripTags(content ?? '');
|
|
76
|
+
if (id && text)
|
|
77
|
+
out.push({ id, text, level: Number(tag[1]) });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=headings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"headings.js","sourceRoot":"","sources":["../src/headings.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC"}
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,mBAAmB;AAEnB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,8CAA8C;AAC9C,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,mBAAmB;AAEnB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,eAAe,CAAC;AAC9B,cAAc,WAAW,CAAC;AAC1B,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-formant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The portable half of a wiki: derived taxonomy, a spec-correct MCP transport, markdown twins, block rendering, rate limiting, and the conditional-GET plumbing agent surfaces need. Zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
"types": "./dist/mcp.d.ts",
|
|
35
35
|
"import": "./dist/mcp.js"
|
|
36
36
|
},
|
|
37
|
+
"./headings": {
|
|
38
|
+
"types": "./dist/headings.d.ts",
|
|
39
|
+
"import": "./dist/headings.js"
|
|
40
|
+
},
|
|
37
41
|
"./markdown": {
|
|
38
42
|
"types": "./dist/markdown.d.ts",
|
|
39
43
|
"import": "./dist/markdown.js"
|