Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2b134e7
docs: build the site with Nuxt 5 and @vite-hub/content
onmax Oct 21, 2026
fdb922c
docs: finish Nuxt content migration
vitehub-bot[bot] Oct 21, 2026
12148d2
fix docs checks and generated-site e2e server
vitehub-bot[bot] Oct 21, 2026
16a153b
fix merge conflicts in docs migration lockfile
vitehub-bot[bot] Oct 21, 2026
3e657c8
remove obsolete docs builder after merge
vitehub-bot[bot] Oct 21, 2026
e5b50da
fix annotate native context-menu handoff after layout updates
vitehub-bot[bot] Oct 21, 2026
e04a51a
fix(docs): draw the charts page, and keep a chart's marks and cards u…
onmax Oct 21, 2026
86a5b3e
fix(charts): a side note's arrow reaches the point it marks with mark…
onmax Oct 21, 2026
5379597
chore(docs): resolve builder deletion conflict
vitehub-bot[bot] Oct 21, 2026
289c36e
fix(docs): deploy generated assets and restore charts skill
vitehub-bot[bot] Oct 21, 2026
d908ef7
Merge branch 'main' into docs/nuxt-vitehub-content
vitehub-bot[bot] Oct 21, 2026
afcf6a7
fix: repair merged pnpm lockfile
vitehub-bot[bot] Oct 21, 2026
98fccd7
Merge branch 'main' into docs/nuxt-vitehub-content
vitehub-bot[bot] Oct 21, 2026
e2d5296
Merge branch 'main' into docs/nuxt-vitehub-content
vitehub-bot[bot] Oct 21, 2026
78f826c
fix(docs): register code and icon plugins
vitehub-bot[bot] Oct 21, 2026
a16cf5a
fix(docs): update Vite Hub content preview
vitehub-bot[bot] Oct 21, 2026
f2f08e9
fix(docs): preserve playground in Nuxt output
vitehub-bot[bot] Oct 21, 2026
dd7505f
fix docs prepare before playground build
vitehub-bot[bot] Oct 21, 2026
16626cd
fix(docs): format Nuxt config
vitehub-bot[bot] Oct 21, 2026
3e0e1fd
Merge branch 'main' into docs/nuxt-vitehub-content
vitehub-bot[bot] Oct 21, 2026
8088477
fix(docs): render homepage window in Nuxt
vitehub-bot[bot] Oct 21, 2026
a768cf6
fix docs checks and generated-site build
vitehub-bot[bot] Oct 21, 2026
11363dc
fix docs merge with latest comark packages
vitehub-bot[bot] Oct 21, 2026
8f01a56
fix: allow msgpackr extraction build
vitehub-bot[bot] Oct 21, 2026
5df97b2
fix(docs): use named github callouts export
vitehub-bot[bot] Oct 21, 2026
3a32328
Merge the restructure stack (#80) into docs/nuxt-vitehub-content
onmax Oct 21, 2026
00902d1
docs: prerender every page from docs/pages and each package's docs/
onmax Oct 21, 2026
4b38207
fix(docs): type content plugin list for check
vitehub-bot[bot] Oct 21, 2026
dabd928
build(docs): Nuxt 4.6 instead of the Nuxt 5 nightly, with server code…
onmax Oct 21, 2026
cb18069
perf(docs): fetch the next page's body on hover and alongside its pay…
onmax Oct 21, 2026
405f6cd
test(docs): check client navigation by the page's heading, and that t…
onmax Oct 21, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ jobs:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
# The generated site imports workspace package output during Nuxt prerendering.
- name: Build packages
run: pnpm build
- name: Docs
run: pnpm docs:build
- name: Chromium
Expand Down
4 changes: 4 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.nuxt
.output
.generated
.data
4 changes: 4 additions & 0 deletions docs/app/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<template>
<!-- One catch-all page serves every slug; the key makes each one mount, so its scripts start and stop with it. -->
<NuxtPage :page-key="(route) => route.path" />
</template>
10 changes: 10 additions & 0 deletions docs/app/components/PageBody.server.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<script setup lang="ts">
// A server component, so the rendered page reaches the browser once: in the HTML on a first load, and as island JSON
// on a client-side navigation. Never in the payload.
const props = defineProps<{ slug: string }>();
const { data } = await useFetch(() => `/api/pages/${props.slug || "index"}`);
</script>

<template>
<div v-html="data?.html" />
</template>
126 changes: 126 additions & 0 deletions docs/app/pages/[...slug].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { github, installerFor, npmx } from "#shared/chrome";
import { docsPath, slugOf } from "~/utils/body";
import { enhance } from "~/utils/enhance";
import { runScripts } from "~/utils/scripts";

const site = "https://comark.onmax.me";
const route = useRoute();
const slug = computed(() => slugOf(route));
const nuxtApp = useNuxtApp();
const hydrating = nuxtApp.isHydrating;
const [{ data: page }] = await Promise.all([
useFetch(() => `/api/pages/${slug.value || "index"}`, {
key: () => `page:${slug.value}`,
query: { body: 0 },
}),
// The body the navigation started fetching, so <PageBody> finds it instead of asking again. If it failed,
// <PageBody> asks itself and reports the error.
import.meta.client && !hydrating ? nuxtApp.$loadBody(slug.value).catch(() => {}) : undefined,
]);
if (!page.value) throw createError({ status: 404, message: `No page at /${slug.value}.` });

useHead(() => ({
title: `${page.value!.title} · ${page.value!.description}`,
meta: [
{ name: "description", content: page.value!.tagline },
{ property: "og:title", content: page.value!.title },
{ property: "og:description", content: page.value!.description },
],
link: [{ rel: "canonical", href: `${site}/${slug.value ? `${slug.value}/` : ""}` }],
}));

const layout = useTemplateRef<HTMLElement>("layout");
const body = useTemplateRef<HTMLElement>("body");
let undo: (() => void)[] = [];
onMounted(() => (undo = [runScripts(body.value!, !hydrating), enhance(layout.value!)]));
onBeforeUnmount(() => undo.forEach((stop) => stop()));

// Links in rendered Markdown are plain anchors; same-site ones navigate in the app.
const router = useRouter();
function follow(event: MouseEvent) {
const link = (event.target as Element).closest("a");
if (
!link ||
event.defaultPrevented ||
event.button ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey
)
return;
const path = docsPath(link);
if (!path || path === location.pathname) return;
event.preventDefault();
const url = new URL(link.href, location.href);
void router.push(path + url.search + url.hash);
}
</script>

<template>
<div v-if="page">
<header class="top">
<NuxtLink class="name" to="/"
><span class="logo" aria-hidden="true">1</span>comark plugins</NuxtLink
>
<nav>
<NuxtLink
v-for="link in page.pages.filter((p) => p.npm && p.slug && !p.slug.includes('/'))"
:key="link.slug"
class="link"
:to="`/${link.slug}/`"
:aria-current="link.slug === slug ? 'page' : undefined"
>{{ link.slug }}</NuxtLink
>
<a
v-if="page.npm"
class="icon"
:href="`https://npmx.dev/package/${page.npm}`"
:aria-label="`${page.npm} on npmx`"
title="npmx"
v-html="npmx"
/>
<a
class="icon"
href="https://github.com/onmax/comark-plugins"
aria-label="comark-plugins on GitHub"
title="GitHub"
v-html="github"
/>
</nav>
</header>
<div ref="layout" class="layout">
<aside class="sidebar">
<nav aria-label="On this page">
<a class="nav-h2" href="#">Introduction</a>
<a v-if="page.npm" href="/play/" class="play">Open in playground</a>
<a
v-for="item in page.nav"
:key="item.id"
:class="`nav-${item.level}`"
:href="`#${item.id}`"
>{{ item.text }}</a
>
</nav>
</aside>
<div class="content">
<div class="hero enter">
<h1>{{ page.title }}</h1>
<p>{{ page.tagline }}</p>
<div v-if="page.install" v-html="installerFor(page.install)" />
</div>
<main ref="body" class="enter" style="--i: 1" @click="follow">
<PageBody :slug="slug" />
</main>
<footer>
<span
>Built on <a href="https://comark.dev">Comark</a> by the
<a href="https://github.com/comarkdown">Comark team</a>. Marks from
<a href="https://annotationkit.vercel.app">AnnotationKit</a>.</span
>
<span>Made by <a href="https://onmax.me">Maxi García</a></span>
</footer>
</div>
</div>
</div>
</template>
58 changes: 58 additions & 0 deletions docs/app/plugins/body.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { bodyKey, docsPath, slugOf } from "~/utils/body";

// `<PageBody>` asks for its island JSON only once the next page's setup runs, after `_payload.json` has arrived, so a
// navigation waited for two requests in a row. The body is fetched here instead: when a pointer rests on a link, on
// focus or touch, and at the latest as the navigation starts, alongside the payload. NuxtLink prefetches payloads
// when links scroll into view; bodies are bigger, so they wait for intent.
export default defineNuxtPlugin((nuxtApp) => {
const router = useRouter();
const data = nuxtApp.payload.data as Record<string, unknown>;

function loadBody(slug: string, priority: RequestPriority = "low"): Promise<unknown> {
const key = bodyKey(slug);
if (data[key]) return Promise.resolve(data[key]);
const pending = fetch(`/__nuxt_island/${key}.json`, { priority })
.then((response) => {
if (!response.ok) throw new Error(`${response.status} for the body of /${slug}`);
return response.json() as Promise<unknown>;
})
.then(
(island) => (data[key] = island),
(error: unknown) => {
delete data[key];
throw error;
},
);
data[key] = pending;
return pending;
}

function prefetch(target: EventTarget | null) {
const link = target instanceof Element ? target.closest("a") : null;
const path = link && docsPath(link);
if (!path || path === router.currentRoute.value.path) return;
loadBody(slugOf(router.resolve(path))).catch(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

router.resolve(path) returns a route whose catch-all params.slug is empty for a string path, so slugOf(router.resolve(path)) is always "" for links such as /annotate/. The hover, focus, and touch prefetch paths therefore request the homepage PageBody island instead of the target page, defeating the optimization and potentially adding an unnecessary request during navigation.

Technical details
# Target page body prefetches the homepage island

## Affected sites
- `docs/app/plugins/body.client.ts:34` — derives the prefetch slug from `router.resolve(path).params`, which does not contain the catch-all path segments.
- `docs/app/plugins/body.client.ts:50` — repeats the same empty-param derivation for navigation-start prefetch.
- `docs/app/pages/[...slug].vue:19` — later requests the correct slug, so the earlier homepage request does not satisfy the destination body cache.

## Required outcome
- Derive the slug from the already validated pathname, or resolve the route using an explicit catch-all `params.slug`, so every prefetch uses the target page's `PageBody` key.
- Ensure the hover, focus, touch, and navigation-start paths all populate the same island cache entry that `<PageBody :slug>` reads.

## Suggested approach
- Convert the docs pathname to the catch-all slug directly after removing the leading and trailing slash, and use that value for `loadBody`.

## Evidence
- Nuxt 4.6.2 router source: https://raw.githubusercontent.com/nuxt/nuxt/v4.6.2/packages/nuxt/src/app/composables/router.ts
- Nuxt 4.6.2 island cache and key construction: https://raw.githubusercontent.com/nuxt/nuxt/v4.6.2/packages/nuxt/src/app/components/nuxt-island.ts

// Links in the rendered Markdown aren't NuxtLinks, so nothing else prefetches their payload.
void nuxtApp.callHook("link:prefetch", path);
}

// A pointer passing over the header on its way somewhere else isn't intent; one that stays 65 ms is.
let resting: ReturnType<typeof setTimeout> | undefined;
document.addEventListener("pointerover", (event) => {
clearTimeout(resting);
if (event.pointerType === "mouse") resting = setTimeout(prefetch, 65, event.target);
});
document.addEventListener("pointerout", () => clearTimeout(resting));
for (const type of ["pointerdown", "focusin", "touchstart"])
document.addEventListener(type, (event) => prefetch(event.target), { passive: true });

router.beforeEach((to, from) => {
if (to.path !== from.path && !nuxtApp.isHydrating) loadBody(slugOf(to), "high").catch(() => {});
});
// The first load keeps its entrance; later pages only fade in, so the new page shows as soon as it arrives.
router.afterEach(() => {
if (!nuxtApp.isHydrating) document.documentElement.dataset.navigated = "";
});

return { provide: { loadBody } };
});
8 changes: 8 additions & 0 deletions docs/app/plugins/body.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// The page already holds the body's HTML, and hydration reads it from there. Left in the payload, the island entry
// makes every prefetched `_payload.json` fetch its page's body too, and the header links to every page.
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.hooks.hook("app:rendered", () => {
for (const key of Object.keys(nuxtApp.payload.data))
if (key.startsWith("PageBody_")) delete nuxtApp.payload.data[key];
});
});
19 changes: 19 additions & 0 deletions docs/app/utils/body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getIslandHash } from "#app";
import type { RouteLocationNormalizedLoaded } from "vue-router";

