Repository navigation
Expand file tree
/
Copy pathremark-python-refs.ts
More file actions
391 lines (346 loc) · 12.2 KB
/
Copy pathremark-python-refs.ts
File metadata and controls
391 lines (346 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
/**
* remark-python-refs
*
* A remark plugin that detects Python ecosystem URLs in Markdown links
* and transforms them into styled inline badges.
*
* Detected patterns:
* - PEP links (by URL pattern OR link text matching "PEP NNN")
* - CPython docs (docs.python.org/...)
* - PyPI links (pypi.org/project/NAME/)
* - GitHub issues/PRs (github.com/OWNER/REPO/issues|pull/NNN)
* - GitHub repos (github.com/OWNER/REPO — exactly 2 path segments)
* - GitHub users/orgs (github.com/NAME — exactly 1 segment, not reserved)
* - CVE references (nvd.nist.gov/vuln/detail/CVE-YYYY-NNNNN)
* - Python releases (python.org/downloads/release/python-XXXX/)
*
* Bare "gh-NNNN" and "CVE-YYYY-NNNN" text (not already inside a link
* or heading) is autolinked and rendered as a badge.
*/
import type { Root, Link, Paragraph, PhrasingContent } from "mdast";
import { SKIP, visit } from "unist-util-visit";
import {
pythonIcon,
docsIcon,
githubIcon,
packageIcon,
issueIcon,
shieldIcon,
downloadIcon,
externalIcon,
} from "../components/references/_icons.js";
// ── URL matchers ────────────────────────────────────────
const PEP_OLD = /^https?:\/\/(?:www\.)?python\.org\/dev\/peps\/pep-(\d+)\/?/i;
const PEP_NEW = /^https?:\/\/peps\.python\.org\/pep-(\d+)\/?/i;
const DOCS = /^https?:\/\/docs\.python\.org\//i;
const PYPI = /^https?:\/\/pypi\.org\/project\/([^/]+)\/?/i;
const GH_ISSUE = /^https?:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/(issues|pull)\/(\d+)\/?/i;
const CVE = /^https?:\/\/nvd\.nist\.gov\/vuln\/detail\/(CVE-[\d-]+)\/?/i;
/**
* Bare references in plain text that get autolinked (outside links,
* headings and code):
* - "gh-156293" → python/cpython issue
* - "CVE-2026-19445" → cve.org record
*/
const BARE_REF = /\b(?:(CVE-\d{4}-\d{4,})|gh-(\d+))\b/g;
const PY_RELEASE = /^https?:\/\/(?:www\.)?python\.org\/downloads\/release\/(python-[\w.]+)\/?/i;
const GITHUB =
/^https?:\/\/github\.com\/([\w.-]+)(?:\/([\w.-]+))?\/?$/i;
/** GitHub top-level paths that are NOT user/org profiles */
const GH_RESERVED = new Set([
"about",
"collections",
"contact",
"customer-stories",
"enterprise",
"events",
"explore",
"features",
"issues",
"login",
"marketplace",
"new",
"nonprofit",
"notifications",
"open-source",
"organizations",
"orgs",
"pricing",
"pulls",
"readme",
"search",
"security",
"settings",
"site",
"sponsors",
"team",
"topics",
"trending",
]);
// ── Helpers ─────────────────────────────────────────────
type RefType = "pep" | "docs" | "pypi" | "gh-repo" | "gh-user" | "gh-issue" | "cve" | "py-release";
interface Match {
type: RefType;
icon: string;
label: string; // author's original link text — preserved
url: string;
}
function extractText(children: PhrasingContent[]): string {
return children
.map((c) => {
if (c.type === "text" || c.type === "inlineCode") return c.value;
if ("children" in c) return extractText(c.children as PhrasingContent[]);
return "";
})
.join("");
}
/** Match by link text — catches "PEP 649", "PEP 649," etc. */
const PEP_TEXT = /^PEP\s+\d+$/i;
function classify(url: string, linkText?: string): Omit<Match, "label" | "url"> | null {
let m: RegExpMatchArray | null;
// URL-based PEP detection
if ((m = url.match(PEP_OLD)) || (m = url.match(PEP_NEW))) {
return { type: "pep", icon: pythonIcon };
}
// Text-based PEP detection — catches links like [PEP 799](https://docs.python.org/...)
if (linkText && PEP_TEXT.test(linkText.trim())) {
return { type: "pep", icon: pythonIcon };
}
// CPython docs — checked after PEP text so [PEP NNN](docs.python.org/...) stays a PEP badge
if (DOCS.test(url)) {
return { type: "docs", icon: docsIcon };
}
if ((m = url.match(PYPI))) {
return { type: "pypi", icon: packageIcon };
}
if ((m = url.match(GH_ISSUE))) {
return { type: "gh-issue", icon: issueIcon };
}
if ((m = url.match(CVE))) {
return { type: "cve", icon: shieldIcon };
}
if ((m = url.match(PY_RELEASE))) {
return { type: "py-release", icon: downloadIcon };
}
if ((m = url.match(GITHUB))) {
const [, owner, repo] = m;
if (repo) {
return { type: "gh-repo", icon: githubIcon };
}
if (!GH_RESERVED.has(owner.toLowerCase())) {
return { type: "gh-user", icon: githubIcon };
}
}
return null;
}
function buildBadgeHtml(match: Match): string {
const cls = `ref-badge ref-${match.type}`;
// Escape HTML in label
const safeLabel = match.label
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
return (
`<a href="${match.url}" class="${cls}" target="_blank" rel="noopener noreferrer">` +
`<span class="ref-icon">${match.icon}</span>` +
`<span class="ref-label">${safeLabel}</span>` +
`<span class="ref-external">${externalIcon}</span>` +
`</a>`
);
}
// ── Markdoc tag handling ────────────────────────────────
// Keystatic serializes inline components as Markdoc tags:
// {% Pep number=649 /%} or {% Pep number=649 %}PEP 649{% /Pep %}
const MARKDOC_TAG =
/\{%\s*(Pep|Docs|PyPi|GhRepo|GhUser)\s+([\s\S]*?)\s*(?:\/%\}|%\}[\s\S]*?\{%\s*\/\1\s*%\})/g;
const ATTR_RE = /(\w+)=(?:"([^"]*?)"|(\S+))/g;
function parseAttrs(str: string): Record<string, string> {
const attrs: Record<string, string> = {};
let m: RegExpExecArray | null;
while ((m = ATTR_RE.exec(str)) !== null) {
attrs[m[1]] = m[2] ?? m[3];
}
ATTR_RE.lastIndex = 0;
return attrs;
}
function markdocTagToBadge(tag: string, attrs: Record<string, string>): string | null {
switch (tag) {
case "Pep": {
const num = attrs.number;
if (!num) return null;
const padded = String(num).padStart(4, "0");
return buildBadgeHtml({
type: "pep",
icon: pythonIcon,
label: `PEP ${num}`,
url: `https://peps.python.org/pep-${padded}/`,
});
}
case "Docs": {
const path = attrs.path;
if (!path) return null;
const label = attrs.label ?? path.replace(/^\//, "").replace(/\.html$/, "").split("/").pop()!;
return buildBadgeHtml({
type: "docs",
icon: docsIcon,
label,
url: `https://docs.python.org/${path.replace(/^\//, "")}`,
});
}
case "PyPi": {
const name = attrs.name;
if (!name) return null;
return buildBadgeHtml({
type: "pypi",
icon: packageIcon,
label: name,
url: `https://pypi.org/project/${name}/`,
});
}
case "GhRepo": {
const repo = attrs.repo;
if (!repo) return null;
return buildBadgeHtml({
type: "gh-repo",
icon: githubIcon,
label: repo.split("/").pop()!,
url: `https://github.com/${repo}`,
});
}
case "GhUser": {
const name = attrs.name;
if (!name) return null;
return buildBadgeHtml({
type: "gh-user",
icon: githubIcon,
label: `@${name}`,
url: `https://github.com/${name}`,
});
}
default:
return null;
}
}
/** Split a text value on Markdoc tags, returning text + html nodes. */
function splitMarkdocTags(value: string): Array<{ type: "text"; value: string } | { type: "html"; value: string }> {
const parts: Array<{ type: "text"; value: string } | { type: "html"; value: string }> = [];
let lastIndex = 0;
MARKDOC_TAG.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = MARKDOC_TAG.exec(value)) !== null) {
if (m.index > lastIndex) {
parts.push({ type: "text", value: value.slice(lastIndex, m.index) });
}
const html = markdocTagToBadge(m[1], parseAttrs(m[2]));
if (html) {
parts.push({ type: "html", value: html });
} else {
parts.push({ type: "text", value: m[0] });
}
lastIndex = m.index + m[0].length;
}
if (lastIndex < value.length) {
parts.push({ type: "text", value: value.slice(lastIndex) });
}
return parts;
}
// ── Plugin ──────────────────────────────────────────────
export default function remarkPythonRefs() {
return (tree: Root, file: any) => {
// Collect unique references for the post footer
const seen = new Set<string>();
const refs: Array<{ type: RefType; label: string; url: string }> = [];
function collectRef(type: RefType, label: string, url: string) {
const key = `${type}:${url}`;
if (!seen.has(key)) {
seen.add(key);
refs.push({ type, label, url });
}
}
// Pass 1: Transform matching links → badges
visit(tree, "link", (node: Link, index, parent) => {
if (index == null || !parent) return;
const rawLabel = extractText(node.children);
const info = classify(node.url, rawLabel);
if (!info) return;
// For autolinked bare URLs, drop the protocol, leading "www.",
// and trailing slash so the badge reads "python.org/downloads/..."
// rather than the full "https://www.python.org/.../" form.
const label = rawLabel.startsWith("http")
? rawLabel.replace(/^https?:\/\/(?:www\.)?/i, "").replace(/\/$/, "")
: rawLabel;
collectRef(info.type, label, node.url);
const html = buildBadgeHtml({ ...info, label, url: node.url });
// Replace the link node with a raw HTML node
(parent.children as any)[index] = {
type: "html",
value: html,
};
});
// Pass 2: Transform Markdoc inline tags (from Keystatic) → badges
visit(tree, "paragraph", (node: Paragraph) => {
let changed = false;
const newChildren: PhrasingContent[] = [];
for (const child of node.children) {
if (child.type !== "text" || !MARKDOC_TAG.test(child.value)) {
MARKDOC_TAG.lastIndex = 0;
newChildren.push(child);
continue;
}
MARKDOC_TAG.lastIndex = 0;
changed = true;
const parts = splitMarkdocTags(child.value);
for (const part of parts) {
newChildren.push(part as any);
}
}
if (changed) {
node.children = newChildren;
}
});
// Pass 3: Autolink bare gh-NNNN issue refs and CVE IDs in text → badges
visit(tree, (node: any, index, parent: any) => {
// Don't touch text that is already a link (or a reference definition),
// or headings — Astro builds heading ids from text nodes only, so
// injecting HTML there would change the anchor slug.
if (
node.type === "link" ||
node.type === "linkReference" ||
node.type === "definition" ||
node.type === "heading"
) {
return SKIP;
}
if (node.type !== "text" || index == null || !parent) return;
const value: string = node.value;
const parts: any[] = [];
let lastIndex = 0;
BARE_REF.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = BARE_REF.exec(value)) !== null) {
if (m.index > lastIndex) {
parts.push({ type: "text", value: value.slice(lastIndex, m.index) });
}
const [label, cve, ghNum] = m;
const match: Match = cve
? { type: "cve", icon: shieldIcon, label, url: `https://www.cve.org/CVERecord?id=${cve}` }
: { type: "gh-issue", icon: issueIcon, label, url: `https://github.com/python/cpython/issues/${ghNum}` };
collectRef(match.type, match.label, match.url);
parts.push({ type: "html", value: buildBadgeHtml(match) });
lastIndex = m.index + m[0].length;
}
if (parts.length === 0) return;
if (lastIndex < value.length) {
parts.push({ type: "text", value: value.slice(lastIndex) });
}
parent.children.splice(index, 1, ...parts);
// Continue after the nodes we just inserted
return index + parts.length;
});
// Expose collected references via remarkPluginFrontmatter
if (!file.data.astro) file.data.astro = {};
if (!file.data.astro.frontmatter) file.data.astro.frontmatter = {};
file.data.astro.frontmatter.references = refs;
};
}