Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions packages/jaamd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ Node, a CommonJS build, or a TypeScript project using
- `markdown.remarkPlugins` and `markdown.rehypePlugins` still work: Astro moves
to unified, jaamd adds its remark plugins.
- Astro `^7.3.0` is required.
- Heading anchors are plain links: a click navigates, nothing is copied.
- Table wrappers and heading anchors are in the built HTML. Turn them off with
`plugins: { tables: false, headingLinks: false }`.
- The lightbox is a `<dialog class="jaamd-lightbox">`. `.jaamd-lightbox__backdrop`,
`.jaamd-lightbox__content` and `.jaamd-lightbox--hidden` are gone: style
`::backdrop` and `[open]`.
- `<details>` animates in CSS. `.jaamd-details-wrapper` and
`.jaamd-details-inner` are gone.

## Setup

Expand Down Expand Up @@ -115,6 +123,8 @@ jaamd({
toc: true, // :::toc directive blocks
alerts: true, // > [!NOTE] / [!WARNING] blockquote alerts
directive: true, // `:::` syntax (prerequisite for codeTabs and toc)
tables: true, // scroll container around tables
headingLinks: true, // anchor link on every heading
},
})
```
Expand Down Expand Up @@ -274,19 +284,22 @@ import MarkdownContent from "@lancher-dev/jaamd/components";

## Client-side Enhancements

A dependency-free ES module (~2 kB gzipped) enhances the rendered markdown. It
re-runs on every `astro:page-load`, so it keeps working across View Transitions.
A dependency-free ES module enhances the rendered markdown. Events are
delegated on `document`; per-element setup re-runs on every `astro:page-load`,
so it keeps working across View Transitions.

Table wrappers and heading anchors come from the build. The module adds them
only to markup that skipped the processor (`set:html`, HTML from a CMS).

| Enhancement | Behaviour |
|---|---|
| Heading links | Adds an anchor to `h1`–`h6`; clicking copies the section URL. Fills in a missing `id` with a `github-slugger`-compatible slug, de-duplicated across the page. |
| Heading links | Adds an anchor link to `h1`–`h6` where the build did not. Fills in a missing `id` with a `github-slugger`-compatible slug, de-duplicated across the page. |
| Table of contents | Marks the `:::toc` entry whose section is on screen with `aria-current="location"`. |
| Copy buttons | Adds a copy button to every `pre`. |
| Image lightbox | Click an image to open it full-screen. Closes on backdrop click, the ✕ button or <kbd>Esc</kbd>. |
| Responsive tables | Wraps every `table` in a horizontally scrollable container. |
| Copy buttons | Adds a copy button to every `pre`. Without clipboard access it selects the code instead. The only clipboard write in the package. |
| Image lightbox | Click an image to open it in a modal `<dialog>`. Closes on a click outside the image, the ✕ button or <kbd>Esc</kbd>. |
| Responsive tables | Wraps a `table` in a horizontally scrollable container where the build did not. |
| Code tabs | Drives the `:::code-tabs` tablist. |
| Spoilers | Reveals `.spoiler` content on click or <kbd>Enter</kbd>/<kbd>Space</kbd>. |
| Details | Animates the height of `<details>` on open/close. |

### Keyboard

Expand All @@ -296,8 +309,13 @@ re-runs on every `astro:page-load`, so it keeps working across View Transitions.
| Spoilers | <kbd>Tab</kbd> to focus, <kbd>Enter</kbd> or <kbd>Space</kbd> to reveal. Hover does not reveal. |
| Lightbox | <kbd>Esc</kbd> to close. |

Under `prefers-reduced-motion: reduce` the `<details>` animation is skipped and
JAAMD's CSS transitions are disabled.
`<details>` animates in CSS, no script: height where the browser supports
`interpolate-size`, a fade elsewhere.

Under `prefers-reduced-motion: reduce` JAAMD's CSS transitions are disabled.

With scripting disabled, tables still scroll, heading anchors work and every
code tab panel is shown.

### Excluding images from the lightbox

Expand Down Expand Up @@ -483,13 +501,16 @@ import {
satteriCodeTabs,
satteriToc,
satteriDirectiveFallback,
satteriTableWrapper,
satteriHeadingLinks,
} from "@lancher-dev/jaamd";

export default defineConfig({
markdown: {
processor: satteri({
features: { directive: true },
mdastPlugins: [satteriAlert, satteriCodeTabs, satteriToc, satteriDirectiveFallback],
hastPlugins: [satteriTableWrapper, satteriHeadingLinks],
}),
},
});
Expand All @@ -503,12 +524,20 @@ On unified:
```ts
// astro.config.mjs
import { unified } from "@astrojs/markdown-remark";
import { remarkAlert, remarkCodeTabs, remarkDirective, remarkToc } from "@lancher-dev/jaamd";
import {
remarkAlert,
remarkCodeTabs,
remarkDirective,
remarkToc,
rehypeTableWrapper,
rehypeHeadingLinks,
} from "@lancher-dev/jaamd";

export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkAlert, remarkDirective, remarkCodeTabs, remarkToc],
rehypePlugins: [rehypeTableWrapper, rehypeHeadingLinks],
}),
},
});
Expand Down
51 changes: 47 additions & 4 deletions packages/jaamd/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import remarkDirective from "remark-directive";
import { remarkAlert } from "./src/plugins/remark-alert.js";
import remarkCodeTabs from "./src/plugins/remark-code-tabs.js";
import remarkToc from "./src/plugins/remark-toc.js";
import { rehypeHeadingLinks, rehypeTableWrapper } from "./src/plugins/rehype.js";
import {
satteriAlert,
satteriCodeTabs,
satteriDirectiveFallback,
satteriHeadingLinks,
satteriTableWrapper,
satteriToc,
} from "./src/plugins/satteri.js";
import { paths } from "./src/paths.js";
Expand Down Expand Up @@ -61,6 +64,10 @@ export interface JaamdOptions {
alerts?: boolean;
/** `:::` directive syntax (prerequisite for codeTabs and toc) */
directive?: boolean;
/** Scroll container around tables */
tables?: boolean;
/** Anchor link on every heading */
headingLinks?: boolean;
};
}

Expand All @@ -72,7 +79,14 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {
noDefault = false,
plugins = {},
} = options;
const { codeTabs = true, toc = true, alerts = true, directive = true } = plugins;
const {
codeTabs = true,
toc = true,
alerts = true,
directive = true,
tables = true,
headingLinks = true,
} = plugins;

