astro-better-refs 0.1.1 → 0.2.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/README.md +12 -0
- package/core.mjs +41 -0
- package/index.mjs +13 -30
- package/package.json +27 -3
- package/satteri.mjs +39 -0
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, '<')
|
|
11
|
+
.replace(/>/g, '>');
|
|
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, '&')
|
|
187
|
-
.replace(/"/g, '"')
|
|
188
|
-
.replace(/</g, '<')
|
|
189
|
-
.replace(/>/g, '>');
|
|
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
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
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'
|
|
223
|
-
const
|
|
224
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
}
|