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
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
push:
branches: [main]
pull_request:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:

jobs:
typecheck:
Expand All @@ -26,8 +29,6 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: pnpm
cache-dependency-path: example/pnpm-lock.yaml
- working-directory: example
run: pnpm install
- working-directory: example
Expand All @@ -39,3 +40,26 @@ jobs:
# renaming its default markdown processor out from under us.
- name: Smoke test rendered output
run: node scripts/smoke-test.mjs

# `example/` pins astro ^7, so the job above never sees a new major.
# Red here means the next Astro major needs checking, not that main is broken.
astro-latest:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '24'
- working-directory: example
run: pnpm install
- working-directory: example
run: pnpm add astro@latest
- working-directory: example
run: pnpm build
- name: Smoke test rendered output against latest Astro
run: node scripts/smoke-test.mjs
67 changes: 22 additions & 45 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,17 @@ export interface JaamdOptions {
selector?: string;

/**
* Shiki syntax-highlighting theme.
*
* - **string** — single theme name (e.g. `"github-light"`).
* - **{ light, dark }** — enables dual-theme mode. Shiki outputs CSS
* variables for both themes and JAAMD injects the switching CSS.
* Use together with a `.dark` class on `<html>` for dark-mode toggling.
*
* Shiki theme. `{ light, dark }` enables dual-theme mode, which switches on a
* `.dark` class on `<html>`.
* @default "github-light"
*/
theme?: string | { light: string; dark: string };

/**
* Skip injecting the default CSS variable fallbacks (`jaamd/default`).
*
* `<MarkdownContent>` already imports `jaamd/default` (and the main
* stylesheet) statically in its own frontmatter, so this option has no
* effect for consumers using that component — the defaults are always
* present there, extracted by Astro/Vite's normal CSS pipeline.
*
* This flag only affects the fallback copy injected via the client-side
* "page" script, which exists for custom wrappers that render markdown
* without `<MarkdownContent>`. Note that CSS side-effect imports inside
* an injected script are not reliably retained by Rollup in a static
* build (confirmed missing from production output in testing) — prefer
* importing `jaamd/default` directly in your own wrapper rather than
* relying on this option.
* Has no effect when rendering through `<MarkdownContent>`, which imports
* them statically; it applies to custom wrappers.
* @default false
*/
noDefault?: boolean;
Expand All @@ -66,12 +51,7 @@ export interface JaamdOptions {
};
}

