185 lines
10 KiB
Markdown
185 lines
10 KiB
Markdown
# 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.
|