export const slugOf = (route: Pick<RouteLocationNormalizedLoaded, "params">) =>
[route.params.slug ?? []].flat().filter(Boolean).join("/");

// The key `<PageBody :slug>` stores its island under in the payload, and the name of its prerendered JSON.
export const bodyKey = (slug: string) =>
`PageBody_${getIslandHash({ name: "PageBody", props: { slug } })}`;

// The path of a link to another docs page, which the app can open without a page load. The catch-all route matches
// any path, so this tells pages from the playground and files, which aren't in the app.
export function docsPath(link: HTMLAnchorElement): string | undefined {
if (!link.href || link.target || link.hasAttribute("download")) return;
const url = new URL(link.href, location.href);
if (url.origin !== location.origin || !url.pathname.endsWith("/")) return;
if (url.pathname.startsWith("/play/")) return;
return url.pathname;
}
122 changes: 122 additions & 0 deletions docs/app/utils/enhance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// The example tabs, the Preview/Code switch, the installer, copy buttons, and the active sidebar link. Without it the page still
// reads top to bottom. Returns a function that undoes it, since the page's elements outlive a client-side navigation.
export function enhance(root: HTMLElement): () => void {
const listeners = new AbortController();
const signal = listeners.signal;
for (const win of root.querySelectorAll<HTMLElement>(".window")) {
const tabs = [...win.querySelectorAll<HTMLElement>(".window-tabs [role=tab]")];
const panels = [...win.querySelectorAll<HTMLElement>(".window-body > [role=tabpanel]")];
const show = (index: number) => {
tabs.forEach((tab, i) => {
tab.setAttribute("aria-selected", String(i === index));
tab.tabIndex = i === index ? 0 : -1;
});
panels.forEach((panel, i) => {
panel.toggleAttribute("inert", i !== index);
panel.toggleAttribute("data-active", i === index);
});
};
win.querySelector(".window-tabs")?.addEventListener(
"click",
(event) => {
const tab = (event.target as Element).closest<HTMLElement>("[role=tab]");
if (tab) show(tabs.indexOf(tab));
},
{ signal },
);
const delay = matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 2200;
const timer = setTimeout(() => show(1), delay);
signal.addEventListener("abort", () => clearTimeout(timer), { once: true });
}
for (const t of root.querySelectorAll<HTMLElement>(".examples"))
t.querySelector(".tablist")?.addEventListener(
"click",
(e) => {
const b = (e.target as Element).closest<HTMLElement>("[role=tab]");
if (!b) return;
for (const x of t.querySelectorAll(":scope > .tablist > [role=tab]"))
x.setAttribute("aria-selected", String(x === b));
for (const p of t.querySelectorAll<HTMLElement>(":scope > [role=tabpanel]"))
p.hidden = p.dataset.tab !== b.dataset.tab;
},
{ signal },
);
for (const ex of root.querySelectorAll<HTMLElement>(".example"))
ex.querySelector(".seg")?.addEventListener(
"click",
(e) => {
const b = (e.target as Element).closest<HTMLElement>("button");
if (!b) return;
for (const x of ex.querySelectorAll(".seg button"))
x.setAttribute("aria-pressed", String(x === b));
ex.querySelector<HTMLElement>(".example-preview")!.hidden = b.dataset.view !== "preview";
ex.querySelector<HTMLElement>(".example-code")!.hidden = b.dataset.view !== "code";
},
{ signal },
);
for (const box of root.querySelectorAll<HTMLElement>(".installer")) {
const commands = JSON.parse(box.dataset.commands!) as Record<string, string>;
const field = box.querySelector<HTMLElement>(".install")!;
let audience = "humans";
let pm = "pnpm";
const update = () => {
const cmd = commands[audience === "agents" ? "agents" : pm]!;
field.dataset.copy = cmd;
field.querySelector("code")!.textContent = cmd;
box.querySelector<HTMLElement>(".installer-pm")!.hidden = audience === "agents";
for (const b of box.querySelectorAll<HTMLElement>("[data-audience]"))
b.setAttribute("aria-pressed", String(b.dataset.audience === audience));
for (const b of box.querySelectorAll<HTMLElement>("[data-pm]"))
b.setAttribute("aria-pressed", String(b.dataset.pm === pm));
};
box.addEventListener(
"click",
(e) => {
const b = (e.target as Element).closest<HTMLElement>("[data-audience],[data-pm]");
if (!b) return;
if (b.dataset.audience) audience = b.dataset.audience;
if (b.dataset.pm) pm = b.dataset.pm;
update();
},
{ signal },
);
field.addEventListener(
"click",
async () => {
await navigator.clipboard.writeText(field.dataset.copy!);
field.classList.add("copied");
setTimeout(() => field.classList.remove("copied"), 1200);
},
{ signal },
);
}
for (const pre of root.querySelectorAll("pre")) {
if (pre.querySelector(":scope > .copy")) continue;
const b = document.createElement("button");
b.className = "copy";
b.type = "button";
b.textContent = "Copy";
b.onclick = async () => {
const code = pre.querySelector("code");
if (!code) return;
await navigator.clipboard.writeText(code.innerText.trimEnd());
b.textContent = "Copied";
setTimeout(() => (b.textContent = "Copy"), 1500);
};
pre.append(b);
}
const links = [...root.querySelectorAll<HTMLAnchorElement>(".sidebar a")];
const spy = new IntersectionObserver(
(entries) => {
for (const e of entries)
if (e.isIntersecting)
for (const a of links) a.toggleAttribute("data-active", a.hash === `#${e.target.id}`);
},
{ rootMargin: "0px 0px -70% 0px" },
);
for (const h of root.querySelectorAll("main h2[id], main h3[id]")) spy.observe(h);
return () => {
listeners.abort();
spy.disconnect();
};
}
24 changes: 24 additions & 0 deletions docs/app/utils/scripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// HTML inserted by the client never runs its scripts, so the HTML renderers' inlined zoom, carousel, and annotate
// scripts are run again when an island brings them. The ones in the server's HTML already ran.
export function runScripts(root: HTMLElement, includeExisting: boolean): () => void {
const run = (old: HTMLScriptElement) => {
const script = document.createElement("script");
script.textContent = old.textContent;
old.replaceWith(script);
return script;
};
const ran = new WeakSet<Node>();
const scan = (node: Node) => {
if (!(node instanceof Element)) return;
for (const script of node.matches("script") ? [node] : node.querySelectorAll("script"))
if (!ran.has(script)) ran.add(run(script as HTMLScriptElement));
};
if (includeExisting) scan(root);
else for (const script of root.querySelectorAll("script")) ran.add(script);
const observer = new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) if (!ran.has(node)) scan(node);
});
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}
Loading
Loading