Files
classroomio/prd/copy-page-as-markdown/README.md
T
Rotimi Best d5b07d356e feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
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
2026-06-07 18:43:05 +01:00

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:

  1. Removes the friction of switching between the lesson and an AI tool.
  2. Produces clean Markdown (no iframes, no video embeds, no rich-text artefacts) so the AI tool receives high-quality context.
  3. 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 .../markdown URL that returns the lesson as text/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

  1. Scope is lesson pages only. Landing and section pages are deferred until there is signal that learners want them.
  2. Per-course toggle is stored on course.metadata. This matches the existing pattern used by lessonDownload, grading, and allowNewStudent, so no new column or migration is required.
  3. 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.
  4. HTML → Markdown conversion runs on the server, not in the browser, so the turndown dependency does not ship to learners and a real bookmarkable .md URL exists.
  5. Videos are excluded from the Markdown output. <iframe>, <video>, and known video-host anchor tags (YouTube, Vimeo, Loom) are stripped before conversion.
  6. The same /markdown URL serves three purposes: target of "View as Markdown", source the client fetches for "Copy Page", and the URL embedded in the AI prompt templates.
  7. Open in ChatGPT / Open in Claude use a course-specific prompt template containing the lesson title, course title, and the public lesson URL.
  8. 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

  1. Fetches /course/<slug>/lesson/<itemSlug>/markdown (returns text/markdown).
  2. Writes the response body to the clipboard via the existing copyToClipboard utility.
  3. Shows a Copied snackbar 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.ts pipeline.
  • <iframe>, <video>, and <a> tags whose href matches YouTube, Vimeo, or Loom are dropped (defence-in-depth, even though sanitiser strips <iframe>/<video> already).
  • Remaining HTML is converted via turndown with sensible defaults: ATX headings, fenced code blocks, - bullets.
  • Output is plain UTF-8 with \n line 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_export
  • course.navItem.settings.allow_markdown_export_description
  • public_course.copy_page.copy
  • public_course.copy_page.view_as_markdown
  • public_course.copy_page.open_in_chatgpt
  • public_course.copy_page.open_in_claude
  • public_course.copy_page.copied

Risks & Open Questions

  1. 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.
  2. 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).
  3. 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.
  4. 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:

  1. pnpm --filter @cio/db build succeeds with the extended metadata type.
  2. pnpm --filter @cio/api build succeeds with the new route and turndown dep.
  3. Toggling the setting in course settings persists across reload.
  4. With the toggle off, the Copy Page button is not rendered on the public lesson page, and a direct GET /…/markdown returns 404.
  5. 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.
  6. Lesson with embedded YouTube / Loom iframes — Markdown output has those stripped, surrounding text intact.