astro-better-refs 0.1.1 → 0.2.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
@@ -106,6 +106,18 @@ Links are resolved to real URLs at build time. Moving a page or heading only req
106
106
  | `failOnBrokenRefs` | `boolean` | `true` | Exit with an error if any `ref:name` link has no matching declaration. |
107
107
  | `failOnDuplicateRefs` | `boolean` | `true` | Exit with an error if the same ref name is declared more than once. |
108
108
 
109
+ ## Sätteri
110
+
111
+ When `markdown.processor` is a [Sätteri](https://satteri.bruits.org/) processor, the integration adds its Sätteri plugin to it instead of building a unified processor. To cover MDX too, pass a shared `state` to the integration and add the plugin to each processor yourself:
112
+
113
+ ```ts
114
+ import { refs } from 'astro-better-refs/satteri';
115
+
116
+ const state = { refMap: null, brokenRefs: [], duplicates: [] };
117
+ const processor = satteri({ mdastPlugins: [refs({ state })] });
118
+ // markdown: { processor }, integrations: [mdx({ processor }), astroRef({ collections, state })]
119
+ ```
120
+
109
121
  ## Build output
110
122
 
111
123
  At the end of each build, astro-better-refs reports:
package/core.mjs ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Ref transforms shared by the unified and Sätteri plugins.
3
+ */
4
+
5
+ // Minimal HTML attribute escaping.
6
+ function escAttr(s) {
7
+ return String(s)
8
+ .replace(/&/g, '&')
9
+ .replace(/"/g, '"')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;');
12
+ }
13
+
14
+ /**
15
+ * Split a `## Heading {ref-name}` suffix off a heading's last text value.
16
+ * Returns { text, refName } or null when there is no suffix.
17
+ */
18
+ export function splitHeadingRef(value) {
19
+ const m = value.match(/\s*\{([^}]+)\}\s*$/);
20
+ if (!m) return null;
21
+ return { text: value.slice(0, m.index).trimEnd(), refName: m[1].trim() };
22
+ }
23
+
24
+ /** Invisible anchor inserted before a heading that declares a ref. */
25
+ export function anchorHtml(refName) {
26
+ return `<span id="${escAttr(refName)}" data-astro-refs="${escAttr(refName)}" aria-hidden="true" class="astro-refs"></span>`;
27
+ }
28
+
29
+ /**
30
+ * Resolve a `ref:name` link URL against the ref map. Returns the new URL, or
31
+ * null for links that don't use the ref: scheme. Unknown refs are recorded in
32
+ * `state.brokenRefs` and resolve to '#'.
33
+ */
34
+ export function resolveRefUrl(state, url) {
35
+ if (typeof url !== 'string' || !url.startsWith('ref:')) return null;
36
+ const refName = url.slice(4);
37
+ const entry = state.refMap?.get(refName);
38
+ if (entry) return entry.url;
39
+ state.brokenRefs.push(refName);
40
+ return '#'; // safe fallback; astro-better-refs reports the broken ref
41
+ }
package/index.mjs CHANGED
@@ -56,6 +56,8 @@ import { readdir, readFile } from 'node:fs/promises';
56
56
  import { join, relative, sep } from 'node:path';
57
57
  import { fileURLToPath } from 'node:url';
58
58
  import process from 'node:process';
59
+ import { anchorHtml, resolveRefUrl, splitHeadingRef } from './core.mjs';
60
+ import { refs as satteriRefs } from './satteri.mjs';
59
61
 
60
62
  // Walk a directory tree, collecting files whose names end with one of `extensions`.