return {
name: "@lancher-dev/jaamd",
Expand Down Expand Up @@ -127,7 +141,20 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {
if (needsDirective) {
processor.options.features = { ...processor.options.features, directive: true };
}
[registered, total] = [missing.length, plugins.length];

const hastPlugins = [
...(tables ? [satteriTableWrapper] : []),
...(headingLinks ? [satteriHeadingLinks] : []),
];
const existingHast = processor.options.hastPlugins;
const hastNames = new Set(namesIn(existingHast));
const missingHast = hastPlugins.filter((p) => !hastNames.has(p.name));

processor.options.hastPlugins = [...existingHast, ...missingHast];
[registered, total] = [
missing.length + missingHast.length,
plugins.length + hastPlugins.length,
];
} else if (isUnified(processor)) {
const plugins = [
...(alerts ? [remarkAlert] : []),
Expand All @@ -149,10 +176,23 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {
);

processor.options.remarkPlugins = [...missing, ...existing];
[registered, total] = [missing.length, plugins.length];

const rehypePlugins = [
...(tables ? [rehypeTableWrapper] : []),
...(headingLinks ? [rehypeHeadingLinks] : []),
];
const existingRehype = processor.options.rehypePlugins;
const rehypeNames = new Set(existingRehype.map(nameOf).filter(Boolean));
const missingRehype = rehypePlugins.filter((p) => !rehypeNames.has(nameOf(p)));

processor.options.rehypePlugins = [...existingRehype, ...missingRehype];
[registered, total] = [
missing.length + missingRehype.length,
plugins.length + rehypePlugins.length,
];
} else {
logger.warn(
`markdown processor "${processor.name}" is neither Sätteri nor unified; jaamd's plugins (alerts/code-tabs/toc) were not added. Pass them to your processor manually.`,
`markdown processor "${processor.name}" is neither Sätteri nor unified; jaamd's markdown plugins were not added. Pass them to your processor manually.`,
);
[registered, total] = [0, 0];
}
Expand Down Expand Up @@ -204,8 +244,11 @@ export {
satteriAlert,
satteriCodeTabs,
satteriDirectiveFallback,
satteriHeadingLinks,
satteriTableWrapper,
satteriToc,
} from "./src/plugins/satteri.js";
export { rehypeHeadingLinks, rehypeTableWrapper } from "./src/plugins/rehype.js";
export { default as remarkCodeTabs } from "./src/plugins/remark-code-tabs.js";
export { default as remarkToc } from "./src/plugins/remark-toc.js";
export { remarkAlert } from "./src/plugins/remark-alert.js";
Expand Down
1 change: 1 addition & 0 deletions packages/jaamd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
},
"devDependencies": {
"@astrojs/markdown-satteri": "0.4.3",
"@types/hast": "^3.0.5",
"@types/mdast": "^4.0.4",
"astro": "^7.3.8",
"satteri": "^0.10.5",
Expand Down
57 changes: 57 additions & 0 deletions packages/jaamd/src/plugins/rehype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/** rehype plugins for the unified processor. */

import type { RehypePlugin } from "@astrojs/markdown-remark";
import type { Element, ElementContent } from "hast";
import { visit } from "unist-util-visit";
import { TABLE_WRAPPER_CLASS } from "../scripts/utils.js";
import { createSlugger, headingLinkHtml } from "./render.js";

/** Wraps tables in a scroll container. */
export const rehypeTableWrapper: RehypePlugin<[]> = () => (tree) => {
visit(tree, "element", (node, index, parent) => {
if (node.tagName !== "table" || index === undefined || !parent) return;

const wrapper: Element = {
type: "element",
tagName: "div",
properties: { className: [TABLE_WRAPPER_CLASS] },
children: [node],
};
parent.children[index] = wrapper;
return index + 1;
});
};

/** Text Astro slugs: text nodes, raw HTML tags left out. */
function textOf(node: ElementContent): string {
if (node.type === "text") return node.value;
return "children" in node ? node.children.map(textOf).join("") : "";
}

function hasExpression(node: ElementContent): boolean {
if ((node.type as string) === "mdxTextExpression") return true;
return "children" in node && node.children.some(hasExpression);
}

/**
* Appends the anchor to headings. Astro assigns ids after user plugins, so the
* id is set here, with the same slug; Astro keeps an id it finds.
*/
export const rehypeHeadingLinks: RehypePlugin<[]> = () => (tree) => {
const slug = createSlugger();

visit(tree, "element", (node) => {
if (!/^h[1-6]$/.test(node.tagName)) return;
// Astro resolves MDX expressions in these: left to the client.
if (hasExpression(node)) return;

const text = textOf(node);
const id = typeof node.properties.id === "string" ? node.properties.id : slug(text);
node.properties.id = id;

node.children.push({
type: "raw",
value: headingLinkHtml(id, text),
} as unknown as ElementContent);
});
};
31 changes: 31 additions & 0 deletions packages/jaamd/src/plugins/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
/** Markup shared by the remark and Sätteri plugins. */

import {
HEADING_LINK_CLASS,
HEADING_LINK_ICON,
headingLinkLabel,
slugify,
} from "../scripts/utils.js";
import { escapeHtml } from "./utils.js";

export const ALERT_MARKER = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i;
Expand Down Expand Up @@ -102,6 +108,31 @@ export function tocOpenHtml(label: string): string {

export const TOC_CLOSE_HTML = `</nav>`;

/** Heading ids as Astro assigns them: `github-slugger`, "setup", "setup-1", "setup-2". */
export function createSlugger(): (text: string) => string {
const counts = new Map<string, number>();

return (text) => {
const slug = slugify(text);
let result = slug;
while (counts.has(result)) {
const n = (counts.get(slug) ?? 0) + 1;
counts.set(slug, n);
result = `${slug}-${n}`;
}
counts.set(result, 0);
return result;
};
}

/** Anchor appended to a heading. */
export function headingLinkHtml(id: string, text: string): string {
return (
`<a class="${HEADING_LINK_CLASS}" href="#${escapeHtml(id)}" ` +
`aria-label="${escapeHtml(headingLinkLabel(text))}">${HEADING_LINK_ICON}</a>`
);
}

const pathData: Record<string, string> = {
note: "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z",
tip: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z",
Expand Down
57 changes: 54 additions & 3 deletions packages/jaamd/src/plugins/satteri.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
/** Sätteri mdast plugins. `:::` blocks require `features.directive`. */

import type { MdastPluginDefinition, MdastVisitorContext } from "satteri";
/** Sätteri mdast and hast plugins. `:::` blocks require `features.directive`. */

import type {
HastPluginDefinition,
MdastPluginDefinition,
MdastVisitorContext,
} from "satteri";
import { TABLE_WRAPPER_CLASS } from "../scripts/utils.js";
import {
ALERT_MARKER,
TOC_CLOSE_HTML,
alertData,
alertTitle,
codeTabLabel,
codeTabsHtml,
createSlugger,
headingLinkHtml,
tocOpenHtml,
} from "./render.js";

Expand Down Expand Up @@ -131,3 +138,47 @@ function keepAsDiv(node: BlockDirective, ctx: MdastVisitorContext): void {
if (isClaimed(node)) return;
ctx.setProperty(node, "data", { ...node.data, hName: "div" });
}

/** Wraps tables in a scroll container. */
export const satteriTableWrapper: HastPluginDefinition = {
name: "jaamd-table-wrapper",
element: {
filter: ["table"],
visit(node, ctx) {
ctx.wrapNode(node, {
type: "element",
tagName: "div",
properties: { className: [TABLE_WRAPPER_CLASS] },
children: [],
});
},
},
};

// One slugger per document, keyed on its data bag.
const sluggers = new WeakMap<object, (text: string) => string>();

/**
* Appends the anchor to headings. Astro assigns ids after user plugins, so the
* id is set here, with the same slug; Astro keeps an id it finds.
*/
export const satteriHeadingLinks: HastPluginDefinition = {
name: "jaamd-heading-links",
element: {
filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
visit(node, ctx) {
const text = ctx.textContent(node);
// Astro resolves frontmatter expressions in these: left to the client.
if (text.includes("frontmatter")) return;

let id = node.properties?.id;
if (typeof id !== "string") {
const slug = sluggers.get(ctx.data) ?? createSlugger();
sluggers.set(ctx.data, slug);
id = slug(text);
ctx.setProperty(node, "id", id);
}
ctx.appendChild(node, { type: "raw", value: headingLinkHtml(id, text) });
},
},
};
Loading
Loading