-
-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathremark-python-refs.ts
More file actions
331 lines (291 loc) · 9.73 KB
/
remark-python-refs.ts
File metadata and controls
331 lines (291 loc) · 9.73 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
/**
* 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/)
*/
import type { Root, Link, Paragraph, PhrasingContent } from "mdast";
import { 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;
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") 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 label = extractText(node.children);
const info = classify(node.url, label);
if (!info) return;
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;
}
});
// 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;
};
}