61
63
  async function walkFiles(dir, extensions) {
@@ -180,15 +182,6 @@ async function buildRefMap(collections, rootDir, extensions) {
180
182
  return { refMap, duplicates };
181
183
  }
182
184
 
183
- // Minimal HTML attribute escaping.
184
- function escAttr(s) {
185
- return String(s)
186
- .replace(/&/g, '&amp;')
187
- .replace(/"/g, '&quot;')
188
- .replace(/</g, '&lt;')
189
- .replace(/>/g, '&gt;');
190
- }
191
-
192
185
  // Remark plugin that transforms the Markdown/MDX AST:
193
186
  //
194
187
  // [text](ref:name) → resolved URL from refMap, or '#' with broken-ref tracking
@@ -202,32 +195,18 @@ function remarkAstroRef({ state }) {
202
195
  if (node.type === 'heading') {
203
196
  const last = node.children?.[node.children.length - 1];
204
197
  if (last?.type === 'text') {
205
- const m = last.value.match(/\s*\{([^}]+)\}\s*$/);
206
- if (m) {
207
- const refName = m[1].trim();
208
- last.value = last.value.slice(0, m.index).trimEnd();
209
- inserts.push({
210
- parent,
211
- index,
212
- node: {
213
- type: 'html',
214
- value: `<span id="${escAttr(refName)}" data-astro-refs="${escAttr(refName)}" aria-hidden="true" class="astro-refs"></span>`,
215
- },
216
- });
198
+ const split = splitHeadingRef(last.value);
199
+ if (split) {
200
+ last.value = split.text;
201
+ inserts.push({ parent, index, node: { type: 'html', value: anchorHtml(split.refName) } });
217
202
  }
218
203
  }
219
204
  }
220
205
 
221
206
  // ref: URL scheme on links.
222
- if (node.type === 'link' && typeof node.url === 'string' && node.url.startsWith('ref:')) {
223
- const refName = node.url.slice(4);
224
- const entry = state.refMap?.get(refName);
225
- if (entry) {
226
- node.url = entry.url;
227
- } else {
228
- state.brokenRefs.push(refName);
229
- node.url = '#'; // safe fallback; astro-better-refs reports the broken ref
230
- }
207
+ if (node.type === 'link') {
208
+ const url = resolveRefUrl(state, node.url);
209
+ if (url !== null) node.url = url;
231
210
  }
232
211
 
233
212
  if (Array.isArray(node.children)) {
@@ -304,6 +283,10 @@ export default function astroRef(opts = {}) {
304
283
  if (externalState) {
305
284
  // caller wires remarkAstroRef manually (e.g. to cover both markdown and mdx processors)
306
285
  updateConfig({ vite: { plugins: [vitePlugin] } });
286
+ } else if (config.markdown?.processor?.name === 'satteri') {
287
+ // Sätteri processors keep their options mutable for integrations to extend
288
+ config.markdown.processor.options.mdastPlugins.push(satteriRefs({ state }));
289
+ updateConfig({ vite: { plugins: [vitePlugin] } });
307
290
  } else if (unifiedFn) {
308
291
  const existing = config.markdown?.processor;
309
292
  const base = (existing && isUnifiedProcessor(existing)) ? existing.options : null;
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "astro-better-refs",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Sphinx-style named refs for Astro: declare anchors anywhere, link by name, stay valid when content moves",
5
5
  "keywords": [
6
6
  "astro",
7
7
  "astro-integration",
8
8
  "refs",
9
9
  "anchors",
10
- "cross-references"
10
+ "cross-references",
11
+ "satteri"
11
12
  ],
12
13
  "license": "MIT",
13
14
  "author": "Nathan Contino <ncontino@lambdalatitudinarians.org>",
@@ -23,13 +24,36 @@
23
24
  "type": "module",
24
25
  "exports": {
25
26
  ".": "./index.mjs",
27
+ "./satteri": "./satteri.mjs",
26
28
  "./Ref.astro": "./Ref.astro"
27
29
  },
28
30
  "files": [
29
31
  "index.mjs",
32
+ "core.mjs",
33
+ "satteri.mjs",
30
34
  "Ref.astro"
31
35
  ],
32
36
  "peerDependencies": {
33
- "astro": ">=4.0.0"
37
+ "astro": ">=4.0.0",
38
+ "satteri": ">=0.10.0 <0.11.0"
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "satteri": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "devDependencies": {
49
+ "@mdx-js/mdx": "^3.1.1",
50
+ "hast-util-from-html": "^2.0.3",
51
+ "rehype-raw": "^7.0.0",
52
+ "rehype-stringify": "^10.0.1",
53
+ "remark-parse": "^11.0.0",
54
+ "remark-rehype": "^11.1.2",
55
+ "satteri": "^0.10.5",
56
+ "unified": "^11.0.5",
57
+ "unist-util-remove-position": "^5.0.0"
34
58
  }
35
59
  }
package/satteri.mjs ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Sätteri mdast plugin for astro-better-refs. Same transforms as the unified
3
+ * `remarkAstroRef`:
4
+ *
5
+ * [text](ref:name) → resolved URL from refMap, or '#' with broken-ref tracking
6
+ * ## Heading {ref-name} → strip suffix, insert invisible <span> before heading
7
+ *
8
+ * The astroRef integration adds this automatically when `markdown.processor`
9
+ * is a Sätteri processor. Wire it yourself (with a shared `state`) to cover an
10
+ * MDX processor as well:
11
+ *
12
+ * import { refs } from 'astro-better-refs/satteri';
13
+ * satteri({ mdastPlugins: [refs({ state })] });
14
+ */
15
+
16
+ import { anchorHtml, resolveRefUrl, splitHeadingRef } from './core.mjs';
17
+
18
+ // MDX can't hold html nodes, so parse into JSX there; braces in the HTML stay literal
19
+ const htmlContent = (ctx, html) => ctx.sourceFormat === 'mdx'
20
+ ? { raw: html, mdxExpressions: false }
21
+ : { type: 'html', value: html };
22
+
23
+ export function refs({ state }) {
24
+ return {
25
+ name: 'astro-better-refs',
26
+ heading(node, ctx) {
27
+ const last = node.children?.[node.children.length - 1];
28
+ if (last?.type !== 'text') return;
29
+ const split = splitHeadingRef(last.value);
30
+ if (!split) return;
31
+ ctx.setProperty(last, 'value', split.text);
32
+ ctx.insertBefore(node, htmlContent(ctx, anchorHtml(split.refName)));
33
+ },
34
+ link(node, ctx) {
35
+ const url = resolveRefUrl(state, node.url);
36
+ if (url !== null) ctx.setProperty(node, 'url', url);
37
+ },
38
+ };
39
+ }