diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0f29278102a..753d410c5b6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,12 @@ importers: hast-util-to-html: specifier: ^8.0.4 version: 8.0.4 + hastscript: + specifier: ^8.0.0 + version: 8.0.0 + html-escaper: + specifier: ^3.0.3 + version: 3.0.3 lang-rome-formatter-ir: specifier: 0.0.2 version: 0.0.2 diff --git a/website/astro.config.ts b/website/astro.config.ts index 05dc40d48346..981acec25146 100644 --- a/website/astro.config.ts +++ b/website/astro.config.ts @@ -1,104 +1,40 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import { netlifyStatic } from "@astrojs/netlify"; import react from "@astrojs/react"; import starlight from "@astrojs/starlight"; import { defineConfig } from "astro/config"; +import { h } from "hastscript"; +import { escape as htmlEscape } from "html-escaper"; import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeSlug from "rehype-slug"; import remarkToc from "remark-toc"; -function resolveFile(relative: string, parent: string, root: string): string { - if (relative[0] === "/") { - return `${root}${relative}`; - } - return path.resolve(path.join(parent, relative)); -} - -const IMPORT_REGEX = /^import"(.*?)";?$/; - -async function readFile( - loc: string, - root: string, - cache: Files, -): Promise { - let content = cache.get(loc); - if (content === undefined) { - content = await fs.readFile(loc, "utf8"); - content = content.trim(); - cache.set(loc, content); - } - - const importMatch = content.match(IMPORT_REGEX); - if (importMatch != null) { - return readFile( - resolveFile(importMatch[1], path.dirname(loc), root), - root, - cache, - ); - } - - return content; -} - -type Files = Map; - -async function inline({ - files, - root, - replacements, -}: { - files: Files; - root: string; - replacements: { - regex: RegExp; - tagBefore: string; - tagAfter: string; - }[]; -}): Promise { - const cache: Files = new Map(); - - await Promise.all( - Array.from(files.entries(), async ([htmlPath, file]) => { - if (htmlPath.includes("playground")) { - return; - } - - const matches: { - key: string; - match: string; - tagBefore: string; - tagAfter: string; - }[] = []; - - for (const { regex, tagBefore, tagAfter } of replacements) { - file = file.replace(regex, (match, p1) => { - const key = `{{INLINE:${matches.length - 1}}}`; - matches.push({ key, match: p1, tagBefore, tagAfter }); - return key; - }); - } - - const sources: string[] = await Promise.all( - matches.map(async ({ match }) => { - const resolvedPath = resolveFile(match, path.dirname(htmlPath), root); - return await readFile(resolvedPath, root, cache); - }), - ); - - for (let i = 0; i < matches.length; i++) { - const { key, tagBefore, tagAfter } = matches[i]; - const source = sources[i]; - const index = file.indexOf(key); - const start = file.slice(0, index); - const end = file.slice(index + key.length); - file = `${start}${tagBefore}${source}${tagAfter}${end}`; - } - - files.set(htmlPath, file); +const anchorLinkIcon = h( + "span", + { ariaHidden: "true", class: "anchor-icon" }, + h( + "svg", + { width: 16, height: 16, viewBox: "0 0 24 24" }, + h("path", { + fill: "currentcolor", + d: "m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 0 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 1 0-1.42-1.42Zm8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 0 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 1 0 1.42 1.42l3.88-3.89a4.49 4.49 0 0 0 0-6.33ZM8.83 15.17a1 1 0 0 0 1.1.22 1 1 0 0 0 .32-.22l4.92-4.92a1 1 0 0 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42Z", }), + ), +); + +const anchorLinkSRLabel = (text: string) => + h( + "span", + { "is:raw": true, class: "sr-only" }, + `Section titled ${htmlEscape(text)}`, ); -} + +const autolinkConfig = { + properties: { class: "anchor-link" }, + behavior: "after", + group: ({ tagName }) => + h("div", { tabIndex: -1, class: `heading-wrapper level-${tagName}` }), + content: ({ heading }) => [anchorLinkIcon, anchorLinkSRLabel("test")], +}; const site = "https://biomejs.dev"; // https://astro.build/config @@ -256,16 +192,7 @@ export default defineConfig({ markdown: { syntaxHighlight: "prism", remarkPlugins: [remarkToc], - rehypePlugins: [ - rehypeSlug, - [ - rehypeAutolinkHeadings, - { - behavior: "append", - content: [], - }, - ], - ], + rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, autolinkConfig]], }, adapter: netlifyStatic(), diff --git a/website/package.json b/website/package.json index b7efce2d4323..aca353c1c06c 100644 --- a/website/package.json +++ b/website/package.json @@ -39,6 +39,8 @@ "codemirror-lang-rome-ast": "0.0.6", "fast-diff": "^1.3.0", "hast-util-to-html": "^8.0.4", + "hastscript": "^8.0.0", + "html-escaper": "^3.0.3", "lang-rome-formatter-ir": "0.0.2", "mdast-util-to-hast": "^12.3.0", "mermaid": "^9.4.3", diff --git a/website/src/styles/_markdown.scss b/website/src/styles/_markdown.scss new file mode 100644 index 000000000000..3c3bd4e602bf --- /dev/null +++ b/website/src/styles/_markdown.scss @@ -0,0 +1,72 @@ +/* Heading anchor link styles */ +.sl-markdown-content .heading-wrapper { + --icon-size: 0.75em; + --icon-spacing: 0.25em; + line-height: var(--sl-line-height-headings); +} + +/* Set font-size on wrapper element, so line-height, margins etc. match heading size. */ +.sl-markdown-content { + .level-h2 { + font-size: var(--sl-text-h2); + } + + .level-h3 { + font-size: var(--sl-text-h3); + } + + .level-h4 { + font-size: var(--sl-text-h4); + } + + .level-h5 { + font-size: var(--sl-text-h5); + } +} + +.sl-markdown-content .heading-wrapper> :first-child { + margin-inline-end: calc(var(--icon-size) + var(--icon-spacing)); + display: inline; +} + +.sl-markdown-content .heading-wrapper svg { + display: inline; + width: var(--icon-size); +} + +.sl-markdown-content .anchor-link { + margin-inline-start: calc(-1 * (var(--icon-size))); + color: var(--sl-color-gray-3); + + &:hover, + &:focus { + color: var(--sl-color-text-accent); + } +} + +@media (hover: hover) { + .sl-markdown-content .anchor-link { + opacity: 0; + } +} + +.sl-markdown-content .heading-wrapper:hover>.anchor-link, +.sl-markdown-content .anchor-link:focus { + opacity: 1; +} + +/* Float anchor links to the left of headings on larger screens. */ +@media (min-width: 95em) { + .sl-markdown-content .heading-wrapper { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + gap: var(--icon-spacing); + margin-inline-start: calc(-1 * (var(--icon-size) + var(--icon-spacing))); + } + + .sl-markdown-content .heading-wrapper> :first-child, + .sl-markdown-content .anchor-link { + margin: 0; + } +} \ No newline at end of file diff --git a/website/src/styles/index.scss b/website/src/styles/index.scss index bf4fc7fb21c9..1933625b151d 100644 --- a/website/src/styles/index.scss +++ b/website/src/styles/index.scss @@ -5,3 +5,4 @@ @import "_credits"; @import "_pre"; @import "_sponsors"; +@import "_markdown";