diff options
| -rw-r--r-- | astro.config.ts | 2 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 9 | ||||
| -rw-r--r-- | src/loaders/pleroma.ts | 22 | ||||
| -rw-r--r-- | src/plugins/rehype-image-captions.ts | 78 | ||||
| -rw-r--r-- | src/utils/markdown.ts | 21 |
6 files changed, 127 insertions, 8 deletions
diff --git a/astro.config.ts b/astro.config.ts index 792a5f2..335e744 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -15,6 +15,7 @@ import rehypeUnwrapImages from "rehype-unwrap-images"; // Remark plugins import remarkDirective from "remark-directive"; /* Handle ::: directives as nodes */ import robotsConfig from "./robots-txt.config"; +import { rehypeImageCaptions } from "./src/plugins/rehype-image-captions"; /* Add image captions on hover */ import { remarkAdmonitions } from "./src/plugins/remark-admonitions"; /* Add admonitions */ import { expressiveCodeOptions, siteConfig } from "./src/site.config"; @@ -64,6 +65,7 @@ export default defineConfig({ ], markdown: { rehypePlugins: [ + rehypeImageCaptions, rehypeHeadingIds, [rehypeAutolinkHeadings, { behavior: "wrap", properties: { className: ["not-prose"] } }], [ diff --git a/package.json b/package.json index b1fdb22..8f7a4d2 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,11 @@ "medium-zoom": "^1.1.0", "rehype-autolink-headings": "^7.1.0", "rehype-external-links": "^3.0.0", + "rehype-stringify": "^10.0.1", "rehype-unwrap-images": "^1.0.0", "remark-directive": "^4.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", "sanitize-html": "^2.17.0", "satori": "0.15.2", "satori-html": "^0.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e47431..b196d43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,12 +68,21 @@ importers: rehype-external-links: specifier: ^3.0.0 version: 3.0.0 + rehype-stringify: + specifier: ^10.0.1 + version: 10.0.1 rehype-unwrap-images: specifier: ^1.0.0 version: 1.0.0 remark-directive: specifier: ^4.0.0 version: 4.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-rehype: + specifier: ^11.1.2 + version: 11.1.2 sanitize-html: specifier: ^2.17.0 version: 2.17.0 diff --git a/src/loaders/pleroma.ts b/src/loaders/pleroma.ts index d633aa0..766e9ee 100644 --- a/src/loaders/pleroma.ts +++ b/src/loaders/pleroma.ts @@ -531,6 +531,18 @@ function buildImageGridHtml(attachments: PleromaMediaAttachment[]): string { if (count === 1 && imageAttachments[0]) { const attachment = imageAttachments[0]; const description = attachment.description || "Image"; + // Only wrap with figcaption if we have descriptive alt text + const hasDescriptiveAlt = + description && description.trim() && description.toLowerCase() !== "image"; + + if (hasDescriptiveAlt) { + return ` +<figure class="image-with-alt group relative mt-4 mb-4"> +<img src="${attachment.url}" alt="${description}" class="w-full cursor-zoom-in rounded-lg border border-gray-200 object-cover transition-colors hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600" loading="lazy" data-zoomable /> +<figcaption class="pointer-events-none absolute bottom-0 left-0 right-0 rounded-b-lg bg-black/70 px-3 py-2 text-sm text-white opacity-0 transition-opacity group-hover:opacity-100">${description}</figcaption> +</figure>`; + } + return ` <div class="mt-4 mb-4"> <img src="${attachment.url}" alt="${description}" class="w-full cursor-zoom-in rounded-lg border border-gray-200 object-cover transition-colors hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600" loading="lazy" data-zoomable /> @@ -542,9 +554,19 @@ function buildImageGridHtml(attachments: PleromaMediaAttachment[]): string { const images = imageAttachments .map((attachment, index) => { const description = attachment.description || "Image"; + const hasDescriptiveAlt = + description && description.trim() && description.toLowerCase() !== "image"; // For odd count, the last image spans all columns const isLastImage = index === count - 1; const spanClass = isOdd && isLastImage ? "sm:col-span-2" : ""; + + if (hasDescriptiveAlt) { + return `<figure class="image-with-alt group relative h-fit ${spanClass}"> +<img src="${attachment.url}" alt="${description}" class="w-full max-h-96 object-cover cursor-zoom-in rounded-lg border border-gray-200 transition-colors hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600" loading="lazy" data-zoomable /> +<figcaption class="pointer-events-none absolute bottom-0 left-0 right-0 rounded-b-lg bg-black/70 px-3 py-2 text-sm text-white opacity-0 transition-opacity group-hover:opacity-100">${description}</figcaption> +</figure>`; + } + return `<img src="${attachment.url}" alt="${description}" class="w-full max-h-96 object-cover cursor-zoom-in rounded-lg border border-gray-200 transition-colors hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600 ${spanClass}" loading="lazy" data-zoomable />`; }) .join("\n"); diff --git a/src/plugins/rehype-image-captions.ts b/src/plugins/rehype-image-captions.ts new file mode 100644 index 0000000..61bdd84 --- /dev/null +++ b/src/plugins/rehype-image-captions.ts @@ -0,0 +1,78 @@ +import type { Element, Root } from "hast"; +import { h } from "hastscript"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; + +/** + * Rehype plugin that wraps images with figure/figcaption to display alt text on hover. + * Skips images without alt text or with generic alt text like "Image". + */ +export const rehypeImageCaptions: Plugin<[], Root> = () => { + return (tree: Root) => { + visit(tree, "element", (node: Element, index, parent) => { + // Only process img elements that have a parent and an index + if (node.tagName !== "img" || !parent || index === undefined) { + return; + } + + // Get the alt text + const alt = node.properties?.alt; + if (!alt || typeof alt !== "string") { + return; + } + + // Skip generic or empty alt text + const trimmedAlt = alt.trim(); + if (!trimmedAlt || trimmedAlt.toLowerCase() === "image") { + return; + } + + // Preserve all existing properties including data-zoomable for medium-zoom + const imgProperties = { ...node.properties }; + + // Create the figcaption element with tailwind classes for hover overlay + const figcaption = h( + "figcaption", + { + class: [ + "pointer-events-none", + "absolute", + "bottom-0", + "left-0", + "right-0", + "rounded-b-lg", + "bg-black/70", + "px-3", + "py-2", + "text-sm", + "text-white", + "opacity-0", + "transition-opacity", + "group-hover:opacity-100", + ].join(" "), + }, + [{ type: "text", value: trimmedAlt }], + ); + + // Create the figure wrapper with group class for hover trigger + const figure = h( + "figure", + { + class: "image-with-alt group relative", + }, + [ + { + type: "element", + tagName: "img", + properties: imgProperties, + children: [], + }, + figcaption, + ], + ); + + // Replace the img with the figure in the parent + parent.children[index] = figure; + }); + }; +}; diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts index 946d8d8..eaac6e7 100644 --- a/src/utils/markdown.ts +++ b/src/utils/markdown.ts @@ -1,11 +1,16 @@ -import { marked } from "marked"; - -marked.setOptions({ - breaks: true, - gfm: true, -}); +import rehypeStringify from "rehype-stringify"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import { unified } from "unified"; +import { rehypeImageCaptions } from "@/plugins/rehype-image-captions"; export function markdownToHtml(markdown: string): string { - const html = marked.parse(markdown); - return typeof html === "string" ? html : ""; + const result = unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeImageCaptions) + .use(rehypeStringify, { allowDangerousHtml: true }) + .processSync(markdown); + + return String(result); } |
