From cf90ba0b77a0a59b8f059e96884a5be39e02dd18 Mon Sep 17 00:00:00 2001 From: Aaron Pham Date: Wed, 2 Oct 2024 08:31:28 -0400 Subject: [PATCH] feat(docs): support GitHub mentions rendering (#77) Signed-off-by: Aaron Pham Signed-off-by: Aaron Pham --- .gitignore | 12 - docs/package.json | 10 +- docs/quartz.config.ts | 1 + docs/quartz/cli/handlers.js | 9 + docs/quartz/components/styles/toc.scss | 1 + docs/quartz/plugins/transformers/github.ts | 389 +++++++++++++++++++++ docs/quartz/plugins/transformers/index.ts | 1 + docs/quartz/styles/base.scss | 12 +- docs/quartz/styles/variables.scss | 24 +- packages/morph/.gitignore | 24 ++ packages/morph/public/vite.svg | 1 + 11 files changed, 449 insertions(+), 35 deletions(-) create mode 100644 docs/quartz/plugins/transformers/github.ts create mode 100644 packages/morph/.gitignore create mode 100644 packages/morph/public/vite.svg diff --git a/.gitignore b/.gitignore index 7fd3da7..d0ec08c 100644 --- a/.gitignore +++ b/.gitignore @@ -166,15 +166,3 @@ venv/ _version.py target .env - -.DS_Store -.gitignore -node_modules -public -prof -tsconfig.tsbuildinfo -.obsidian -.quartz-cache -private/ -.replit -replit.nix diff --git a/docs/package.json b/docs/package.json index 7a17ec0..ae57872 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,15 +1,15 @@ { - "name": "@jackyzha0/quartz", - "description": "🌱 publish your digital garden and notes as a website", + "name": "@aarnphm/tinymorph", + "description": "Documentation for tinymorph", "private": true, "version": "4.4.0", "type": "module", - "author": "jackyzha0 ", + "author": "aarnphm ", "license": "MIT", - "homepage": "https://quartz.jzhao.xyz", + "homepage": "https://tinymorph.aarnphm.xyz", "repository": { "type": "git", - "url": "https://github.com/jackyzha0/quartz.git" + "url": "https://github.com/aarnphm/tinymorph.git" }, "scripts": { "quartz": "./quartz/bootstrap-cli.mjs", diff --git a/docs/quartz.config.ts b/docs/quartz.config.ts index 3753b13..18d9ed9 100644 --- a/docs/quartz.config.ts +++ b/docs/quartz.config.ts @@ -74,6 +74,7 @@ const config: QuartzConfig = { }), Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false, enableCheckbox: true }), Plugin.GitHubFlavoredMarkdown(), + Plugin.GitHub(), Plugin.TableOfContents(), Plugin.CrawlLinks({ markdownLinkResolution: "absolute", diff --git a/docs/quartz/cli/handlers.js b/docs/quartz/cli/handlers.js index 12e7e8e..0c3fb41 100644 --- a/docs/quartz/cli/handlers.js +++ b/docs/quartz/cli/handlers.js @@ -350,6 +350,15 @@ export async function handleBuild(argv) { source: "**/*.*", headers: [{ key: "Content-Disposition", value: "inline" }], }, + { + source: "**/*.webp", + headers: [{ key: "Content-Type", value: "image/webp" }], + }, + // fixes bug where avif images are displayed as text instead of images (future proof) + { + source: "**/*.avif", + headers: [{ key: "Content-Type", value: "image/avif" }], + }, ], }) const status = res.statusCode diff --git a/docs/quartz/components/styles/toc.scss b/docs/quartz/components/styles/toc.scss index 512b01d..3b2b6b3 100644 --- a/docs/quartz/components/styles/toc.scss +++ b/docs/quartz/components/styles/toc.scss @@ -74,6 +74,7 @@ button#toc { } > ul.overflow { max-height: none; + width: 100%; } @for $i from 0 through 6 { diff --git a/docs/quartz/plugins/transformers/github.ts b/docs/quartz/plugins/transformers/github.ts new file mode 100644 index 0000000..5b448b6 --- /dev/null +++ b/docs/quartz/plugins/transformers/github.ts @@ -0,0 +1,389 @@ +// modified version of https://github.com/remarkjs/remark-github/blob/main/index.js +// main change: Update mentionRegex to work with rehype-citation +import fs from "node:fs" +import path from "node:path" + +import { BuildUrlValues, defaultBuildUrl } from "remark-github" +import { RepositoryInfo, UrlInfo } from "remark-github/lib" +import { Root, Link } from "mdast" +import { + RegExpMatchObject, + ReplaceFunction, + findAndReplace as mdastFindReplace, +} from "mdast-util-find-and-replace" +import { toString } from "mdast-util-to-string" +import { QuartzTransformerPlugin } from "../types" +import { visit } from "unist-util-visit" +import { PhrasingContent } from "mdast-util-find-and-replace/lib" + +// Previously, GitHub linked `@mention` and `@mentions` to their blog post about +// mentions (). +// Since June 2019, and possibly earlier, they stopped linking those references. +const denyMention = new Set(["mention", "mentions"]) +// Denylist of SHAs that are also valid words. +// +// GitHub allows abbreviating SHAs up to 7 characters. +// These cases are ignored in text because they might just be ment as normal +// words. +// If you’d like these to link to their SHAs, use more than 7 characters. +// +// Generated by: +// +// ```sh +// egrep -i "^[a-f0-9]{7,}$" /usr/share/dict/words +// ``` +// +// Added a couple forms of 6 character words in GH-20: +// . +const denyHash = new Set(["acceded", "deedeed", "defaced", "effaced", "fabaceae"]) +// Constants. +const minShaLength = 7 +// Username may only contain alphanumeric characters or single hyphens, and +// cannot begin or end with a hyphen*. +// +// \* That is: until . +const userGroup = "[\\da-z][-\\da-z]{0,38}" +const projectGroup = "(?:\\.git[\\w-]|\\.(?!git)|[\\w-])+" +const repoGroup = "(" + userGroup + ")\\/(" + projectGroup + ")" +const linkRegex = new RegExp( + "^https?:\\/\\/github\\.com\\/" + + repoGroup + + "\\/(commit|compare|issues|pull)\\/([a-f\\d]+(?:\\.{3}[a-f\\d]+)?\\/?(?=[#?]|$))", + "i", +) +const repoRegex = new RegExp("(?:^|/(?:repos/)?)" + repoGroup + "(?=\\.git|[\\/#@]|$)", "i") +const referenceRegex = new RegExp( + "(" + userGroup + ")(?:\\/(" + projectGroup + "))?(?:#([1-9]\\d*)|@([a-f\\d]{7,40}))", + "gi", +) +const mentionRegex = new RegExp("(? string + mentionStrong?: boolean +} + +const defaultOptions: Options = { + repository: "https://github.com/aarnphm/aarnphm.github.io", + buildUrl: defaultBuildUrl, + mentionStrong: true, +} + +export const GitHub: QuartzTransformerPlugin> = (userOpts) => { + const opts = { ...defaultOptions, ...userOpts } + const buildUrl = opts.buildUrl || defaultBuildUrl + + return { + name: "GitHub", + markdownPlugins() { + return [ + () => { + return (tree: Root, file) => { + const repository = opts.repository || getRepoFromPackage(file.cwd) + + if (!repository) { + throw new Error("Unexpected missing `repository` in `options`") + } + + // Parse the URL: See the tests for all possible kinds. + const repositoryMatch = repoRegex.exec(repository) + + if (!repositoryMatch) { + throw new Error( + "Unexpected invalid `repository`, expected for example `user/project`", + ) + } + + const repositoryInfo: RepositoryInfo = { + project: repositoryMatch[2], + user: repositoryMatch[1], + } + mdastFindReplace( + tree, + [ + [referenceRegex, replaceReference], + [mentionRegex, replaceMention], + [/(?:#|\bgh-)([1-9]\d*)/gi, replaceIssue], + [/\b([a-f\d]{7,40})\.{3}([a-f\d]{7,40})\b/gi, replaceHashRange], + [/\b[a-f\d]{7,40}\b/gi, replaceHash], + ], + { ignore: ["link", "linkReference"] }, + ) + + visit(tree, "link", function (node) { + const link = parse(node) + + if (!link) { + return + } + + const comment = link.comment ? " (comment)" : "" + let base: string + + if ( + link.project !== repositoryInfo.project || + // Compare page uses full `user/project` for forks. + (link.page === "compare" && link.user !== repositoryInfo.user) + ) { + base = link.user + "/" + link.project + } else if (link.user === repositoryInfo.user) { + base = "" + } else { + base = link.user + } + + const children: PhrasingContent[] = [] + + if (link.page === "issues" || link.page === "pull") { + base += "#" + children.push({ + type: "text", + value: base + link.reference + comment, + }) + } else { + if (base) { + children.push({ type: "text", value: base + "@" }) + } + + children.push({ type: "inlineCode", value: link.reference }) + + if (link.comment) { + children.push({ type: "text", value: comment }) + } + } + + node.children = children + }) + + /** + * @type {ReplaceFunction} + */ + function replaceMention(value: string, username: string, match: RegExpMatchObject) { + if (match === undefined) return false + if ( + /[\w`]/.test(match.input.charAt(match.index - 1)) || + /[/\w`]/.test(match.input.charAt(match.index + value.length)) || + denyMention.has(username) + ) { + return false + } + + const url = buildUrl({ type: "mention", user: username }) + + if (!url) return false + + let node: PhrasingContent = { type: "text", value } + + if (opts.mentionStrong !== false) { + node = { type: "strong", children: [node] } + } + + return { type: "link", title: null, url, children: [node] } + } + + /** + * @type {ReplaceFunction} + */ + function replaceIssue(value: string, no: string, match: RegExpMatchObject) { + if ( + /\w/.test(match.input.charAt(match.index - 1)) || + /\w/.test(match.input.charAt(match.index + value.length)) + ) { + return false + } + + const url = buildUrl({ no, type: "issue", ...repositoryInfo }) + + return url + ? { type: "link", title: null, url, children: [{ type: "text", value }] } + : false + } + + /** + * @type {ReplaceFunction} + */ + function replaceHashRange( + value: string, + a: string, + b: string, + match: RegExpMatchObject, + ) { + if ( + /[^\t\n\r (@[{]/.test(match.input.charAt(match.index - 1)) || + /\w/.test(match.input.charAt(match.index + value.length)) || + denyHash.has(value) + ) { + return false + } + + const url = buildUrl({ + base: a, + compare: b, + type: "compare", + ...repositoryInfo, + }) + + return url + ? { + type: "link", + title: null, + url, + children: [{ type: "inlineCode", value: abbr(a) + "..." + abbr(b) }], + } + : false + } + + /** + * @type {ReplaceFunction} + */ + function replaceHash(value: string, match: RegExpMatchObject) { + if ( + /[^\t\n\r (@[{.]/.test(match.input.charAt(match.index - 1)) || + // For some weird reason GH does link two dots, but not one 🤷‍♂️ + (match.input.charAt(match.index - 1) === "." && + match.input.charAt(match.index - 2) !== ".") || + /\w/.test(match.input.charAt(match.index + value.length)) || + denyHash.has(value) + ) { + return false + } + + const url = buildUrl({ hash: value, type: "commit", ...repositoryInfo }) + + return url + ? { + type: "link", + title: null, + url, + children: [{ type: "inlineCode", value: abbr(value) }], + } + : false + } + + /** + * @type {ReplaceFunction} + */ + function replaceReference( + $0: string, + user: string, + specificProject: string, + no: string, + hash: string, + match: RegExpMatchObject, + ) { + if ( + /[^\t\n\r (@[{]/.test(match.input.charAt(match.index - 1)) || + /\w/.test(match.input.charAt(match.index + $0.length)) + ) { + return false + } + + const project = specificProject || repositoryInfo.project + const values: BuildUrlValues = no + ? { no, project, type: "issue", user } + : { hash, project, type: "commit", user } + const url = buildUrl(values) + + if (!url) return false + + const nodes: PhrasingContent[] = [] + let value = "" + + if (project !== repositoryInfo.project) { + value += user + "/" + project + } else if (user !== repositoryInfo.user) { + value += user + } + + if (no) { + value += "#" + no + } else { + value += "@" + nodes.push({ type: "inlineCode", value: abbr(hash) }) + } + + nodes.unshift({ type: "text", value }) + + return { type: "link", title: null, url, children: nodes } + } + } + }, + ] + }, + } +} + +/** + * Abbreviate a SHA. + * + * @param {string} sha + * SHA. + * @returns {string} + * Abbreivated SHA. + */ +function abbr(sha: string): string { + return sha.slice(0, minShaLength) +} + +/** + * Parse a link and determine whether it links to GitHub. + */ +function parse(node: Link): UrlInfo | undefined { + const match = linkRegex.exec(node.url) + + if ( + // Not a proper URL. + !match || + // Looks like formatting. + node.children.length !== 1 || + node.children[0].type !== "text" || + toString(node) !== node.url || + // SHAs can be min 4, max 40 characters. + (match[3] === "commit" && (match[4].length < 4 || match[4].length > 40)) || + // SHAs can be min 4, max 40 characters. + (match[3] === "compare" && !/^[a-f\d]{4,40}\.{3}[a-f\d]{4,40}$/.test(match[4])) || + // Issues / PRs are decimal only. + ((match[3] === "issues" || match[3] === "pull") && /[a-f]/i.test(match[4])) || + // Projects can be at most 99 characters. + match[2].length >= 100 + ) { + return + } + + let reference = match[4] + + if (match[3] === "compare") { + const [base, compare] = reference.split("...") + reference = abbr(base) + "..." + abbr(compare) + } else { + reference = abbr(reference) + } + + return { + comment: node.url.charAt(match[0].length) === "#" && match[0].length + 1 < node.url.length, + page: match[3], + project: match[2], + reference, + user: match[1], + } +} diff --git a/docs/quartz/plugins/transformers/index.ts b/docs/quartz/plugins/transformers/index.ts index ff2c296..d1d4e38 100644 --- a/docs/quartz/plugins/transformers/index.ts +++ b/docs/quartz/plugins/transformers/index.ts @@ -1,4 +1,5 @@ export { Twitter } from "./twitter" +export { GitHub } from "./github" export { FrontMatter } from "./frontmatter" export { GitHubFlavoredMarkdown } from "./gfm" export { Citations } from "./citations" diff --git a/docs/quartz/styles/base.scss b/docs/quartz/styles/base.scss index 9cf8cc9..61c918f 100644 --- a/docs/quartz/styles/base.scss +++ b/docs/quartz/styles/base.scss @@ -190,7 +190,7 @@ a { & .sidebar.left { z-index: 1; - grid-area: sidebar-left; + grid-area: grid-sidebar-left; flex-direction: column; @media all and ($mobile) { gap: 0; @@ -205,7 +205,7 @@ a { } & .sidebar.right { - grid-area: sidebar-right; + grid-area: grid-sidebar-right; margin-right: 0; flex-direction: column; @media all and ($mobile) { @@ -232,7 +232,7 @@ a { } & .page-header { - grid-area: page-header; + grid-area: grid-header; margin: $topSpacing 0 0 0; @media all and ($mobile) { margin-top: 0; @@ -241,11 +241,11 @@ a { } & .center > article { - grid-area: page-center; + grid-area: grid-center; } - & .page-footer { - grid-area: page-footer; + & footer { + grid-area: grid-footer; } & .center, diff --git a/docs/quartz/styles/variables.scss b/docs/quartz/styles/variables.scss index 3ac5a8b..9335e55 100644 --- a/docs/quartz/styles/variables.scss +++ b/docs/quartz/styles/variables.scss @@ -27,11 +27,11 @@ $mobileGrid: ( rowGap: "5px", columnGap: "5px", templateAreas: - '"sidebar-left"\ - "page-header"\ - "page-center"\ - "sidebar-right"\ - "page-footer"', + '"grid-sidebar-left"\ + "grid-header"\ + "grid-center"\ + "grid-sidebar-right"\ + "grid-footer"', ); $tabletGrid: ( templateRows: "auto auto auto auto", @@ -39,10 +39,10 @@ $tabletGrid: ( rowGap: "5px", columnGap: "5px", templateAreas: - '"sidebar-left page-header"\ - "sidebar-left page-center"\ - "sidebar-left sidebar-right"\ - "sidebar-left page-footer"', + '"grid-sidebar-left grid-header"\ + "grid-sidebar-left grid-center"\ + "grid-sidebar-left grid-sidebar-right"\ + "grid-sidebar-left grid-footer"', ); $desktopGrid: ( templateRows: "auto auto auto", @@ -50,7 +50,7 @@ $desktopGrid: ( rowGap: "5px", columnGap: "5px", templateAreas: - '"sidebar-left page-header sidebar-right"\ - "sidebar-left page-center sidebar-right"\ - "sidebar-left page-footer sidebar-right"', + '"grid-sidebar-left grid-header grid-sidebar-right"\ + "grid-sidebar-left grid-center grid-sidebar-right"\ + "grid-sidebar-left grid-footer grid-sidebar-right"', ); diff --git a/packages/morph/.gitignore b/packages/morph/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/packages/morph/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/morph/public/vite.svg b/packages/morph/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/packages/morph/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file