repoview 0.1.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.
@@ -0,0 +1,344 @@
1
+ import path from "node:path";
2
+ import hljs from "highlight.js";
3
+ import GithubSlugger from "github-slugger";
4
+ import MarkdownIt from "markdown-it";
5
+ import { full as emoji } from "markdown-it-emoji";
6
+ import footnote from "markdown-it-footnote";
7
+ import taskLists from "markdown-it-task-lists";
8
+ import sanitizeHtml from "sanitize-html";
9
+
10
+ function escapeHtml(s) {
11
+ return String(s)
12
+ .replaceAll("&", "&")
13
+ .replaceAll("<", "&lt;")
14
+ .replaceAll(">", "&gt;")
15
+ .replaceAll('"', "&quot;")
16
+ .replaceAll("'", "&#39;");
17
+ }
18
+
19
+ function isExternalHref(href) {
20
+ return /^(?:[a-z]+:)?\/\//i.test(href) || href.startsWith("mailto:") || href.startsWith("tel:");
21
+ }
22
+
23
+ function toPosixPath(p) {
24
+ return p.split(path.sep).join("/");
25
+ }
26
+
27
+ function normalizeRepoPath(posixPath) {
28
+ const stripped = String(posixPath || "").replace(/^\/+/, "");
29
+ let normalized = path.posix.normalize(stripped);
30
+ if (normalized === "." || normalized === "./") return "";
31
+ // GitHub effectively clamps links so they can't escape the repo root.
32
+ while (normalized === ".." || normalized.startsWith("../")) {
33
+ normalized = normalized === ".." ? "" : normalized.slice(3);
34
+ }
35
+ return normalized;
36
+ }
37
+
38
+ function encodePathForUrl(posixPath) {
39
+ return posixPath
40
+ .split("/")
41
+ .map((segment) => encodeURIComponent(segment))
42
+ .join("/");
43
+ }
44
+
45
+ function rewriteLinkHref(href, baseDirPosix) {
46
+ if (!href) return href;
47
+ if (href.startsWith("#") || isExternalHref(href)) return href;
48
+
49
+ const trimmed = href.trim();
50
+ const hashIndex = trimmed.indexOf("#");
51
+ const beforeHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
52
+ const hash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1) : "";
53
+
54
+ const queryIndex = beforeHash.indexOf("?");
55
+ const rawPath = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
56
+ const query = queryIndex >= 0 ? beforeHash.slice(queryIndex) : "";
57
+
58
+ if (/^\/(?:blob|tree|raw|static)(?:\/|$)/.test(rawPath) || rawPath === "/events") {
59
+ return href;
60
+ }
61
+
62
+ const raw = rawPath.trim();
63
+ if (!raw) return href;
64
+
65
+ const isRooted = raw.startsWith("/");
66
+ const targetPosix = isRooted
67
+ ? normalizeRepoPath(raw)
68
+ : normalizeRepoPath(path.posix.join(baseDirPosix || "", raw));
69
+ if (targetPosix == null) return href;
70
+
71
+ const isTree = raw.endsWith("/") || targetPosix === "";
72
+ const newPath = `/${isTree ? "tree" : "blob"}/${encodePathForUrl(targetPosix)}`;
73
+ const withQuery = query ? `${newPath}${query}` : newPath;
74
+ return hash ? `${withQuery}#${hash}` : withQuery;
75
+ }
76
+
77
+ function rewriteImageSrc(src, baseDirPosix) {
78
+ if (!src) return src;
79
+ if (isExternalHref(src) || src.startsWith("data:")) return src;
80
+
81
+ const trimmed = src.trim();
82
+ const hashIndex = trimmed.indexOf("#");
83
+ const beforeHash = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
84
+ const hash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1) : "";
85
+
86
+ const queryIndex = beforeHash.indexOf("?");
87
+ const rawPath = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash;
88
+ const query = queryIndex >= 0 ? beforeHash.slice(queryIndex) : "";
89
+
90
+ if (/^\/(?:raw|static)(?:\/|$)/.test(rawPath)) return src;
91
+
92
+ const isRooted = rawPath.startsWith("/");
93
+ const targetPosix = isRooted
94
+ ? normalizeRepoPath(rawPath)
95
+ : normalizeRepoPath(path.posix.join(baseDirPosix || "", rawPath));
96
+ if (targetPosix == null) return src;
97
+ const newPath = `/raw/${encodePathForUrl(targetPosix)}`;
98
+ const withQuery = query ? `${newPath}${query}` : newPath;
99
+ return hash ? `${withQuery}#${hash}` : withQuery;
100
+ }
101
+
102
+ export function createMarkdownRenderer() {
103
+ const md = new MarkdownIt({
104
+ html: true,
105
+ linkify: true,
106
+ typographer: false,
107
+ highlight(code, lang) {
108
+ if (lang && hljs.getLanguage(lang)) {
109
+ return hljs.highlight(code, { language: lang }).value;
110
+ }
111
+ return hljs.highlightAuto(code).value;
112
+ },
113
+ })
114
+ .use(emoji, { shortcuts: {} })
115
+ .use(footnote)
116
+ .use(taskLists, { enabled: true, label: false, labelAfter: false })
117
+ .enable(["table", "strikethrough"]);
118
+
119
+ const defaultFence = md.renderer.rules.fence;
120
+ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
121
+ const token = tokens[idx];
122
+ const info = (token.info || "").trim();
123
+ const lang = info.split(/\s+/g)[0]?.toLowerCase() || "";
124
+ if (lang === "mermaid") {
125
+ return `<pre class="mermaid">${escapeHtml(token.content || "")}</pre>\n`;
126
+ }
127
+ if (typeof defaultFence === "function") return defaultFence(tokens, idx, options, env, self);
128
+ return self.renderToken(tokens, idx, options);
129
+ };
130
+
131
+ const defaultLinkOpen =
132
+ md.renderer.rules.link_open ||
133
+ function (tokens, idx, options, env, self) {
134
+ return self.renderToken(tokens, idx, options);
135
+ };
136
+ md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
137
+ const token = tokens[idx];
138
+ const hrefIndex = token.attrIndex("href");
139
+ if (hrefIndex >= 0) {
140
+ const href = token.attrs[hrefIndex][1];
141
+ const rewritten = rewriteLinkHref(href, env.baseDirPosix || "");
142
+ token.attrs[hrefIndex][1] = rewritten;
143
+ if (isExternalHref(href)) {
144
+ token.attrSet("target", "_blank");
145
+ token.attrSet("rel", "noreferrer noopener");
146
+ }
147
+ }
148
+ return defaultLinkOpen(tokens, idx, options, env, self);
149
+ };
150
+
151
+ const defaultImage =
152
+ md.renderer.rules.image ||
153
+ function (tokens, idx, options, env, self) {
154
+ return self.renderToken(tokens, idx, options);
155
+ };
156
+ md.renderer.rules.image = function (tokens, idx, options, env, self) {
157
+ const token = tokens[idx];
158
+ const srcIndex = token.attrIndex("src");
159
+ if (srcIndex >= 0) {
160
+ const src = token.attrs[srcIndex][1];
161
+ token.attrs[srcIndex][1] = rewriteImageSrc(src, env.baseDirPosix || "");
162
+ }
163
+ return defaultImage(tokens, idx, options, env, self);
164
+ };
165
+
166
+ const defaultHeadingOpen =
167
+ md.renderer.rules.heading_open ||
168
+ function (tokens, idx, options, env, self) {
169
+ return self.renderToken(tokens, idx, options);
170
+ };
171
+ md.renderer.rules.heading_open = function (tokens, idx, options, env, self) {
172
+ const token = tokens[idx];
173
+ if (token.attrIndex("id") < 0 && token.tag && token.tag.startsWith("h")) {
174
+ const titleToken = tokens[idx + 1];
175
+ const headingText = titleToken?.content || "";
176
+ const slugger = env.__slugger || (env.__slugger = new GithubSlugger());
177
+ const slug = slugger.slug(headingText);
178
+ if (slug) token.attrSet("id", slug);
179
+ }
180
+ const idIndex = token.attrIndex("id");
181
+ const id = idIndex >= 0 ? token.attrs[idIndex][1] : "";
182
+ const rendered = defaultHeadingOpen(tokens, idx, options, env, self);
183
+ if (!id) return rendered;
184
+ return `${rendered}<a class="anchor" aria-hidden="true" href="#${escapeHtml(id)}"></a>`;
185
+ };
186
+
187
+ const alertTypes = new Map([
188
+ ["NOTE", { classSuffix: "note", title: "Note" }],
189
+ ["TIP", { classSuffix: "tip", title: "Tip" }],
190
+ ["IMPORTANT", { classSuffix: "important", title: "Important" }],
191
+ ["WARNING", { classSuffix: "warning", title: "Warning" }],
192
+ ["CAUTION", { classSuffix: "caution", title: "Caution" }],
193
+ ]);
194
+
195
+ md.core.ruler.after("inline", "github-alerts", (state) => {
196
+ const tokens = state.tokens;
197
+ for (let i = 0; i < tokens.length; i++) {
198
+ if (tokens[i].type !== "blockquote_open") continue;
199
+
200
+ let level = 1;
201
+ let closeIndex = -1;
202
+ for (let j = i + 1; j < tokens.length; j++) {
203
+ if (tokens[j].type === "blockquote_open") level++;
204
+ else if (tokens[j].type === "blockquote_close") level--;
205
+ if (level === 0) {
206
+ closeIndex = j;
207
+ break;
208
+ }
209
+ }
210
+ if (closeIndex === -1) continue;
211
+
212
+ const paragraphOpen = tokens[i + 1];
213
+ const inline = tokens[i + 2];
214
+ if (paragraphOpen?.type !== "paragraph_open" || inline?.type !== "inline") continue;
215
+ const children = inline.children || [];
216
+
217
+ const firstTextIndex = children.findIndex(
218
+ (t) => t.type === "text" && /^\s*\[!\w+\]/.test(t.content),
219
+ );
220
+ if (firstTextIndex === -1) continue;
221
+
222
+ const match = children[firstTextIndex].content.match(
223
+ /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i,
224
+ );
225
+ if (!match) continue;
226
+ const typeKey = match[1].toUpperCase();
227
+ const alert = alertTypes.get(typeKey);
228
+ if (!alert) continue;
229
+
230
+ children[firstTextIndex].content = children[firstTextIndex].content.slice(match[0].length);
231
+ if (children[firstTextIndex].content.length === 0) {
232
+ children.splice(firstTextIndex, 1);
233
+ if (children[firstTextIndex]?.type === "softbreak") children.splice(firstTextIndex, 1);
234
+ }
235
+ while (children[0]?.type === "softbreak") children.shift();
236
+
237
+ const open = tokens[i];
238
+ open.type = "html_block";
239
+ open.tag = "";
240
+ open.nesting = 0;
241
+ open.markup = "";
242
+ open.content = `<div class="markdown-alert markdown-alert-${alert.classSuffix}">\n`;
243
+ open.block = true;
244
+
245
+ const close = tokens[closeIndex];
246
+ close.type = "html_block";
247
+ close.tag = "";
248
+ close.nesting = 0;
249
+ close.markup = "";
250
+ close.content = `</div>\n`;
251
+ close.block = true;
252
+
253
+ const titleToken = new state.Token("html_block", "", 0);
254
+ titleToken.content = `<p class="markdown-alert-title">${escapeHtml(alert.title)}</p>\n`;
255
+ titleToken.block = true;
256
+ tokens.splice(i + 1, 0, titleToken);
257
+ i++;
258
+ }
259
+ });
260
+
261
+ function sanitize(html, env) {
262
+ const baseDirPosix = env?.baseDirPosix || "";
263
+ return sanitizeHtml(html, {
264
+ allowedTags: [
265
+ ...sanitizeHtml.defaults.allowedTags,
266
+ "details",
267
+ "summary",
268
+ "img",
269
+ "section",
270
+ "sup",
271
+ "h1",
272
+ "h2",
273
+ "h3",
274
+ "h4",
275
+ "h5",
276
+ "h6",
277
+ "table",
278
+ "thead",
279
+ "tbody",
280
+ "tr",
281
+ "th",
282
+ "td",
283
+ "pre",
284
+ "code",
285
+ "span",
286
+ "div",
287
+ "kbd",
288
+ "input",
289
+ ],
290
+ allowedAttributes: {
291
+ "*": ["class", "id", "aria-label", "aria-hidden", "role"],
292
+ a: ["href", "name", "title", "target", "rel", "tabindex"],
293
+ img: ["src", "alt", "title", "width", "height", "loading"],
294
+ input: ["type", "checked", "disabled"],
295
+ details: ["open"],
296
+ },
297
+ allowedSchemes: ["http", "https", "mailto", "tel", "data"],
298
+ allowProtocolRelative: true,
299
+ transformTags: {
300
+ a: (tagName, attribs) => {
301
+ const next = { ...attribs };
302
+ if (next.href) {
303
+ const originalHref = next.href;
304
+ next.href = rewriteLinkHref(originalHref, baseDirPosix);
305
+ if (isExternalHref(originalHref)) {
306
+ next.target = "_blank";
307
+ next.rel = "noreferrer noopener";
308
+ }
309
+ }
310
+ return { tagName, attribs: next };
311
+ },
312
+ img: (tagName, attribs) => {
313
+ const next = { ...attribs };
314
+ if (next.src) next.src = rewriteImageSrc(next.src, baseDirPosix);
315
+ if (!next.loading) next.loading = "lazy";
316
+ return { tagName, attribs: next };
317
+ },
318
+ input: (tagName, attribs) => {
319
+ const next = { ...attribs };
320
+ if (next.type === "checkbox") next.disabled = "disabled";
321
+ return { tagName, attribs: next };
322
+ },
323
+ },
324
+ });
325
+ }
326
+
327
+ return {
328
+ render(markdown, env) {
329
+ const e = env ?? {};
330
+ const html = md.render(markdown ?? "", e);
331
+ return sanitize(html, e);
332
+ },
333
+ renderCodeBlock(text, { languageHint } = {}) {
334
+ const lang = languageHint && hljs.getLanguage(languageHint) ? languageHint : "";
335
+ const highlighted = lang
336
+ ? hljs.highlight(text, { language: lang }).value
337
+ : hljs.highlightAuto(text).value;
338
+ return sanitize(
339
+ `<pre class="hljs"><code>${highlighted || escapeHtml(text)}</code></pre>`,
340
+ { baseDirPosix: "" },
341
+ );
342
+ },
343
+ };
344
+ }