/**
* jaamd — Just Another Astro Markdown
*
* Registers remark plugins and injects the stylesheet automatically.
* Supports `astro add jaamd`.
*/
/** Registers jaamd's remark plugins and injects its stylesheets. */
export default function jaamd(options: JaamdOptions = {}): AstroIntegration {
const {
selector = ".jaamd-content",
Expand Down Expand Up @@ -110,12 +90,8 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {

const markdownUpdate: Record<string, any> = { shikiConfig: mergedShikiConfig };

// Astro pre-fills its default processor here, so seeing this name means
// the user did not override it and it is safe to replace.
// The name is an implementation detail, not a public API: if Astro
// renames it, jaamd stops registering its plugins and only logs a
// warning. That is why CI asserts on the rendered HTML, and why the
// peer range is capped. Re-verify on every Astro major.
// Not public API. If Astro renames it, jaamd silently skips its plugins;
// the scheduled CI run against latest Astro is what catches that.
const ASTRO_DEFAULT_PROCESSOR = "satteri";

const currentProcessor = existingMarkdown.processor;
Expand All @@ -131,8 +107,7 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {
const target = isUnified ? currentProcessor : unified();
const existing: unknown[] = target.options.remarkPlugins ?? [];

// Registering a remark plugin twice re-registers its micromark
// extensions, so skip anything the user already wired up.
// Registering a plugin twice re-registers its micromark extensions.
const nameOf = (p: unknown): string => {
const fn = Array.isArray(p) ? p[0] : p;
return typeof fn === "function" ? fn.name : "";
Expand All @@ -142,9 +117,8 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {
(p) => !existing.includes(p) && !existingNames.has(nameOf(p)),
);

// A new array rather than an unshift: the processor may be a
// module-scope object shared between configs, and mutating it in
// place would corrupt it and stack duplicates across setup runs.
// New array, not unshift: the processor may be shared between configs,
// and mutating it stacks duplicates across setup runs.
target.options.remarkPlugins = [...missing, ...existing];
markdownUpdate.processor = target;

Expand All @@ -155,26 +129,29 @@ export default function jaamd(options: JaamdOptions = {}): AstroIntegration {

updateConfig({
vite: {
ssr: {
// Ensure jaamd source files (including .astro components) are
// processed by Vite transforms (i.e. the Astro compiler) rather
// than being treated as pre-bundled external modules.
noExternal: ["jaamd"],
},
// Without this, jaamd's .astro sources are treated as pre-bundled
// externals and never reach the Astro compiler.
ssr: { noExternal: ["jaamd"] },
},
markdown: markdownUpdate,
});

// "page" stage: bundled by Vite, tree-shaken, no duplicate injection
const isDualTheme = typeof theme === "object" && theme.light && theme.dark;

// Stylesheets go through "page-ssr". CSS imported from the client "page"
// stage is dropped by Rollup in static builds.
injectScript(
"page",
"page-ssr",
(!noDefault ? `import "jaamd/default";
` : "") +
(isDualTheme ? `import "jaamd/shiki-dual";
` : "") +
`import "jaamd/styles";
` +
`,
);

injectScript(
"page",
`import { initMarkdownEnhancements } from "jaamd/client";
` +
`function __jaamdRun() { initMarkdownEnhancements(${JSON.stringify(selector)}); }
Expand Down
34 changes: 26 additions & 8 deletions scripts/smoke-test.mjs
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
#!/usr/bin/env node
/**
* Post-build smoke test for the example site.
* Post-build assertions on ./example. A green `astro build` proves nothing:
* jaamd only warns when it cannot register its plugins.
*
* When jaamd cannot recognise Astro's default markdown processor it logs a
* warning and skips its remark plugins, and the build still exits 0. So a green
* build proves nothing about whether alerts and code-tabs rendered. Asserting on
* the produced HTML is what catches Astro renaming that processor.
*
* Usage: node scripts/smoke-test.mjs (after building ./example)
* Usage: node scripts/smoke-test.mjs
*/

import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";

const PAGE = join(process.cwd(), "example", "dist", "demo", "index.html");
const DIST = join(process.cwd(), "example", "dist");
const PAGE = join(DIST, "demo", "index.html");

if (!existsSync(PAGE)) {
console.error(`✗ built page not found: ${PAGE}\n Did \`npm run build\` run in ./example?`);
Expand All @@ -22,6 +19,13 @@ if (!existsSync(PAGE)) {

const html = readFileSync(PAGE, "utf8");

// Stylesheets the page actually links, concatenated.
const css = [...html.matchAll(/<link[^>]+rel="stylesheet"[^>]+href="([^"]+)"/g)]
.map((m) => join(DIST, m[1].replace(/^\//, "")))
.filter(existsSync)
.map((f) => readFileSync(f, "utf8"))
.join("\n");

/** @type {{ name: string, ok: boolean, hint: string }[]} */
const results = [];

Expand Down Expand Up @@ -92,6 +96,20 @@ check(
"no stylesheet reached the page; the CSS import chain is broken",
);

check(
"markdown styles shipped",
css.includes("--jaamd-"),
"markdown.css did not reach the linked stylesheets",
);

// In dual mode Shiki only sets --shiki-light/--shiki-dark; without these rules
// code renders with no colour at all.
check(
"dual-theme code colours shipped",
css.includes("var(--shiki-light)") && css.includes("var(--shiki-dark)"),
"shiki-dual.css did not reach the CSS; inject it at the page-ssr stage, not page",
);

check(
"client enhancements bundled",
/<script[^>]+type="module"/.test(html),
Expand Down
20 changes: 2 additions & 18 deletions src/components/MarkdownContent.astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,11 @@ import "../styles/variables.css";
import "../styles/markdown.css";

/**
* MarkdownContent
*
* Wraps markdown-rendered HTML. Client-side enhancements are initialised
* automatically by the jaamd() integration via its injected page script.
*
* The full generic Polymorphic<Props> type is declared in MarkdownContent.astro.d.ts
* and is used automatically by TypeScript when importing this component.
* The inline Props here is intentionally simplified to avoid Astro's parser
* misreading TypeScript generics (e.g. <Tag extends HTMLTag>) as HTML tags.
* Kept deliberately non-generic: Astro's parser reads `<Tag extends HTMLTag>` as
* an HTML tag. The real generic type lives in MarkdownContent.astro.d.ts.
*/
type Props = {
as?: HTMLTag;
/**
* Extra CSS classes to append to the wrapper element.
* The `jaamd-content` class is always present — it is the selector used
* by the JS enhancements and must not be removed.
*
* @example
* // Renders: <article class="jaamd-content prose mx-auto">
* <MarkdownContent as="article" class="prose mx-auto">
*/
class?: string;
};

Expand Down
11 changes: 2 additions & 9 deletions src/components/MarkdownContent.astro.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,8 @@ import type { HTMLTag, Polymorphic } from "astro/types";

export type Props<Tag extends HTMLTag = "div"> = Polymorphic<{
as: Tag;
/**
* Extra CSS classes to append to the wrapper element.
* The `jaamd-content` class is always present — it is the selector used
* by the JS enhancements and must not be removed.
*
* @example
* // Renders: <article class="jaamd-content prose">
* <MarkdownContent as="article" class="prose">
*/
/** Appended to the always-present `jaamd-content` class, which the JS
* enhancements select on. */
class?: string;
}>;

Expand Down
19 changes: 4 additions & 15 deletions src/plugins/remark-code-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,11 @@ function escapeHtml(value: string): string {
}

/**
* Remark plugin: tabbed code blocks.
* Tabbed code blocks: a `:::code-tabs` container wrapping fenced blocks.
* The meta string after the language is the tab label, falling back to the
* language, then "Tab N".
*
* Syntax:
* :::code-tabs
* ```bash npm
* npm install
* ```
* ```bash pnpm
* pnpm install
* ```
* :::
*
* The meta string (text after the language) becomes the tab label.
* Falls back to the language identifier, then "Tab N".
*
* Requires remark-directive to be registered before this plugin.
* Requires remark-directive to run before this plugin.
*/
const remarkCodeTabs: Plugin<[], Root> = () => {
return (tree: Root) => {
Expand Down
12 changes: 0 additions & 12 deletions src/scripts/enhancements.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,3 @@
/**
* jaamd client-side enhancements
*
* Plain ES module — bundled by Vite as part of the Astro build.
* No external runtime dependencies.
*
* Each feature lives in its own module; this file is the public entry point
* that wires them all together via `initMarkdownEnhancements`.
*/

import { addHeadingLinks } from "./modules/heading-links.js";
import { addCopyButtons } from "./modules/copy-buttons.js";
import { addImageLightbox } from "./modules/lightbox.js";
Expand All @@ -16,8 +6,6 @@ import { initCodeTabs } from "./modules/code-tabs.js";
import { initSpoilers } from "./modules/spoilers.js";
import { initDetails } from "./modules/details.js";

// ─── Public API ───────────────────────────────────────────────────────────────

export function initMarkdownEnhancements(
selector: string = ".jaamd-content",
): void {
Expand Down
10 changes: 2 additions & 8 deletions src/scripts/modules/spoilers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import { qsa } from "../utils.js";

// ─── Spoilers ─────────────────────────────────────────────────────────────────

/**
* `.spoiler` is authored as plain markup (e.g. `<span class="spoiler">`) and so
* carries no semantics. Promote it to a button: focusable, operable with
* Enter/Space, and reporting its state. Without this the content is
* unreachable without a mouse.
*/
/** `.spoiler` is authored as plain markup; this gives it button semantics so it
* is reachable without a mouse. */
export function initSpoilers(selector: string): void {
qsa<HTMLElement>(document, `${selector} .spoiler`).forEach((el) => {
if (el.dataset.spoilerInit) return;
Expand Down
13 changes: 2 additions & 11 deletions src/scripts/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
// ─── Shared DOM utilities ─────────────────────────────────────────────────────

/**
* Slugify heading text for use as an element id.
*
* Keeps any Unicode letter or number, so CJK, Cyrillic, Arabic and accented
* Latin headings survive instead of collapsing to an empty string under an
* ASCII-only `\w` filter.
*/
/** Unicode-aware on purpose: `\w` would collapse CJK and Cyrillic headings to "". */
export function slugify(text: string): string {
return text
.toLowerCase()
Expand All @@ -16,8 +8,7 @@ export function slugify(text: string): string {
.replace(/^-+|-+$/g, "");
}

/** Appends `-1`, `-2`, ... until the id is free, so repeated headings never
* share an anchor. */
/** Appends `-1`, `-2`, … until the id is free. */
export function uniqueElementId(base: string): string {
const seed = base || "section";
if (!document.getElementById(seed)) return seed;
Expand Down
15 changes: 3 additions & 12 deletions src/styles/shiki-dual.css
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
/* ============================================================
* JAAMD — Shiki Dual-Theme Switching
* ============================================================
*
* Injected automatically when theme is { light, dark }.
* Applies the correct Shiki CSS-variable set based on html.dark.
*
* Importable as: import "jaamd/shiki-dual";
* @import "jaamd/shiki-dual.css";
* ============================================================ */
/* Shiki dual-theme switching. Injected when `theme` is { light, dark }. */

/* Light mode (default) — use --shiki-light-* variables */
/* Light mode (default) */
.jaamd-content .astro-code span,
.jaamd-content .shiki span {
color: var(--shiki-light) !important;
Expand All @@ -18,7 +9,7 @@
text-decoration: var(--shiki-light-text-decoration) !important;
}

/* Dark mode — switch to --shiki-dark-* variants */
/* Dark mode */
html.dark .jaamd-content .astro-code span,
html.dark .jaamd-content .shiki span {
color: var(--shiki-dark) !important;
Expand Down
Loading
Loading