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 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"}
@@ -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(/&nbsp;/g, ' ').replace(/&amp;/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
@@ -1,5 +1,6 @@
1
1
  export * from './taxonomy.js';
2
2
  export * from './mcp.js';
3
+ export * from './headings.js';
3
4
  export * from './markdown.js';
4
5
  export * from './entities.js';
5
6
  export * from './http.js';
@@ -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
@@ -5,6 +5,7 @@
5
5
  // then lets drift.
6
6
  export * from './taxonomy.js';
7
7
  export * from './mcp.js';
8
+ export * from './headings.js';
8
9
  export * from './markdown.js';
9
10
  export * from './entities.js';
10
11
  export * from './http.js';
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.2.1",
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"