astro-better-refs 0.1.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.md +7 -0
- package/README.md +147 -0
- package/Ref.astro +17 -0
- package/index.mjs +357 -0
- package/package.json +35 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Better Static Sites
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# astro-better-refs
|
|
2
|
+
|
|
3
|
+
Sphinx-style named refs for Astro. Declare an anchor anywhere in your content and link to it by name from anywhere else in the site. Links stay valid even when content moves, because the name travels with the content.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install astro-better-refs
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Add the integration to your Astro config. The `collections` option maps each source directory to its URL base.
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// astro.config.mjs
|
|
17
|
+
import { defineConfig } from 'astro';
|
|
18
|
+
import astroRef from 'astro-better-refs';
|
|
19
|
+
|
|
20
|
+
export default defineConfig({
|
|
21
|
+
integrations: [
|
|
22
|
+
astroRef({
|
|
23
|
+
collections: [
|
|
24
|
+
{ src: 'src/content/docs', base: '/docs' },
|
|
25
|
+
],
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
If your sticky header pushes content down, set this CSS variable once in your global stylesheet:
|
|
32
|
+
|
|
33
|
+
```css
|
|
34
|
+
:root {
|
|
35
|
+
--astro-refs-scroll-offset: 80px; /* match your header height */
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Declaring refs
|
|
40
|
+
|
|
41
|
+
There are three ways to declare a ref. In all cases, the name must be unique across the entire site.
|
|
42
|
+
|
|
43
|
+
### 1. Component
|
|
44
|
+
|
|
45
|
+
Place an invisible anchor anywhere -- before a heading, in the middle of prose, wherever you want the link to land.
|
|
46
|
+
|
|
47
|
+
```mdx
|
|
48
|
+
import Ref from 'astro-better-refs/Ref.astro';
|
|
49
|
+
|
|
50
|
+
<Ref id="my-anchor" />
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 2. Frontmatter
|
|
54
|
+
|
|
55
|
+
Declare a page-level ref in the frontmatter. Links to this ref resolve to the page URL with no fragment (scroll to top).
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
---
|
|
59
|
+
title: My Page
|
|
60
|
+
ref: my-page
|
|
61
|
+
---
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Multiple aliases for the same page:
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
---
|
|
68
|
+
refs:
|
|
69
|
+
- my-page
|
|
70
|
+
- legacy-page-name
|
|
71
|
+
---
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Section heading
|
|
75
|
+
|
|
76
|
+
Add `{ref-name}` to the end of any Markdown heading. The suffix is stripped from the rendered heading; an invisible anchor is inserted just before it.
|
|
77
|
+
|
|
78
|
+
```markdown
|
|
79
|
+
## Potato Varieties {potato-varieties}
|
|
80
|
+
|
|
81
|
+
### Russet {russet-potato}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
This is the most convenient option for section-level refs, since the ref lives right next to the content it names.
|
|
85
|
+
|
|
86
|
+
## Linking to a ref
|
|
87
|
+
|
|
88
|
+
Use the `ref:` URL scheme in any Markdown link:
|
|
89
|
+
|
|
90
|
+
```markdown
|
|
91
|
+
See [potato varieties](ref:potato-varieties) for details.
|
|
92
|
+
|
|
93
|
+
The [Russet](ref:russet-potato) is the most common variety.
|
|
94
|
+
|
|
95
|
+
Visit [the overview page](ref:my-page).
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Links are resolved to real URLs at build time. Moving a page or heading only requires updating the ref declaration -- not every link pointing to it.
|
|
99
|
+
|
|
100
|
+
## Options
|
|
101
|
+
|
|
102
|
+
| Option | Type | Default | Description |
|
|
103
|
+
|---|---|---|---|
|
|
104
|
+
| `collections` | `{src, base}[]` | `[]` | Source directories and their URL bases. `src` is relative to the project root. |
|
|
105
|
+
| `extensions` | `string[]` | `['.md', '.mdx', '.astro']` | File extensions to scan for ref declarations. |
|
|
106
|
+
| `failOnBrokenRefs` | `boolean` | `true` | Exit with an error if any `ref:name` link has no matching declaration. |
|
|
107
|
+
| `failOnDuplicateRefs` | `boolean` | `true` | Exit with an error if the same ref name is declared more than once. |
|
|
108
|
+
|
|
109
|
+
## Build output
|
|
110
|
+
|
|
111
|
+
At the end of each build, astro-better-refs reports:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
[astro-better-refs] all refs ok (42 declared)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or, if there are problems:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
[astro-better-refs] 1 duplicate ref:
|
|
121
|
+
"potato-varieties"
|
|
122
|
+
src/content/docs/vegetables/potatoes.mdx
|
|
123
|
+
src/content/docs/vegetables/starchy.mdx
|
|
124
|
+
|
|
125
|
+
[astro-better-refs] 2 unresolved refs:
|
|
126
|
+
"missing-anchor"
|
|
127
|
+
"old-section-name"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Unresolved refs (links to names that were never declared) render as `href="#"` in the output so the page still builds. The build exits with code 1 if `failOnBrokenRefs` or `failOnDuplicateRefs` is set and violations are found.
|
|
131
|
+
|
|
132
|
+
## Scroll offset
|
|
133
|
+
|
|
134
|
+
When the user navigates to a ref, the browser scrolls the target element into view. If your site has a sticky header, the heading or anchor may be hidden underneath it. Fix this by setting `--astro-refs-scroll-offset` to the height of your header:
|
|
135
|
+
|
|
136
|
+
```css
|
|
137
|
+
:root {
|
|
138
|
+
--astro-refs-scroll-offset: 64px;
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The default is 80px.
|
|
143
|
+
|
|
144
|
+
## Works well with
|
|
145
|
+
|
|
146
|
+
- **astro-better-link-checker** -- once astro-better-refs resolves `ref:name` links to real URLs, astro-better-link-checker validates those URLs as regular links. If a ref points to a page that was deleted (but the declaration wasn't removed), astro-better-link-checker catches it.
|
|
147
|
+
- **astro-toc** -- refs declared with `{ref-name}` in headings do not appear in the table of contents. The heading itself renders normally; only the invisible anchor is added.
|
package/Ref.astro
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
id: string;
|
|
4
|
+
}
|
|
5
|
+
const { id } = Astro.props;
|
|
6
|
+
if (!id) throw new Error('[astro-better-refs] <Ref> requires an id prop');
|
|
7
|
+
---
|
|
8
|
+
<span id={id} data-astro-refs={id} aria-hidden="true" class="astro-refs"></span>
|
|
9
|
+
<style is:global>
|
|
10
|
+
.astro-refs {
|
|
11
|
+
display: block;
|
|
12
|
+
width: 0;
|
|
13
|
+
height: 0;
|
|
14
|
+
visibility: hidden;
|
|
15
|
+
scroll-margin-top: var(--astro-refs-scroll-offset, 80px);
|
|
16
|
+
}
|
|
17
|
+
</style>
|
package/index.mjs
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* astro-better-refs
|
|
3
|
+
*
|
|
4
|
+
* Sphinx-style named refs for Astro. Declare an anchor anywhere in your content
|
|
5
|
+
* and link to it by name from anywhere else in the site. Links stay valid even
|
|
6
|
+
* when content moves, because the name travels with the content.
|
|
7
|
+
*
|
|
8
|
+
* Three ways to declare a ref:
|
|
9
|
+
*
|
|
10
|
+
* 1. Component — place an invisible anchor anywhere in prose or before a heading:
|
|
11
|
+
* <Ref id="my-anchor" />
|
|
12
|
+
*
|
|
13
|
+
* 2. Frontmatter — point a name at the top of the page (no fragment):
|
|
14
|
+
* ---
|
|
15
|
+
* ref: my-page-name
|
|
16
|
+
* ---
|
|
17
|
+
* Multiple aliases:
|
|
18
|
+
* ---
|
|
19
|
+
* refs:
|
|
20
|
+
* - my-page-name
|
|
21
|
+
* - legacy-name
|
|
22
|
+
* ---
|
|
23
|
+
*
|
|
24
|
+
* 3. Section heading — add {ref-name} to the end of any heading:
|
|
25
|
+
* ## Potatoes {my-potato-section}
|
|
26
|
+
* The suffix is stripped from the rendered heading; an invisible anchor
|
|
27
|
+
* is inserted just before the heading element.
|
|
28
|
+
*
|
|
29
|
+
* Link to any ref from anywhere in the site using the ref: URL scheme:
|
|
30
|
+
*
|
|
31
|
+
* [See the potato section](ref:my-potato-section)
|
|
32
|
+
*
|
|
33
|
+
* Usage (astro.config.ts):
|
|
34
|
+
*
|
|
35
|
+
* import astroRef from 'astro-better-refs';
|
|
36
|
+
* export default defineConfig({
|
|
37
|
+
* integrations: [
|
|
38
|
+
* astroRef({
|
|
39
|
+
* collections: [{ src: 'src/content/docs', base: '/docs' }],
|
|
40
|
+
* }),
|
|
41
|
+
* ],
|
|
42
|
+
* });
|
|
43
|
+
*
|
|
44
|
+
* Options:
|
|
45
|
+
* collections {Array<{src, base}>} source dirs and their URL bases (required)
|
|
46
|
+
* extensions {string[]} file extensions to scan (default: ['.md', '.mdx', '.astro'])
|
|
47
|
+
* failOnBrokenRefs {boolean} exit 1 on unresolved ref: links (default: true)
|
|
48
|
+
* failOnDuplicateRefs {boolean} exit 1 on duplicate ref names (default: true)
|
|
49
|
+
*
|
|
50
|
+
* Scroll offset:
|
|
51
|
+
* Set --astro-refs-scroll-offset on :root to match your sticky header height.
|
|
52
|
+
* The default is 80px.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
56
|
+
import { join, relative, sep } from 'node:path';
|
|
57
|
+
import { fileURLToPath } from 'node:url';
|
|
58
|
+
import process from 'node:process';
|
|
59
|
+
|
|
60
|
+
// Walk a directory tree, collecting files whose names end with one of `extensions`.
|
|
61
|
+
async function walkFiles(dir, extensions) {
|
|
62
|
+
let entries;
|
|
63
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
64
|
+
catch { return []; }
|
|
65
|
+
const parts = await Promise.all(
|
|
66
|
+
entries.map(e => {
|
|
67
|
+
const p = join(dir, e.name);
|
|
68
|
+
if (e.isDirectory()) return walkFiles(p, extensions);
|
|
69
|
+
return extensions.some(ext => e.name.endsWith(ext)) ? [p] : [];
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
return parts.flat();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Derive a root-relative URL path from an absolute source file path.
|
|
76
|
+
// Returns null if the file doesn't fall under any configured collection.
|
|
77
|
+
function fileToUrl(filePath, collections, rootDir) {
|
|
78
|
+
for (const { src, base } of collections) {
|
|
79
|
+
const srcAbs = join(rootDir, src);
|
|
80
|
+
const prefix = srcAbs.endsWith(sep) ? srcAbs : srcAbs + sep;
|
|
81
|
+
if (!filePath.startsWith(prefix)) continue;
|
|
82
|
+
|
|
83
|
+
let rel = relative(srcAbs, filePath).replace(/\\/g, '/');
|
|
84
|
+
rel = rel.replace(/\.(mdx?|astro)$/, '');
|
|
85
|
+
if (rel === 'index') rel = '';
|
|
86
|
+
else rel = rel.replace(/\/index$/, '');
|
|
87
|
+
|
|
88
|
+
const urlBase = base.replace(/\/$/, '');
|
|
89
|
+
return urlBase + (rel ? '/' + rel : '');
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Extract all ref declarations from a source file's text.
|
|
95
|
+
// Returns [{name, anchor}]:
|
|
96
|
+
// anchor === name → URL will be pageUrl#name (component or heading)
|
|
97
|
+
// anchor === null → URL will be pageUrl (frontmatter, page-level)
|
|
98
|
+
function extractRefs(text, filePath) {
|
|
99
|
+
const refs = [];
|
|
100
|
+
const isMd = filePath.endsWith('.md') || filePath.endsWith('.mdx');
|
|
101
|
+
let m;
|
|
102
|
+
|
|
103
|
+
// <Ref id="name" /> or <Ref id='name' /> — all file types
|
|
104
|
+
const reComponent = /<Ref\s+id=(["'])([^"']+)\1/g;
|
|
105
|
+
while ((m = reComponent.exec(text)) !== null) {
|
|
106
|
+
refs.push({ name: m[2], anchor: m[2] });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isMd) {
|
|
110
|
+
// YAML frontmatter: ref: name or refs: [n1, n2] or refs:\n - n
|
|
111
|
+
const fmMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
112
|
+
if (fmMatch) {
|
|
113
|
+
const fm = fmMatch[1];
|
|
114
|
+
|
|
115
|
+
const single = fm.match(/^ref:\s*["']?([^"'\r\n]+?)["']?\s*$/m);
|
|
116
|
+
if (single) refs.push({ name: single[1].trim(), anchor: null });
|
|
117
|
+
|
|
118
|
+
// inline list: refs: [a, b, c]
|
|
119
|
+
const inline = fm.match(/^refs:\s*\[([^\]]+)\]/m);
|
|
120
|
+
if (inline) {
|
|
121
|
+
for (const item of inline[1].split(',')) {
|
|
122
|
+
const name = item.trim().replace(/^["']|["']$/g, '');
|
|
123
|
+
if (name) refs.push({ name, anchor: null });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// block list:
|
|
128
|
+
// refs:
|
|
129
|
+
// - name
|
|
130
|
+
if (!inline) {
|
|
131
|
+
const block = fm.match(/^refs:\s*\r?\n((?:[ \t]*-[ \t]+[^\r\n]+\r?\n?)+)/m);
|
|
132
|
+
if (block) {
|
|
133
|
+
for (const item of block[1].matchAll(/^[ \t]*-[ \t]+([^\r\n]+)/gm)) {
|
|
134
|
+
refs.push({ name: item[1].trim().replace(/^["']|["']$/g, ''), anchor: null });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ## Heading {ref-name}
|
|
141
|
+
const reHeading = /^#{1,6}[^\n]+\{([^}\n]+)\}/gm;
|
|
142
|
+
while ((m = reHeading.exec(text)) !== null) {
|
|
143
|
+
const name = m[1].trim();
|
|
144
|
+
refs.push({ name, anchor: name });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return refs;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Scan all source files and build the ref map.
|
|
152
|
+
// Returns { refMap: Map<name, {url, file}>, duplicates: [{name, file1, file2}] }
|
|
153
|
+
async function buildRefMap(collections, rootDir, extensions) {
|
|
154
|
+
const refMap = new Map();
|
|
155
|
+
const duplicates = [];
|
|
156
|
+
|
|
157
|
+
for (const collection of collections) {
|
|
158
|
+
const srcAbs = join(rootDir, collection.src);
|
|
159
|
+
const files = await walkFiles(srcAbs, extensions);
|
|
160
|
+
|
|
161
|
+
await Promise.all(files.map(async file => {
|
|
162
|
+
const pageUrl = fileToUrl(file, collections, rootDir);
|
|
163
|
+
if (!pageUrl) return;
|
|
164
|
+
|
|
165
|
+
let text;
|
|
166
|
+
try { text = await readFile(file, 'utf-8'); }
|
|
167
|
+
catch { return; }
|
|
168
|
+
|
|
169
|
+
for (const { name, anchor } of extractRefs(text, file)) {
|
|
170
|
+
const url = anchor ? `${pageUrl}#${anchor}` : pageUrl;
|
|
171
|
+
if (refMap.has(name)) {
|
|
172
|
+
duplicates.push({ name, file1: refMap.get(name).file, file2: file });
|
|
173
|
+
} else {
|
|
174
|
+
refMap.set(name, { url, file });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return { refMap, duplicates };
|
|
181
|
+
}
|
|
182
|
+
|
|
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
|
+
// Remark plugin that transforms the Markdown/MDX AST:
|
|
193
|
+
//
|
|
194
|
+
// [text](ref:name) → resolved URL from refMap, or '#' with broken-ref tracking
|
|
195
|
+
// ## Heading {ref-name} → strip suffix, insert invisible <span> before heading
|
|
196
|
+
function remarkAstroRef({ state }) {
|
|
197
|
+
return tree => {
|
|
198
|
+
const inserts = []; // {parent, index, node} — applied after the walk
|
|
199
|
+
|
|
200
|
+
function walk(node, parent, index) {
|
|
201
|
+
// Heading with {ref-name} suffix on its last text child.
|
|
202
|
+
if (node.type === 'heading') {
|
|
203
|
+
const last = node.children?.[node.children.length - 1];
|
|
204
|
+
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
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 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
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (Array.isArray(node.children)) {
|
|
234
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
235
|
+
walk(node.children[i], node, i);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
walk(tree, null, -1);
|
|
241
|
+
|
|
242
|
+
// Insert heading anchors in reverse index order so earlier insertions
|
|
243
|
+
// don't shift the indices of later ones.
|
|
244
|
+
inserts.sort((a, b) => b.index - a.index);
|
|
245
|
+
for (const { parent, index, node } of inserts) {
|
|
246
|
+
if (parent?.children) parent.children.splice(index, 0, node);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export { remarkAstroRef };
|
|
252
|
+
|
|
253
|
+
export default function astroRef(opts = {}) {
|
|
254
|
+
const {
|
|
255
|
+
collections = [],
|
|
256
|
+
extensions = ['.md', '.mdx', '.astro'],
|
|
257
|
+
failOnBrokenRefs = true,
|
|
258
|
+
failOnDuplicateRefs = true,
|
|
259
|
+
_exit = process.exit,
|
|
260
|
+
state: externalState = null,
|
|
261
|
+
} = opts;
|
|
262
|
+
|
|
263
|
+
const state = externalState ?? {
|
|
264
|
+
refMap: null,
|
|
265
|
+
brokenRefs: [],
|
|
266
|
+
duplicates: [],
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
name: 'astro-better-refs',
|
|
271
|
+
hooks: {
|
|
272
|
+
'astro:config:setup': async ({ config, updateConfig, logger }) => {
|
|
273
|
+
const rootDir = config.root instanceof URL
|
|
274
|
+
? fileURLToPath(config.root)
|
|
275
|
+
: String(config.root ?? '.');
|
|
276
|
+
|
|
277
|
+
if (!collections.length) {
|
|
278
|
+
(logger ?? console).warn('[astro-better-refs] no collections configured — no refs will be scanned');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let scanned = false;
|
|
282
|
+
const vitePlugin = {
|
|
283
|
+
name: 'astro-better-refs-scanner',
|
|
284
|
+
async buildStart() {
|
|
285
|
+
if (scanned || !collections.length) return;
|
|
286
|
+
scanned = true;
|
|
287
|
+
const result = await buildRefMap(collections, rootDir, extensions);
|
|
288
|
+
state.refMap = result.refMap;
|
|
289
|
+
state.duplicates = result.duplicates;
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Astro 7 requires markdown.processor instead of markdown.remarkPlugins
|
|
294
|
+
let unifiedFn, isUnifiedProcessor;
|
|
295
|
+
try {
|
|
296
|
+
const { createRequire } = await import('node:module');
|
|
297
|
+
const req = createRequire(config.root);
|
|
298
|
+
const modPath = req.resolve('@astrojs/markdown-remark');
|
|
299
|
+
({ unified: unifiedFn, isUnifiedProcessor } = await import(modPath));
|
|
300
|
+
} catch {
|
|
301
|
+
unifiedFn = null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (externalState) {
|
|
305
|
+
// caller wires remarkAstroRef manually (e.g. to cover both markdown and mdx processors)
|
|
306
|
+
updateConfig({ vite: { plugins: [vitePlugin] } });
|
|
307
|
+
} else if (unifiedFn) {
|
|
308
|
+
const existing = config.markdown?.processor;
|
|
309
|
+
const base = (existing && isUnifiedProcessor(existing)) ? existing.options : null;
|
|
310
|
+
const processor = unifiedFn({
|
|
311
|
+
remarkPlugins: [...(base?.remarkPlugins ?? []), [remarkAstroRef, { state }]],
|
|
312
|
+
rehypePlugins: base?.rehypePlugins ?? [],
|
|
313
|
+
remarkRehype: base?.remarkRehype ?? {},
|
|
314
|
+
gfm: base?.gfm,
|
|
315
|
+
smartypants: base?.smartypants,
|
|
316
|
+
});
|
|
317
|
+
updateConfig({
|
|
318
|
+
markdown: { processor },
|
|
319
|
+
vite: { plugins: [vitePlugin] },
|
|
320
|
+
});
|
|
321
|
+
} else {
|
|
322
|
+
// fallback for older Astro versions
|
|
323
|
+
updateConfig({
|
|
324
|
+
markdown: { remarkPlugins: [[remarkAstroRef, { state }]] },
|
|
325
|
+
vite: { plugins: [vitePlugin] },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
'astro:build:done': ({ logger }) => {
|
|
331
|
+
const log = msg => (logger ? logger.info(msg) : console.log(msg));
|
|
332
|
+
let fail = false;
|
|
333
|
+
|
|
334
|
+
if (state.duplicates.length > 0) {
|
|
335
|
+
const lines = state.duplicates
|
|
336
|
+
.map(({ name, file1, file2 }) => ` "${name}"\n ${file1}\n ${file2}`)
|
|
337
|
+
.join('\n');
|
|
338
|
+
log(`[astro-better-refs] ${state.duplicates.length} duplicate ref${state.duplicates.length === 1 ? '' : 's'}:\n${lines}`);
|
|
339
|
+
if (failOnDuplicateRefs) fail = true;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const uniqueBroken = [...new Set(state.brokenRefs)].sort();
|
|
343
|
+
if (uniqueBroken.length > 0) {
|
|
344
|
+
const lines = uniqueBroken.map(n => ` "${n}"`).join('\n');
|
|
345
|
+
log(`[astro-better-refs] ${uniqueBroken.length} unresolved ref${uniqueBroken.length === 1 ? '' : 's'}:\n${lines}`);
|
|
346
|
+
if (failOnBrokenRefs) fail = true;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!state.duplicates.length && !uniqueBroken.length) {
|
|
350
|
+
log(`[astro-better-refs] all refs ok (${state.refMap?.size ?? 0} declared)`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (fail) _exit(1);
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "astro-better-refs",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Sphinx-style named refs for Astro: declare anchors anywhere, link by name, stay valid when content moves",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"astro",
|
|
7
|
+
"astro-integration",
|
|
8
|
+
"refs",
|
|
9
|
+
"anchors",
|
|
10
|
+
"cross-references"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Nathan Contino <ncontino@lambdalatitudinarians.org>",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/better-static-sites/better-static-sites.github.io.git",
|
|
17
|
+
"directory": "astro-better-refs"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://better-static-sites.github.io",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/better-static-sites/better-static-sites.github.io/issues"
|
|
22
|
+
},
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": "./index.mjs",
|
|
26
|
+
"./Ref.astro": "./Ref.astro"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"index.mjs",
|
|
30
|
+
"Ref.astro"
|
|
31
|
+
],
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"astro": ">=4.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|