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
138 changes: 138 additions & 0 deletions .github/workflows/preview-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Posts (and keeps updated) a sticky comment linking each docs PR to its live preview.
#
# `pull_request_target` so the workflow also runs for fork PRs — safe here because it never checks
# out or executes PR code; it only reads PR metadata and writes a comment. Previews for fork PRs
# stay disabled until a maintainer adds the `preview:enabled` label (enforced server-side too, see
# `server/utils/github.ts`), so the comment either shows the links or explains how to enable them.
name: preview comment

on:
pull_request_target:
branches:
- main
paths:
- 'playground/content/**'
types:
- opened
- reopened
- synchronize
- labeled
- unlabeled

permissions:
pull-requests: write

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}

jobs:
comment:
runs-on: ubuntu-latest

steps:
- name: Create or update preview comment
uses: actions/github-script@v8
env:
SITE_URL: https://docs-template.comark.dev
CONTENT_DIR: playground/content
PREVIEW_LABEL: preview:enabled
with:
script: |
const marker = '<!-- comark-docs-preview -->'
const { SITE_URL, CONTENT_DIR, PREVIEW_LABEL } = process.env
const { owner, repo } = context.repo
const pr = context.payload.pull_request

const internal = pr.head.repo && pr.head.repo.full_name === `${owner}/${repo}`
const enabled = internal || pr.labels.some((label) => label.name === PREVIEW_LABEL)

let body
if (!enabled) {
body = [
marker,
'## Documentation previews',
'',
'Previews are disabled for pull requests from forks.',
`A maintainer can add the \`${PREVIEW_LABEL}\` label to enable them.`,
].join('\n')
} else {
const previewRoot = `${SITE_URL}/pr/${pr.number}`
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
})

const prefix = `${CONTENT_DIR}/`
const pages = files
.filter((file) =>
file.status !== 'removed'
&& file.filename.startsWith(prefix)
&& file.filename.endsWith('.md'),
)
.map((file) => {
const relativePath = file.filename
.slice(prefix.length)
.replace(/\.md$/, '')
const routeSegments = relativePath
.split('/')
.map((segment) => segment.replace(/^\d+\./, ''))
.map(encodeURIComponent)
if (routeSegments.at(-1) === 'index') {
routeSegments.pop()
}
const route = routeSegments.join('/')

return {
filename: file.filename,
route,
url: route ? `${previewRoot}/${route}` : `${previewRoot}/`,
}
})
.sort((a, b) => a.filename.localeCompare(b.filename))

body = [
marker,
'## Documentation previews',
'',
`📚 [Preview all documentation changes](${previewRoot}) (follows new pushes)`,
...(pages.length
? [
'',
...pages.map((page) => `- [/${page.route}](${page.url})`),
]
: []),
'',
`Pinned to the current head: [\`${pr.head.sha.slice(0, 7)}\`](${SITE_URL}/blob/${pr.head.sha})`,
...(internal
? []
: ['', `Enabled by the \`${PREVIEW_LABEL}\` label — remove it to disable the preview.`]),
].join('\n')
}

const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
})
const existingComment = comments.find((comment) =>
comment.user?.type === 'Bot' && comment.body?.includes(marker),
)

if (existingComment) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
})
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body,
})
}
5 changes: 3 additions & 2 deletions app/components/docs/DocsPageAsideLinks.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ const links = computed(() => [
label: 'Edit this page on GitHub',
to: `${githubUrl.value}/edit/${content.value.mode === 'tree' ? content.value.ref : github?.branch || 'main'}/${github?.contentDir || 'content'}/${props.page.meta.stem}${props.page.meta.extension}`,
target: '_blank',
disabled: content.value.mode === 'blob',
class: content.value.mode === 'blob' ? 'text-dimmed hover:text-dimmed cursor-not-allowed' : undefined,
// /blob/ pins a commit and /pr/ may come from a fork branch this site can't link an editor to.
disabled: ['blob', 'pr'].includes(content.value.mode),
class: ['blob', 'pr'].includes(content.value.mode) ? 'text-dimmed hover:text-dimmed cursor-not-allowed' : undefined,
},
{
icon: 'i-lucide-star',
Expand Down
13 changes: 11 additions & 2 deletions app/composables/useDocsContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ function getClient(basePath: string) {

export interface ActiveContent {
mode: ContentMode
/** The branch name (tree) or commit SHA (blob); `undefined` in prod. */
/** The branch name (tree), commit SHA (blob) or PR number (pr); `undefined` in prod. */
ref?: string
/** Link prefix for this version (`/tree/<branch>`, `/blob/<sha>`, or `''` in prod). */
/** Link prefix for this version (`/tree/<branch>`, `/blob/<sha>`, `/pr/<number>`, or `''` in prod). */
base: string
/** The path within the content source (with leading slash). */
path: string
Expand Down Expand Up @@ -61,6 +61,15 @@ export function useDocsContent(): ComputedRef<ActiveContent> {
client: getClient(`/api/content/blob/${route.params.ref}`),
}
}
if (route.params.number && route.path.startsWith('/pr/')) {
return {
mode: 'pr',
ref: route.params.number as string,
base: `/pr/${route.params.number}`,
path,
client: getClient(`/api/content/pr/${route.params.number}`),
}
}
return { mode: 'prod', base: '', path, client: prodContent }
})
}
12 changes: 7 additions & 5 deletions app/router.options.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { RouterConfig } from '@nuxt/schema'

/**
* Register the versioned preview routes (`/tree/:ref`, `/blob/:ref` and their `/:slug(.+)` docs variants) as
* real routes reusing the landing and docs page components. They can't be `alias` entries: an alias may not
* introduce a param (`:ref`) the canonical record lacks — Vue Router warns R0102, accurately.
* Register the versioned preview routes (`/tree/:ref`, `/blob/:ref`, `/pr/:number` and their `/:slug(.+)` docs
* variants) as real routes reusing the landing and docs page components. They can't be `alias` entries: an alias
* may not introduce a param (`:ref`) the canonical record lacks — Vue Router warns R0102, accurately.
*
* Deliberate divergence from comark-content: added there in comarkdown/comark-content#79, reverted to aliases in #83.
* The layer was extracted in between and keeps this on purpose — don't "resync" it away.
Expand All @@ -17,13 +17,15 @@ export default <RouterConfig>{
if (landing) {
extra.push(
{ ...landing, name: 'landing-tree', path: '/tree/:ref' },
{ ...landing, name: 'landing-blob', path: '/blob/:ref' }
{ ...landing, name: 'landing-blob', path: '/blob/:ref' },
{ ...landing, name: 'landing-pr', path: '/pr/:number(\\d+)' }
)
}
if (docs) {
extra.push(
{ ...docs, name: 'docs-tree', path: '/tree/:ref/:slug(.+)' },
{ ...docs, name: 'docs-blob', path: '/blob/:ref/:slug(.+)' }
{ ...docs, name: 'docs-blob', path: '/blob/:ref/:slug(.+)' },
{ ...docs, name: 'docs-pr', path: '/pr/:number(\\d+)/:slug(.+)' }
)
}

Expand Down
2 changes: 1 addition & 1 deletion app/types/content.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
/** Which serving mode the active route is in. */
export type ContentMode = 'prod' | 'tree' | 'blob'
export type ContentMode = 'prod' | 'tree' | 'blob' | 'pr'
3 changes: 3 additions & 0 deletions modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,10 @@ export default defineNuxtModule<ComarkDocsOptions>({
// Layer-owned page (not derived from content/); still SSRs the content navigation shell.
'/logos': { isr },
// Previews are served live (SSR) off Runtime Cache; `/blob/**` is immutable commit HTML.
// `/pr/**` follows the PR's head like `/tree/**` follows a branch, so it shares the short TTL.
'/tree/**': { isr, robots: 'noindex, nofollow' },
'/blob/**': { isr: true, robots: 'noindex, nofollow' },
'/pr/**': { isr, robots: 'noindex, nofollow' },
// Raw markdown mirrors of every page, for agents.
'/raw/**': { isr, robots: 'noindex' },
// Global content indexes, purged by the push webhook on content changes.
Expand All @@ -169,6 +171,7 @@ export default defineNuxtModule<ComarkDocsOptions>({
// Fetched on every page hydration (see app.vue) and parses every doc body, so cache it.
'/api/content/blob/*/search-sections': { isr: true },
'/api/content/tree/*/search-sections': { isr },
'/api/content/pr/*/search-sections': { isr },
'/api/content/search-sections': { isr },
'/api/code-explorer/**': { isr },
}
Expand Down
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default defineNuxtConfig({
ignore: ['content/**'],
ui: { content: true, prose: true },
sitemap: {
sources: ['/api/__sitemap__/urls'], exclude: ['/tree/**', '/blob/**']
sources: ['/api/__sitemap__/urls'], exclude: ['/tree/**', '/blob/**', '/pr/**']
},
ogImage: { zeroRuntime: false },
icon: {
Expand Down
25 changes: 20 additions & 5 deletions playground/content/3.concepts/2.versioned-previews.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,47 @@
---
title: Versioned previews
description: Browse the docs at any branch or commit through /tree and /blob URLs, without touching production.
description: Browse the docs at any branch, commit or pull request through /tree, /blob and /pr URLs, without touching production.
---

Because content is [addressed by commit](/concepts/architecture#pinned-to-a-commit), the site can render *any* version of your docs on demand. Prefix a URL and you're browsing another branch or commit — no build, no deploy, no separate preview environment.
Because content is [addressed by commit](/concepts/architecture#pinned-to-a-commit), the site can render *any* version of your docs on demand. Prefix a URL and you're browsing another branch, commit or pull request — no build, no deploy, no separate preview environment.

| Mode | URL pattern | Content |
| --- | --- | --- |
| Branch | `/tree/:branch/<page>` | Latest content commit on the branch (follows new pushes) |
| Commit | `/blob/:sha/<page>` | That exact commit, immutable |
| Pull request | `/pr/:number/<page>` | The PR's head commit (follows new pushes) |

For example, this very page on the `main` branch lives at `/tree/main/concepts/versioned-previews`.

## How it works

A preview isn't a filtered view of production — it's a full content instance built from that version's files. The navigation tree, search index, and every internal link are rebuilt from the previewed commit and prefixed with the version base, so you browse a coherent snapshot. A page that only exists on your branch is fully navigable there.

Branch previews resolve the branch to its latest content commit on request, so refreshing a `/tree/` URL after a push shows the new content. Commit previews are immutable and cached accordingly.
Branch previews resolve the branch to its latest content commit on request, so refreshing a `/tree/` URL after a push shows the new content. Commit previews are immutable and cached accordingly. Pull request previews resolve the PR to its head commit on request, so they follow new pushes like branch previews do — including pushes to a fork branch.

## Fork pull requests

GitHub shares git objects across the fork network: once a fork opens a pull request, its commits become fetchable through *your* repository's API. Rendering any well-formed SHA would let anyone put arbitrary markdown on your domain by opening a PR from a fork — a phishing vector, even with scripts and embeds stripped.

So a commit only renders under `/blob/:sha` when at least one of these holds:

- The commit is in the production branch's history (this keeps version-history links working).
- A pull request from your own repository contains it. Contributors with push access could publish a `/tree/` preview anyway, so their PRs need no extra step.
- A pull request from a fork contains it **and** a maintainer added the `preview:enabled` label to that PR.

`/pr/:number` follows the same rule: same-repo PRs always render, fork PRs only with the `preview:enabled` label. Removing the label revokes both within about a minute (the decision cache's TTL).

Every other SHA answers 404, and `/tree/` rejects GitHub's hidden `pull/<n>/head` refs, so the label check can't be sidestepped through a branch preview.

## What previews are for

- **Reviewing content PRs** — share a `/tree/my-branch` link instead of asking reviewers to run the site. Pair it with the [PR preview comment action](/deployment/pr-preview-comments) to get links posted automatically.
- **Reviewing content PRs** — share a `/pr/123` or `/tree/my-branch` link instead of asking reviewers to run the site. Pair it with the [PR preview comment action](/deployment/pr-preview-comments) to get links posted automatically.
- **Checking history** — the version history panel (keyboard shortcut `g` then `h`, on any docs page) lists the commits that touched the current page and links each one to its `/blob/` preview.
- **Debugging** — pin a report to an exact commit with an immutable `/blob/<sha>` URL.

## Good to know

- Previews are public, like the rest of the site, but send `noindex` robots headers and canonicalize to the production URL — they won't compete with your real pages in search.
- The raw Markdown mirrors work in previews too: `/tree/my-branch/raw/<page>.md`.
- "Edit this page on GitHub" targets the previewed branch on `/tree/` pages, and is disabled on `/blob/` pages since a commit can't be edited.
- "Edit this page on GitHub" targets the previewed branch on `/tree/` pages, and is disabled on `/blob/` and `/pr/` pages a commit can't be edited, and a PR may come from a fork branch the site can't link an editor to.
- Preview instances are kept in a small LRU pool per server instance; evicted versions rebuild on demand, and their parsed pages survive in the per-SHA cache.
Loading
Loading