-
-
Notifications
You must be signed in to change notification settings - Fork 659
feat: serve markdown from the homepage via Accept negotiation #3493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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'); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| <link rel="stylesheet" href="shepherd.js/dist/css/shepherd.css" /> | ||
| <script type="module" src="shepherd.js/dist/js/shepherd.mjs"></script> | ||
| \`\`\` | ||
|
|
||
| ## 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 | ||
| `; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' | ||
| } | ||
|
Comment on lines
+14
to
+17
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a non-storable cache policy for both Markdown responses.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| }); | ||
| } | ||
|
|
||
| Astro.response.headers.set('Vary', 'Accept'); | ||
| Astro.response.headers.set( | ||
| 'Cache-Control', | ||
| 'public, max-age=0, must-revalidate' | ||
| ); | ||
| --- | ||
|
|
||
| <MainPage isHome={true}> | ||
|
|
@@ -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); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| 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', | ||
| 'Cache-Control': 'public, max-age=0, must-revalidate' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Use a non-storing cache policy when responses must be uncached.
🤖 Prompt for AI Agents |
||
| } | ||
| }); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| 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', () => { | ||
|
Comment on lines
+10
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
rg -n -C 4 'dist\.test\.ts|astro build|vitest' \
--glob 'package.json' \
--glob '*.yml' \
--glob '*.yaml' \
.
fd --hidden -t f 'sitemap-index\.xml|index\.md|index\.html' landing 2>/dev/null || trueRepository: shipshapecode/shepherd Length of output: 26168 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- landing/test/dist.test.ts ---'
cat -n landing/test/dist.test.ts
printf '%s\n' '--- root and landing package scripts ---'
python3 - <<'PY'
import json
for path in ("package.json", "landing/package.json"):
try:
with open(path) as f:
data = json.load(f)
except FileNotFoundError:
continue
print(path)
print(json.dumps(data.get("scripts", {}), indent=2))
PY
printf '%s\n' '--- CI and task references ---'
rg -n -C 5 'landing|astro build|pnpm.*build|pnpm.*test|dist\.test\.ts' \
--glob '*.yml' --glob '*.yaml' --glob 'package.json' --glob 'Makefile' --glob '*.json' \
. || true
printf '%s\n' '--- landing output-related files ---'
git ls-files landing | rg '(^|/)(test|src|public|astro\.config|package\.json)|sitemap|dist|vercel' || trueRepository: shipshapecode/shepherd Length of output: 11209 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- landing configuration and test configuration ---'
for f in landing/astro.config.mjs landing/vitest.config.* landing/test/setup/dev-server.ts landing/test/helpers.ts; do
if [ -f "$f" ]; then
echo "--- $f ---"
cat -n "$f"
fi
done
printf '%s\n' '--- repository workflow files ---'
git ls-files | rg '(^|/)(\.github/workflows/|.*(workflow|ci).*\.(yml|yaml)$)' || true
printf '%s\n' '--- test command references outside package manifests ---'
rg -n -C 4 'landing|dist\.test|vitest run|astro build|pnpm test' \
--glob '!pnpm-lock.yaml' --glob '!**/node_modules/**' --glob '!**/dist/**' \
.github docs-src landing README.md 2>/dev/null || true
printf '%s\n' '--- ignore rules and tracked generated output ---'
for f in .gitignore landing/.gitignore; do
if [ -f "$f" ]; then
echo "--- $f ---"
cat -n "$f"
fi
done
git ls-files landing/.vercel landing/dist landing/test/dist.test.ts || true
printf '%s\n' '--- candidate output directories currently present ---'
for d in landing/.vercel/output/static landing/dist/client landing/dist; do
if [ -d "$d" ]; then
printf '%s: present\n' "$d"
find "$d" -maxdepth 1 -type f -printf ' %f\n' | sort | head -30
else
printf '%s: absent\n' "$d"
fi
doneRepository: shipshapecode/shepherd Length of output: 9595 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- test workflow ---'
cat -n .github/workflows/test.yml
printf '%s\n' '--- publish workflow ---'
cat -n .github/workflows/publish.yml
printf '%s\n' '--- all landing-related commands ---'
rg -n -C 8 'landing|astro build|astro check|pnpm.*build|pnpm.*test' \
--glob '!pnpm-lock.yaml' --glob '!**/node_modules/**' \
.github package.json landing/package.json pnpm-workspace.yaml README.md 2>/dev/null || true
printf '%s\n' '--- static test imports and suite declarations ---'
rg -n -C 3 'from .vitest|describe|it\(' landing/test --glob '*.ts'Repository: shipshapecode/shepherd Length of output: 17422 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import json
import os
import re
from pathlib import Path
root = Path(".")
landing_test = (root / "landing/test/dist.test.ts").read_text()
root_pkg = json.loads((root / "package.json").read_text())
landing_pkg = json.loads((root / "landing/package.json").read_text())
workflow = (root / ".github/workflows/test.yml").read_text()
candidates = [
root / "landing/.vercel/output/static",
root / "landing/dist/client",
root / "landing/dist",
]
selected = next(
(str(path) for path in candidates if (path / "sitemap-index.xml").exists()),
None,
)
print(f"selected_static_dir={selected!r}")
print(f"dist_suite_guard={!bool(selected)}")
print(f"landing_test_command={landing_pkg['scripts']['test']!r}")
print(f"root_test_ci={root_pkg['scripts']['test:ci']!r}")
print(f"root_build_excludes_landing={'!landing' in root_pkg['scripts']['build']}")
print(f"workflow_invokes_root_test_ci={'pnpm test:ci' in workflow}")
print(f"test_has_landing_build_reference={'astro build' in landing_test}")
assert selected is None, "The checked-out tree unexpectedly contains a candidate sitemap"
assert "skipIf(!staticDir)" in landing_test
assert landing_pkg["scripts"]["test"] == "vitest run"
assert "!landing" in root_pkg["scripts"]["build"]
assert "pnpm test:ci" in workflow
assert "astro build" not in landing_test
PYRepository: shipshapecode/shepherd Length of output: 336 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import json
from pathlib import Path
root = Path(".")
landing_test = (root / "landing/test/dist.test.ts").read_text()
root_pkg = json.loads((root / "package.json").read_text())
landing_pkg = json.loads((root / "landing/package.json").read_text())
workflow = (root / ".github/workflows/test.yml").read_text()
candidates = [
root / "landing/.vercel/output/static",
root / "landing/dist/client",
root / "landing/dist",
]
selected = next(
(str(path) for path in candidates if (path / "sitemap-index.xml").exists()),
None,
)
print(f"selected_static_dir={selected!r}")
print(f"dist_suite_guard={!bool(selected)}")
print(f"landing_test_command={landing_pkg['scripts']['test']!r}")
print(f"root_test_ci={root_pkg['scripts']['test:ci']!r}")
print(f"root_build_excludes_landing={'!landing' in root_pkg['scripts']['build']}")
print(f"workflow_invokes_root_test_ci={'pnpm test:ci' in workflow}")
print(f"test_has_landing_build_reference={'astro build' in landing_test}")
assert selected is None
assert "skipIf(!staticDir)" in landing_test
assert landing_pkg["scripts"]["test"] == "vitest run"
assert "!landing" in root_pkg["scripts"]["build"]
assert "pnpm test:ci" in workflow
assert "astro build" not in landing_test
PYRepository: shipshapecode/shepherd Length of output: 336 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
python3 - <<'PY'
import json
from pathlib import Path
root = Path(".")
landing_test = (root / "landing/test/dist.test.ts").read_text()
root_pkg = json.loads((root / "package.json").read_text())
landing_pkg = json.loads((root / "landing/package.json").read_text())
workflow = (root / ".github/workflows/test.yml").read_text()
candidates = [
root / "landing/.vercel/output/static",
root / "landing/dist/client",
root / "landing/dist",
]
selected = next(
(str(path) for path in candidates if (path / "sitemap-index.xml").exists()),
None,
)
print("selected_static_dir={!r}".format(selected))
print("dist_suite_guard={}".format(not bool(selected)))
print("landing_test_command={!r}".format(landing_pkg["scripts"]["test"]))
print("root_test_ci={!r}".format(root_pkg["scripts"]["test:ci"]))
print("root_build_excludes_landing={}".format("!landing" in root_pkg["scripts"]["build"]))
print("workflow_invokes_root_test_ci={}".format("pnpm test:ci" in workflow))
print("test_has_landing_build_reference={}".format("astro build" in landing_test))
assert selected is None
assert "skipIf(!staticDir)" in landing_test
assert landing_pkg["scripts"]["test"] == "vitest run"
assert "!landing" in root_pkg["scripts"]["build"]
assert "pnpm test:ci" in workflow
assert "astro build" not in landing_test
PYRepository: shipshapecode/shepherd Length of output: 405 Run the landing build-output test against a current build.
🤖 Prompt for AI Agents |
||
| 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('<loc>https://www.shepherdjs.dev/</loc>'); | ||
| expect(sitemap).not.toContain('<loc>https://shepherdjs.dev/'); | ||
| }); | ||
|
|
||
| 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/?$'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Normalize the media parameter name before testing for
q.Line 23 ignores
Q=0.prefersMarkdown('text/markdown;Q=0, text/html')returnstrueeven though the client refuses Markdown. Convertkeyto lowercase before the comparison. Add a test for uppercaseQ.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents