New features: - HLS video encoding/streaming (hls routes, hls-encoder) and a media transcripts pipeline (transcripts route + media-transcript queries) - AI assistant chat vs. run model: agent-course-generation worker/queue, run-monitor pane, chat-changes card, content ask-ai bar - Per-theme course-page previews across org landing-page themes (bold, classic, corporate, editorial, etc.) with a shared course token/component set - View-as-student mode and login-link / link-invite invite flows Refactor: - Extract packages/core consolidating config (env, storage, dashboard-url), services and utils previously duplicated under apps/api/src/config - Remove legacy agent service modules (chat-tools, chat-context, context-window, document, fetch-url, lesson-content) - Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding - Add format script to tenant-router so turbo run format covers it
10 KiB
Copy Page as Markdown PRD
Status
- Draft (not started)
Purpose
Give learners on public course pages a one-click way to export lesson content as Markdown and hand it off to an AI tool. The pattern is the same split-button found on Vercel, Next.js, and shadcn-svelte docs:
- Primary "Copy Page" copies the lesson as Markdown to the clipboard.
- Chevron dropdown exposes:
- View as Markdown
- Open in ChatGPT
- Open in Claude
Teachers control whether this surface appears, per course, via a setting in course settings.
Motivation
Learners increasingly mix course content with AI tools — pasting lesson text into ChatGPT/Claude to ask follow-up questions, get worked examples, or debug exercises. Today they manually select-and-copy from the page, which captures HTML noise, broken whitespace, and embedded video chrome. Shipping a first-class "Copy Page" action:
- Removes the friction of switching between the lesson and an AI tool.
- Produces clean Markdown (no iframes, no video embeds, no rich-text artefacts) so the AI tool receives high-quality context.
- Matches the convention learners already see on modern documentation sites — feels native to anyone who uses them.
Scope
In scope (v1)
- Public lesson pages only:
/course/[slug]/lesson/[itemSlug]. - Per-course toggle in course settings (
metadata.allowMarkdownExport). Default: off. - Server-side HTML → Markdown conversion with videos stripped.
- A bookmarkable
.../markdownURL that returns the lesson astext/markdown. - Split button on the public lesson shell with four actions: Copy Page, View as Markdown, Open in ChatGPT, Open in Claude.
Out of scope (v1)
- Course landing pages, section pages, and any non-lesson public surface.
Open in v0(skipped — most lessons aren't UI/component focused).- Bulk export of an entire course as a single Markdown document.
- Authenticated dashboard surfaces (only public pages get the action).
- Localised Markdown output (initial release exports the lesson's current locale verbatim).
Confirmed Decisions
- Scope is lesson pages only. Landing and section pages are deferred until there is signal that learners want them.
- Per-course toggle is stored on
course.metadata. This matches the existing pattern used bylessonDownload,grading, andallowNewStudent, so no new column or migration is required. - Default value is off. Teachers must explicitly opt in. This avoids silently exposing course content as easily copyable Markdown to courses created before the feature shipped.
- HTML → Markdown conversion runs on the server, not in the browser, so the
turndowndependency does not ship to learners and a real bookmarkable.mdURL exists. - Videos are excluded from the Markdown output.
<iframe>,<video>, and known video-host anchor tags (YouTube, Vimeo, Loom) are stripped before conversion. - The same
/markdownURL serves three purposes: target of "View as Markdown", source the client fetches for "Copy Page", and the URL embedded in the AI prompt templates. - Open in ChatGPT / Open in Claude use a course-specific prompt template containing the lesson title, course title, and the public lesson URL.
- No v0 destination in v1. Revisit if usage data shows demand from teachers running code-heavy courses.
User Stories
- As a learner reading a public course lesson, I want to copy the lesson as Markdown so I can paste clean context into ChatGPT or Claude.
- As a learner reading a long technical lesson, I want a "View as Markdown" URL so I can read it in a distraction-free format and link to it.
- As a teacher publishing a public course, I want to choose whether learners can export my lesson content, so I retain control over how my material is reused.
- As a teacher with a paid course, I want this feature off by default so my content is not trivially extractable without my consent.
Feature Detail
Teacher-facing — Course Settings toggle
A new Switch is added to the existing course settings page, next to lessonDownload:
- Label: "Allow learners to copy lesson pages as Markdown"
- Helper text: "When enabled, public lesson pages show a Copy Page button that exports the lesson content (excluding videos) as Markdown."
- Off by default for both new and existing courses.
Persisted via the existing course update endpoint as metadata.allowMarkdownExport: boolean.
Learner-facing — Public lesson page
When the course has metadata.allowMarkdownExport === true, the public lesson page shell renders a split button in the top-right toolbar slot:
[ 📋 Copy Page | ⌄ ]
│
├── M↓ View as Markdown
├── 💬 Open in ChatGPT
└── ✻ Open in Claude
Primary "Copy Page" button
- Fetches
/course/<slug>/lesson/<itemSlug>/markdown(returnstext/markdown). - Writes the response body to the clipboard via the existing
copyToClipboardutility. - Shows a
Copiedsnackbar on success, error snackbar on failure.
View as Markdown
Opens /course/<slug>/lesson/<itemSlug>/markdown in a new tab. Browser renders the raw Markdown inline (response is text/markdown; charset=utf-8, no Content-Disposition: attachment so it does not download).
Open in ChatGPT
Opens https://chatgpt.com/?prompt=<encoded prompt> in a new tab.
Open in Claude
Opens https://claude.ai/new?q=<encoded prompt> in a new tab. The exact param name (q vs prompt) is verified during implementation; if neither is supported the button falls back to opening claude.ai/new so the learner can paste manually.
Prompt template (shared by ChatGPT and Claude)
I'm studying "<lesson title>" from the course "<course title>" (<public lesson URL>).
Help me understand the concepts, give examples, or help debug based on it.
Markdown output format
The endpoint returns a single Markdown document:
# <course title> — <lesson title>
<converted lesson body>
Conversion rules:
- HTML body is first run through the existing
sanitize-html.tspipeline. <iframe>,<video>, and<a>tags whosehrefmatches YouTube, Vimeo, or Loom are dropped (defence-in-depth, even though sanitiser strips<iframe>/<video>already).- Remaining HTML is converted via
turndownwith sensible defaults: ATX headings, fenced code blocks,-bullets. - Output is plain UTF-8 with
\nline endings.
Gating
GET /course/<slug>/lesson/<itemSlug>/markdown returns 404 when the course has metadata.allowMarkdownExport !== true. This makes the toggle authoritative — the URL cannot be used to bypass the teacher's preference.
Technical Notes
Touched files / new files
| Purpose | Path |
|---|---|
| Course metadata type | packages/db/src/schema.ts (extend the inline metadata type on courses) |
| Validation schema | packages/utils/src/validation/course/course.ts (ZCourseMetadata) |
| Settings UI | apps/dashboard/src/lib/features/course/pages/settings.svelte |
| Translations | apps/dashboard/src/lib/utils/translations/en.json |
| New: HTML→MD util | apps/api/src/utils/html-to-markdown.ts |
| New: markdown route | apps/api/src/routes/course/lesson-markdown.ts (mounted via the course aggregate router per CLAUDE.md rules) |
| New: copy-page button | packages/ui/src/custom/public-course/copy-page-button.svelte |
| Public lesson wiring | apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/[itemSlug]/+page.svelte and the topRight slot on packages/ui/src/custom/public-course/shell.svelte |
| Reused: clipboard util | apps/dashboard/src/lib/utils/functions/formatYoutubeVideo.ts (copyToClipboard) |
| Reused: dropdown primitives | packages/ui/src/base/dropdown-menu/ |
| Reused: HTML sanitiser | apps/api/src/utils/sanitize-html.ts |
| New dep | turndown + @types/turndown in apps/api/package.json |
No database migration is required — the toggle lives in the existing JSONB metadata column.
Translation keys
course.navItem.settings.allow_markdown_exportcourse.navItem.settings.allow_markdown_export_descriptionpublic_course.copy_page.copypublic_course.copy_page.view_as_markdownpublic_course.copy_page.open_in_chatgptpublic_course.copy_page.open_in_claudepublic_course.copy_page.copied
Risks & Open Questions
- Claude URL parameter.
claude.ai/new?q=…is the current convention but is not officially documented. Implementation must verify before merge and fall back to a no-parameter open if it breaks. - Sanitiser/turndown interaction. Some tiptap output (custom embed nodes, fancy tables) may produce odd Markdown. The PR should include a smoke test with at least one rich lesson (mixed headings, images, code blocks, a table, and a YouTube embed).
- Markdown URL as exfiltration vector. The endpoint is gated by
metadata.allowMarkdownExport, but teachers may not realise that toggling the switch on makes a public, scrapable URL available. Helper text on the setting calls this out explicitly. - Future surfaces. If learners ask for "copy entire course" or "copy section", we will revisit; the v1 endpoint shape (
/lesson/<itemSlug>/markdown) leaves room for sibling endpoints like/section/<sectionSlug>/markdown.
Verification
End-to-end checks before sign-off:
pnpm --filter @cio/db buildsucceeds with the extended metadata type.pnpm --filter @cio/api buildsucceeds with the new route andturndowndep.- Toggling the setting in course settings persists across reload.
- With the toggle off, the Copy Page button is not rendered on the public lesson page, and a direct
GET /…/markdownreturns 404. - With the toggle on:
- Copy Page → snackbar
Copied; pasted result is clean Markdown with no<iframe>/<video>. - View as Markdown → new tab shows the raw Markdown inline.
- Open in ChatGPT → new tab at
chatgpt.com/?prompt=…with the templated prompt and current public URL. - Open in Claude → new tab at
claude.ai/new?q=…with the templated prompt and current public URL.
- Copy Page → snackbar
- Lesson with embedded YouTube / Loom iframes — Markdown output has those stripped, surrounding text intact.