From 73630c1aecd0837ac769522c1ebc2b283688e5d4 Mon Sep 17 00:00:00 2001 From: Robert Wagner Date: Mon, 24 Aug 2026 22:01:26 -0400 Subject: [PATCH 1/2] feat: serve markdown from the homepage via Accept negotiation The homepage is now rendered on demand so requests with `Accept: text/markdown` receive a markdown rendition of the page (text/markdown + Vary: Accept, per acceptmarkdown.com); HTML responses also send Vary: Accept. The same markdown is served statically at /index.md. On-demand rendering is used because vercel.json rewrites are unreliable with the Astro adapter and Vercel edge middleware does not run for prerendered pages. The response is uncached (max-age=0, must-revalidate) so a CDN can never serve the wrong variant; the sitemap gains customPages for the no-longer-prerendered homepage. Co-Authored-By: Claude Fable 5 --- landing/astro.config.mjs | 10 ++- landing/src/lib/accept.ts | 78 +++++++++++++++++++++++ landing/src/lib/homepage-markdown.ts | 92 ++++++++++++++++++++++++++++ landing/src/pages/index.astro | 34 ++++++++-- landing/src/pages/index.md.ts | 11 ++++ landing/test/accept.test.ts | 49 +++++++++++++++ landing/test/dist.test.ts | 28 +++++++++ landing/test/markdown.e2e.test.ts | 49 +++++++++++++++ 8 files changed, 345 insertions(+), 6 deletions(-) create mode 100644 landing/src/lib/accept.ts create mode 100644 landing/src/lib/homepage-markdown.ts create mode 100644 landing/src/pages/index.md.ts create mode 100644 landing/test/accept.test.ts create mode 100644 landing/test/dist.test.ts create mode 100644 landing/test/markdown.e2e.test.ts diff --git a/landing/astro.config.mjs b/landing/astro.config.mjs index d8be87d5e..3eaf6eff9 100644 --- a/landing/astro.config.mjs +++ b/landing/astro.config.mjs @@ -12,7 +12,15 @@ export default defineConfig({ // sitemap need to use www to avoid redirect chains. site: 'https://www.shepherdjs.dev', - integrations: [mdx(), sitemap()], + integrations: [ + mdx(), + sitemap({ + // The homepage is rendered on demand (for markdown content + // negotiation), so the sitemap integration cannot discover it + // at build time. + customPages: ['https://www.shepherdjs.dev/'] + }) + ], output: 'static', adapter: vercel(), diff --git a/landing/src/lib/accept.ts b/landing/src/lib/accept.ts new file mode 100644 index 000000000..d938e8ecf --- /dev/null +++ b/landing/src/lib/accept.ts @@ -0,0 +1,78 @@ +interface MediaRange { + type: string; + subtype: string; + q: number; + specificity: number; +} + +function parseAccept(accept: string): MediaRange[] { + return accept + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const [range, ...params] = part.split(';'); + const [type = '*', subtype = '*'] = (range ?? '') + .trim() + .toLowerCase() + .split('/'); + + let q = 1; + for (const param of params) { + const [key, value] = param.split('=').map((s) => s.trim()); + if (key === 'q' && value) { + const parsed = Number.parseFloat(value); + if (!Number.isNaN(parsed)) { + q = Math.min(Math.max(parsed, 0), 1); + } + } + } + + const specificity = + type === '*' ? 0 : subtype === '*' || subtype === undefined ? 1 : 2; + + return { type, subtype: subtype ?? '*', q, specificity }; + }); +} + +function quality(ranges: MediaRange[], type: string, subtype: string): number { + let best: MediaRange | undefined; + + for (const range of ranges) { + const typeMatches = range.type === '*' || range.type === type; + const subtypeMatches = range.subtype === '*' || range.subtype === subtype; + + if (typeMatches && subtypeMatches) { + if (!best || range.specificity > best.specificity) { + best = range; + } + } + } + + return best ? best.q : 0; +} + +/** + * Returns true when the request's Accept header explicitly asks for + * text/markdown with a quality at least as high as text/html. + * + * Wildcards (`*` and `text/*`) never count as an explicit request for + * markdown, so browsers (which send `text/html,...,*∕*;q=0.8`) always + * receive HTML. + */ +export function prefersMarkdown(accept: string | null): boolean { + if (!accept) { + return false; + } + + const ranges = parseAccept(accept); + const markdown = ranges.find( + (r) => r.type === 'text' && r.subtype === 'markdown' + ); + + if (!markdown || markdown.q === 0) { + return false; + } + + return markdown.q >= quality(ranges, 'text', 'html'); +} diff --git a/landing/src/lib/homepage-markdown.ts b/landing/src/lib/homepage-markdown.ts new file mode 100644 index 000000000..194d32ecd --- /dev/null +++ b/landing/src/lib/homepage-markdown.ts @@ -0,0 +1,92 @@ +import { SITE_URL } from '../consts'; + +/** + * Markdown representation of the homepage, served when a client sends + * `Accept: text/markdown` (see src/pages/index.astro) and directly at + * /index.md (see src/pages/index.md.ts). + */ +export const homepageMarkdown = `# Shepherd.js — Guide your users through a tour of your app + +Shepherd is an open-source JavaScript library for building guided product +tours, user onboarding flows, trainings, and feature announcements. Each tour +is a sequence of steps rendered as accessible dialogs that can attach to any +element in the DOM (positioned by [Floating UI](https://floating-ui.com/)), +highlight it with a modal overlay, and walk the user through your app. + +- Website: ${SITE_URL}/ +- Documentation: https://docs.shepherdjs.dev/ +- GitHub: https://github.com/shipshapecode/shepherd +- npm: https://www.npmjs.com/package/shepherd.js +- Pricing and licensing: ${SITE_URL}/pricing + +## Features + +- **Accessibility**: full keyboard navigation, focus trapping, and a11y + compliance via aria attributes. +- **Highly customizable**: minimal default styles that are easy to theme; + bring your own CSS classes per tour or per step. +- **Framework ready**: works with React, Ember, Angular, Vue.js, ES Modules, + or plain JavaScript. +- **Smart positioning**: steps never end up off screen or cropped by an + overflow, thanks to Floating UI. + +## Installation + +\`\`\`bash +npm install shepherd.js +\`\`\` + +Or include it directly: + +\`\`\`html + + +\`\`\` + +## Quick example + +\`\`\`js +import Shepherd from 'shepherd.js'; + +const tour = new Shepherd.Tour({ + useModalOverlay: true, + defaultStepOptions: { + cancelIcon: { enabled: true }, + scrollTo: { behavior: 'smooth', block: 'center' } + } +}); + +tour.addStep({ + title: 'Creating a Shepherd Tour', + text: 'Create a Tour instance and add as many steps as you want.', + attachTo: { element: '.example', on: 'bottom' }, + buttons: [ + { action() { return this.back(); }, secondary: true, text: 'Back' }, + { action() { return this.next(); }, text: 'Next' } + ] +}); + +tour.start(); +\`\`\` + +## When to use Shepherd + +Reach for Shepherd when you need to guide users through a web interface: +onboarding new users step by step, announcing or explaining new features, +walking through complex forms or workflows, or building in-app training. +It runs entirely in the browser with no backend service required. + +## Licensing + +Shepherd is free for open-source, personal, and non-commercial projects +(AGPL-3.0). Commercial licenses are available at ${SITE_URL}/pricing. +Shepherd is maintained by [Ship Shape](https://shipshape.io/). + +## More + +- Docs and guides: https://docs.shepherdjs.dev/ +- LLM/agent guidance: ${SITE_URL}/llms.txt +- Blog: ${SITE_URL}/blog +- About: ${SITE_URL}/about +- Contact: ${SITE_URL}/contact +`; diff --git a/landing/src/pages/index.astro b/landing/src/pages/index.astro index 914d43e4c..9117855e0 100644 --- a/landing/src/pages/index.astro +++ b/landing/src/pages/index.astro @@ -1,6 +1,28 @@ --- import { Code } from 'astro:components'; import MainPage from '@layouts/MainPage.astro'; +import { prefersMarkdown } from '../lib/accept'; +import { homepageMarkdown } from '../lib/homepage-markdown'; + +// Rendered on demand so we can content-negotiate: agents that ask for +// `Accept: text/markdown` get a markdown representation of this page. +export const prerender = false; + +if (prefersMarkdown(Astro.request.headers.get('accept'))) { + return new Response(homepageMarkdown, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8', + Vary: 'Accept', + 'Cache-Control': 'public, max-age=0, must-revalidate' + } + }); +} + +Astro.response.headers.set('Vary', 'Accept'); +Astro.response.headers.set( + 'Cache-Control', + 'public, max-age=0, must-revalidate' +); --- @@ -94,25 +116,27 @@ import MainPage from '@layouts/MainPage.astro'; // wait for shepherd to be ready setTimeout(function () { const shepherd = setupShepherd(); - + // Check if we should auto-start the tour (after redirect from another page) const shouldStartTour = sessionStorage.getItem('startTourOnLoad'); if (shouldStartTour) { sessionStorage.removeItem('startTourOnLoad'); shepherd.start(); } - + // Clean up previous listener if it exists if ((window as any).__startTourAbortController) { (window as any).__startTourAbortController.abort(); } - + // Create new AbortController for this listener const controller = new AbortController(); (window as any).__startTourAbortController = controller; - + // Listen for custom event from Demo button when already on home page - window.addEventListener('startTour', () => shepherd.start(), { signal: controller.signal }); + window.addEventListener('startTour', () => shepherd.start(), { + signal: controller.signal + }); }, 400); } diff --git a/landing/src/pages/index.md.ts b/landing/src/pages/index.md.ts new file mode 100644 index 000000000..e206a6a92 --- /dev/null +++ b/landing/src/pages/index.md.ts @@ -0,0 +1,11 @@ +import type { APIRoute } from 'astro'; + +import { homepageMarkdown } from '../lib/homepage-markdown'; + +export const GET: APIRoute = () => { + return new Response(homepageMarkdown, { + headers: { + 'Content-Type': 'text/markdown; charset=utf-8' + } + }); +}; diff --git a/landing/test/accept.test.ts b/landing/test/accept.test.ts new file mode 100644 index 000000000..5ff42f79f --- /dev/null +++ b/landing/test/accept.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { prefersMarkdown } from '../src/lib/accept'; + +describe('prefersMarkdown', () => { + it('returns false when there is no Accept header', () => { + expect(prefersMarkdown(null)).toBe(false); + expect(prefersMarkdown('')).toBe(false); + }); + + it('returns true for an explicit text/markdown request', () => { + expect(prefersMarkdown('text/markdown')).toBe(true); + }); + + it('returns true when markdown and html are equally acceptable', () => { + expect(prefersMarkdown('text/markdown, text/html')).toBe(true); + expect(prefersMarkdown('text/html, text/markdown')).toBe(true); + }); + + it('returns true when markdown is preferred over html via q values', () => { + expect(prefersMarkdown('text/markdown, text/html;q=0.9')).toBe(true); + expect(prefersMarkdown('text/html;q=0.5, text/markdown;q=0.8')).toBe(true); + }); + + it('returns false when html is preferred over markdown', () => { + expect(prefersMarkdown('text/html, text/markdown;q=0.8')).toBe(false); + }); + + it('returns false for a typical browser Accept header', () => { + expect( + prefersMarkdown( + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8' + ) + ).toBe(false); + }); + + it('does not treat wildcards as a request for markdown', () => { + expect(prefersMarkdown('*/*')).toBe(false); + expect(prefersMarkdown('text/*')).toBe(false); + }); + + it('returns false when markdown is explicitly refused', () => { + expect(prefersMarkdown('text/markdown;q=0, text/html')).toBe(false); + }); + + it('handles uppercase and whitespace', () => { + expect(prefersMarkdown(' TEXT/MARKDOWN ; q=1.0 ')).toBe(true); + }); +}); diff --git a/landing/test/dist.test.ts b/landing/test/dist.test.ts new file mode 100644 index 000000000..953f51882 --- /dev/null +++ b/landing/test/dist.test.ts @@ -0,0 +1,28 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const landingDir = fileURLToPath(new URL('..', import.meta.url)); + +// The @astrojs/vercel adapter emits static assets into .vercel/output/static; +// older layouts used dist/client. These assertions only run after a build. +const staticDir = [ + join(landingDir, '.vercel/output/static'), + join(landingDir, 'dist/client'), + join(landingDir, 'dist') +].find((dir) => existsSync(join(dir, 'sitemap-index.xml'))); + +describe.skipIf(!staticDir)('build output', () => { + it('includes the homepage in the sitemap, on the www domain only', () => { + const sitemap = readFileSync(join(staticDir!, 'sitemap-0.xml'), 'utf-8'); + + expect(sitemap).toContain('https://www.shepherdjs.dev/'); + expect(sitemap).not.toContain('https://shepherdjs.dev/'); + }); + + it('emits index.md and does not prerender the negotiated homepage', () => { + expect(existsSync(join(staticDir!, 'index.md'))).toBe(true); + expect(existsSync(join(staticDir!, 'index.html'))).toBe(false); + }); +}); diff --git a/landing/test/markdown.e2e.test.ts b/landing/test/markdown.e2e.test.ts new file mode 100644 index 000000000..72607926d --- /dev/null +++ b/landing/test/markdown.e2e.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { TEST_BASE_URL } from './setup/dev-server'; + +describe('markdown content negotiation', () => { + it('serves markdown when the client asks for text/markdown', async () => { + const response = await fetch(`${TEST_BASE_URL}/`, { + headers: { Accept: 'text/markdown' } + }); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/markdown'); + expect(response.headers.get('vary')).toMatch(/accept/i); + expect(body).toMatch(/^# Shepherd/); + }); + + it('sends Vary: Accept on the HTML response', async () => { + const response = await fetch(`${TEST_BASE_URL}/`, { + headers: { Accept: 'text/html' } + }); + + expect(response.headers.get('content-type')).toContain('text/html'); + expect(response.headers.get('vary')).toMatch(/accept/i); + }); + + it('serves HTML for a typical browser Accept header', async () => { + const response = await fetch(`${TEST_BASE_URL}/`, { + headers: { + Accept: + 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + } + }); + + expect(response.headers.get('content-type')).toContain('text/html'); + }); +}); + +describe('markdown mirror', () => { + it('serves /index.md as markdown', async () => { + const response = await fetch(`${TEST_BASE_URL}/index.md`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/markdown'); + expect(body).toMatch(/^# Shepherd/); + expect(body).toContain('## When to use Shepherd'); + }); +}); From c39faa7377bb95c6b05517ed67e20505e822b4f4 Mon Sep 17 00:00:00 2001 From: Robert Wagner Date: Mon, 24 Aug 2026 22:42:34 -0400 Subject: [PATCH 2/2] fix: render /index.md on demand so it cannot shadow the homepage With the homepage no longer prerendered there is no static index.html, and Vercel's filesystem handler (which runs before the function routes) resolved the prerendered index.md as the directory index for '/', serving raw markdown to every visitor. Rendering /index.md on demand keeps the static output free of root index files, so '/' always reaches the negotiation route. Co-Authored-By: Claude Fable 5 --- landing/src/pages/index.md.ts | 9 ++++++++- landing/test/dist.test.ts | 25 +++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/landing/src/pages/index.md.ts b/landing/src/pages/index.md.ts index e206a6a92..b2350b058 100644 --- a/landing/src/pages/index.md.ts +++ b/landing/src/pages/index.md.ts @@ -2,10 +2,17 @@ import type { APIRoute } from 'astro'; import { homepageMarkdown } from '../lib/homepage-markdown'; +// Rendered on demand rather than prerendered: a static index.md file would +// be picked up by Vercel's filesystem handler as the directory index for `/` +// (there is no static index.html — the homepage is content negotiated), which +// would serve raw markdown to every visitor before the negotiation route runs. +export const prerender = false; + export const GET: APIRoute = () => { return new Response(homepageMarkdown, { headers: { - 'Content-Type': 'text/markdown; charset=utf-8' + 'Content-Type': 'text/markdown; charset=utf-8', + 'Cache-Control': 'public, max-age=0, must-revalidate' } }); }; diff --git a/landing/test/dist.test.ts b/landing/test/dist.test.ts index 953f51882..c347a2945 100644 --- a/landing/test/dist.test.ts +++ b/landing/test/dist.test.ts @@ -21,8 +21,29 @@ describe.skipIf(!staticDir)('build output', () => { expect(sitemap).not.toContain('https://shepherdjs.dev/'); }); - it('emits index.md and does not prerender the negotiated homepage', () => { - expect(existsSync(join(staticDir!, 'index.md'))).toBe(true); + it('keeps the root free of static index files that would shadow negotiation', () => { + // Vercel's filesystem handler runs before the `/` function route. A + // static index.html would bypass negotiation entirely, and with no + // index.html present a static index.md becomes the directory index for + // `/`, serving raw markdown to every visitor. Both routes must be + // rendered on demand. expect(existsSync(join(staticDir!, 'index.html'))).toBe(false); + expect(existsSync(join(staticDir!, 'index.md'))).toBe(false); + }); + + it('routes / and /index.md to the render function', () => { + const configPath = join(landingDir, '.vercel/output/config.json'); + + if (!existsSync(configPath)) { + return; // Older build layout without a deployment config. + } + + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + const functionRoutes = config.routes + .filter((route: { dest?: string }) => route.dest === '_render') + .map((route: { src: string }) => route.src); + + expect(functionRoutes).toContain('^/$'); + expect(functionRoutes).toContain('^/index\\.md/?$'); }); });