starlight-llm-actions
v0.13.0
Published
Starlight plugin: Page Actions dropdown with Copy/View/PDF and Open-in-LLM buttons (ChatGPT, Claude, Gemini, Perplexity, T3 Chat, Cursor, Copilot).
Maintainers
Readme
Getting Started
Want to get started immediately? Check out the getting started guide on the documentation site.
Features
A Starlight plugin that adds a Page Actions dropdown to every doc page.
- Copy as Markdown — fetches the page's markdown source and writes to clipboard
- View as Markdown — opens the markdown source in a new tab
- Save as PDF — triggers the browser print dialog (off by default)
- Open in
<provider>— opens the current page in ChatGPT, Claude, Gemini, GitHub Copilot, Perplexity, T3 Chat, or Cursor using the most reliable per-provider strategy (URL fetch / inline content / clipboard + open) - Content Collections beyond
docs— a changelog, blog, or API reference in its own collection gets the same Markdown routes and index coverage - Per-page opt-out via frontmatter
- Optional print/PDF snapshot disclaimer with branding row
- Optional
llms.txt,llms-full.txt, and named subset bundles, generated by the same renderer the per-page Markdown route uses
Install
npm install starlight-llm-actionsUsage
Add the plugin to your Starlight config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlightLlmActions from 'starlight-llm-actions';
export default defineConfig({
integrations: [
starlight({
title: 'My Docs',
plugins: [starlightLlmActions()],
}),
],
});Per-page opt-out
Add llmActions: false to a page's frontmatter to hide the dropdown there:
---
title: This page disables the actions
llmActions: false
---Enable the "Save as PDF" action
The PDF action is off by default — the print dialog can still be reached via Cmd/Ctrl+P, and many sites prefer not to advertise PDF as a primary artifact. To show the in-menu button:
starlightLlmActions({
actions: { printPdf: true },
})Choosing what the Markdown route emits
By default the injected .md route serves the page's unprocessed source —
which on an MDX-heavy site means import statements and JSX component tags that
an AI agent has to guess its way through. renderMarkdown controls that:
starlightLlmActions({
renderMarkdown: 'simple',
})| Value | Behavior |
| --- | --- |
| 'raw' (default) | Emit entry.body verbatim — the original Markdown/MDX source. |
| 'simple' | Render the page to HTML the way Starlight does, then flatten that HTML back to plain Markdown. |
| { module: '…' } | Use your own renderer. See below. |
'simple' resolves components to their rendered output, so Starlight's
<Tabs>, <FileTree>, <Steps>, and Expressive Code blocks arrive as ordinary
Markdown lists, code fences, and headings rather than as component tags. Unknown
custom elements are unwrapped, keeping their text content. Add
data-mdast="ignore" to any element whose rendered output is pure chrome to drop
it entirely.
Because the flattening pipeline is heavy and most sites won't want it,
'simple' requires optional dependencies that the plugin does not install for
you:
npm install @astrojs/mdx unified rehype-parse rehype-remark remark-gfm remark-stringify hast-util-select unist-util-removeIf any are missing the build fails at config time with the exact install command, rather than partway through page rendering.
Limitation: 'simple' renders through an Astro container that only registers
the MDX renderer. A page using a framework component (React, Vue, Svelte, Solid)
will throw during rendering. That failure is caught per page — the plugin logs a
warning naming the page and falls back to that page's raw source, so one such
page never fails the build or affects the rest of the site.
Custom renderers
{ module } points at a module whose default export is a MarkdownRenderer.
The value must be a module specifier, not a function: plugin config is
serialized on its way into the injected route, and a function cannot survive
that trip. Relative paths resolve against your Astro project root.
starlightLlmActions({
renderMarkdown: { module: './src/render-markdown.ts' },
})// src/render-markdown.ts
import type { MarkdownRenderer } from 'starlight-llm-actions';
const render: MarkdownRenderer = async (entry, context) => {
return entry.body ?? '';
};
export default render;Return the body only — the route still prepends # {title} and the
> description blockquote. Throwing falls back to the raw source for that page,
with a warning, exactly as 'simple' does.
Advertising the Markdown source with <link rel="alternate">
linkAlternate adds a per-page link tag pointing at that page's Markdown
route, which lets crawlers and agents discover the Markdown without knowing the
plugin's URL convention:
starlightLlmActions({
linkAlternate: true,
})<link rel="alternate" type="text/markdown" href="/guides/example.md" />The href follows your markdownUrl template and respects Astro's base, and
non-ASCII slugs are percent-encoded. Two kinds of page get no tag, because the
Markdown route generates nothing for them: drafts, and the 404 page.
| Option | Default | Behavior |
| --- | --- | --- |
| true | — | Shorthand for {}. |
| type | 'text/markdown' | The tag's type attribute. |
| absolute | false | Emit a full URL built from Astro's site instead of a root-relative path. |
absolute: true requires site to be set in your Astro config; the plugin
throws at config time if it isn't, rather than emitting a broken href.
Site-level indexes: llms.txt and llms-full.txt
llmsTxt generates the files that let an agent discover your docs or take them
all in one request:
starlightLlmActions({
llmsTxt: true,
})| File | Contents |
| --- | --- |
| /llms.txt | The llmstxt.org index: site title, description, a link to each bundle, and a link to every page's Markdown route. |
| /llms-full.txt | Every page concatenated into one Markdown document. |
| /llms-{subset}.txt | One file per named subset. |
The bundles run the same renderMarkdown pipeline the per-page route runs, so
the Markdown in llms-full.txt is byte-identical to the Markdown at each page's
own .md URL — one configuration, one quality of output on every surface. Each
page is rendered once per build no matter how many files it appears in.
starlightLlmActions({
llmsTxt: {
promote: ['index*', 'getting-started/**'],
demote: ['reference/**'],
exclude: ['internal/**'],
subsets: [
{ label: 'REST API', description: 'the API reference', paths: ['api/**'] },
],
},
})| Option | Default | Behavior |
| --- | --- | --- |
| true | — | Shorthand for {}. |
| promote | ['index*'] | Site-path globs sorted to the top. Earlier patterns outrank later ones. |
| demote | [] | Site-path globs sorted to the end. Wins over promote on a page matching both. |
| exclude | [] | Site-path globs dropped from every index. The page still serves its own .md route. |
| subsets | [] | Named slices, one extra bundle each. label becomes the file name — 'REST API' gives /llms-rest-api.txt. |
Requires site in your Astro config: llms.txt links have to be absolute,
because an agent handed the file as a blob has no base to resolve against. The
plugin throws at config time if site is unset.
See the site-level indexes
guide for
the full walkthrough, including migrating from starlight-llms-txt.
Collections beyond docs
By default the plugin publishes Starlight's docs collection and nothing else. A
site that keeps some content in a second collection — a changelog with release
metadata, a blog, an API reference — lists it in collections to give those
pages the same .md routes and the same index coverage:
starlightLlmActions({
collections: [
'docs',
{ name: 'changelog', path: 'changelog/entry/{id}' },
],
})Starlight derives a docs page's URL from its entry id, so the two match. A
collection with its own route file usually does not, and path maps one onto the
other — {id} stands for the entry id. Get this right and the Markdown lands
beside the HTML, which is the whole .md convention.
Pages in a collection you have not listed are left alone: no .md route, no
index entry, and the dropdown drops its Copy and View items on those pages rather
than pointing them at a 404.
Configuration
For the full configuration reference — including per-provider overrides, the print/PDF snapshot disclaimer, and the markdown URL template — see the configuration docs.
Static hosting and the .md extension
The Markdown route is prerendered, so the Content-Type: text/markdown header it
sets exists only during the build and is discarded when the response is written
to disk. Static hosts derive Content-Type from the file extension instead.
Keep .md at the end of your markdownUrl template so hosts serve the file as
Markdown rather than as application/octet-stream (which triggers a download) or
as HTML. Both of these work:
starlightLlmActions({
markdownUrl: '/{slug}.md', // default → /guides/example.md
})
starlightLlmActions({
markdownUrl: '/{slug}/index.md', // → /guides/example/index.md
})On a server-rendered deployment the header survives and the extension matters
less, but keeping .md costs nothing and keeps the two deployment targets
consistent.
Make sure your host sends charset=utf-8
Deriving the type from the extension is only half the job. Some deploy tools stop
at text/markdown and never add a charset, and a browser with no charset to go on
falls back to windows-1252. Every non-ASCII byte then renders as mojibake: a curly
apostrophe becomes ’, an em dash becomes —. Ordinary prose trips this on
the first page rather than in some edge case, and the natural suspicion — that the
renderer emitted bad bytes — is wrong. The file on disk is fine.
So the rule is one header parameter: whatever your host is, make sure it serves
.md as text/markdown; charset=utf-8. On most hosts that is a headers config —
a [[headers]] block in netlify.toml, a headers entry in vercel.json, a
response-header rule on your CDN.
aws s3 sync is the awkward one, because the type is baked in at upload time. It
derives Content-Type from Python's mimetypes, which returns a bare
text/markdown for .md. And it re-uploads a file only when the size differs,
the local copy is newer, or the object is missing — so bolting --content-type
onto a second run over the same dist skips every file and changes nothing.
Upload in two passes instead:
aws s3 sync --delete dist s3://example.com/ \
--exclude "*.md"
aws s3 sync --delete dist s3://example.com/ \
--exclude "*" --include "*.md" \
--content-type "text/markdown; charset=utf-8"--delete stays safe across both passes. Sync excludes filtered-out files from
deletion as well
as from upload, so the first pass never touches remote .md and the second only
ever deletes stale .md. Together they delete exactly what a single pass would
have.
One catch when you go to check your work: astro preview also serves .md as
text/markdown with no charset, so a local test looks identical to the broken
production case. Verify against the deployed URL:
curl -sI https://example.com/guides/example.md | grep -i content-typeCustomization
Using a custom PageTitle override
If your Starlight config already has a custom PageTitle component, the plugin skips its automatic injection and logs a dev-mode warning. Import PageActions directly inside your override instead:
---
import Default from '@astrojs/starlight/components/PageTitle.astro';
import PageActions from 'starlight-llm-actions/components/PageActions.astro';
---
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
<Default {...Astro.props}><slot /></Default>
<PageActions />
</div>The plugin integration still handles route injection and config resolution — only the UI placement is up to you.
CSS variables
The plugin scopes its styles with a set of --llm-* custom properties on the .sl-llm-actions root element, all defaulting to Starlight design tokens. Override them in your site's custom CSS to retheme without touching internal selectors.
See CSS customization for the full reference.
License
Licensed under the MIT License, Copyright © Holden Hewett.
Icons sourced from Simple Icons under CC0 and
Lobe Icons under MIT. Brand names and marks remain
trademarks of their respective owners and appear nominatively here only to
identify the linked services. No endorsement is implied. To use official brand
assets, override providers.<name>.icon in your plugin config.
See LICENSE for more information.
