From 73567a464a72f69636a0f1163a17519801807a0e Mon Sep 17 00:00:00 2001 From: ridemountainpig Date: Sun, 5 Jul 2026 15:12:48 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20share=20themes=20by=20URL=20?= =?UTF-8?q?=E2=80=94=20CLI=20theme=20add,=20gallery=20import=20+=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .changeset/theme-import-from-url.md | 5 + apps/web/content/docs/cli/meta.json | 2 +- apps/web/content/docs/cli/overview.mdx | 4 +- apps/web/content/docs/cli/theme-add.mdx | 80 ++++ apps/web/content/docs/core-feature/themes.mdx | 22 ++ apps/web/content/docs/reference/config.mdx | 11 + packages/core/README.md | 1 + .../app/components/themes/theme-detail.tsx | 18 +- .../components/themes/theme-import-dialog.tsx | 170 ++++++++ .../app/components/themes/themes-gallery.tsx | 107 ++++- packages/core/src/app/routes/themes.tsx | 6 + packages/core/src/app/virtual.d.ts | 1 + packages/core/src/cli/run.ts | 21 + packages/core/src/cli/theme.ts | 123 ++++++ packages/core/src/config.ts | 11 + packages/core/src/editing/slide-ops.test.ts | 86 ++++ packages/core/src/editing/slide-ops.ts | 99 +++++ packages/core/src/locale/en.ts | 23 ++ packages/core/src/locale/ja.ts | 24 ++ packages/core/src/locale/types.ts | 26 ++ packages/core/src/locale/zh-cn.ts | 22 ++ packages/core/src/locale/zh-tw.ts | 22 ++ packages/core/src/themes/import.ts | 372 ++++++++++++++++++ packages/core/src/themes/scan.ts | 101 +++++ packages/core/src/vite/api-plugin.ts | 2 + packages/core/src/vite/config.ts | 9 +- packages/core/src/vite/routes/context.ts | 8 + packages/core/src/vite/routes/themes.ts | 174 ++++++++ packages/core/src/vite/themes-plugin.ts | 99 ++--- 29 files changed, 1574 insertions(+), 75 deletions(-) create mode 100644 .changeset/theme-import-from-url.md create mode 100644 apps/web/content/docs/cli/theme-add.mdx create mode 100644 packages/core/src/app/components/themes/theme-import-dialog.tsx create mode 100644 packages/core/src/cli/theme.ts create mode 100644 packages/core/src/themes/import.ts create mode 100644 packages/core/src/themes/scan.ts create mode 100644 packages/core/src/vite/routes/themes.ts diff --git a/.changeset/theme-import-from-url.md b/.changeset/theme-import-from-url.md new file mode 100644 index 000000000..c9dff985a --- /dev/null +++ b/.changeset/theme-import-from-url.md @@ -0,0 +1,5 @@ +--- +"@open-slide/core": minor +--- + +Share themes by URL: copy a theme's URL, import one from another open-slide site via `open-slide theme add ` or the Themes panel, and delete a theme from the gallery. diff --git a/apps/web/content/docs/cli/meta.json b/apps/web/content/docs/cli/meta.json index a7a0b110f..e6965cdc6 100644 --- a/apps/web/content/docs/cli/meta.json +++ b/apps/web/content/docs/cli/meta.json @@ -1,4 +1,4 @@ { "title": "CLI", - "pages": ["overview", "init", "dev", "build", "preview", "sync-skills"] + "pages": ["overview", "init", "dev", "build", "preview", "theme-add", "sync-skills"] } diff --git a/apps/web/content/docs/cli/overview.mdx b/apps/web/content/docs/cli/overview.mdx index b18f16009..2f7a09d48 100644 --- a/apps/web/content/docs/cli/overview.mdx +++ b/apps/web/content/docs/cli/overview.mdx @@ -7,8 +7,8 @@ open-slide ships two CLIs: - **`@open-slide/cli`** — the scaffolder. One command (`init`) to bootstrap a new workspace. -- **`@open-slide/core`** — the runtime CLI. `dev`, `build`, `preview`, and - `sync:skills` for an existing workspace. +- **`@open-slide/core`** — the runtime CLI. `dev`, `build`, `preview`, + `theme add`, and `sync:skills` for an existing workspace. After `init`, `package.json` exposes the runtime CLI under standard scripts: diff --git a/apps/web/content/docs/cli/theme-add.mdx b/apps/web/content/docs/cli/theme-add.mdx new file mode 100644 index 000000000..1095a3e9d --- /dev/null +++ b/apps/web/content/docs/cli/theme-add.mdx @@ -0,0 +1,80 @@ +--- +title: open-slide theme add +description: Import a theme from another deployed open-slide site into your workspace. +--- + +Pull a theme — its `.md` recipe and `.demo.tsx` preview — from any +deployed open-slide site (or a running dev server) straight into your local +`themes/` folder. + +```text +open-slide theme add +``` + +The `open-slide` bin lives in your workspace, not on your global `PATH`, so +run it through your package manager from the project root: + +```text +npx open-slide theme add # npm +pnpm exec open-slide theme add # pnpm +yarn open-slide theme add # yarn +bunx open-slide theme add # bun +``` + +`` can be any of: + +- a deployed site root — `https://example.com` +- a theme manifest — `https://example.com/themes/index.json` +- a single theme file — `https://example.com/themes/corporate.md` +- a copied theme page link — `https://example.com/themes/corporate` + +When a site exposes several themes and you don't pass `--id` or `--all`, the +CLI lists them and prompts you to pick one. + +## Trust prompt + +A theme's `.demo` file is code that runs in your dev server and build. Before +importing from a host you haven't allow-listed, the CLI shows the source and +asks for confirmation. Pass `--yes` to skip it in scripts, or restrict imports +to known hosts via [`themeImport.allowedHosts`](#restricting-sources). + +## Name collisions + +Importing a theme whose id already exists copies it in as `-1`, `-2`, +… (with a matching suffixed display name) instead of overwriting — so +duplicates stay distinguishable. Pass `--force` to replace the existing theme +in place. + +## Flags + +| Flag | Default | Description | +| -------------- | ------- | -------------------------------------------------------- | +| `--id ` | — | Pick one theme by id when the source exposes several. | +| `--all` | off | Import every theme the source exposes. | +| `--force` | off | Overwrite existing theme files instead of renaming. | +| `-y, --yes` | off | Skip the source-trust confirmation prompt. | + +## Restricting sources + +To lock imports down to hosts you trust, set `themeImport.allowedHosts` in +`open-slide.config.ts`. An entry matches the host exactly or any subdomain of +it. When set, off-list hosts are rejected and the trust prompt is skipped. + +```ts title="open-slide.config.ts" +import type { OpenSlideConfig } from '@open-slide/core'; + +const openSlideConfig: OpenSlideConfig = { + themeImport: { + allowedHosts: ['themes.mycompany.com'], + }, +}; + +export default openSlideConfig; +``` + +## From the dev UI + +The same import is available in the **Themes** panel's **Import from URL** +button while running `open-slide dev`. Each theme page also has a **Copy theme +URL** button to grab a link others can import from. See +[Themes](/docs/core-feature/themes#sharing-themes). diff --git a/apps/web/content/docs/core-feature/themes.mdx b/apps/web/content/docs/core-feature/themes.mdx index 76c03f06b..1597c6440 100644 --- a/apps/web/content/docs/core-feature/themes.mdx +++ b/apps/web/content/docs/core-feature/themes.mdx @@ -85,3 +85,25 @@ and writes pages that match. Already have a deck whose look you like? Use [`/create-theme`](/docs/skills/create-theme) to extract its design tokens into a reusable theme file. + +## Sharing themes + +Themes travel between workspaces by URL. `open-slide build` emits the raw +`.md` and `.demo.*` files to `dist/themes/`, and the dev server +serves the same paths — so any deployed open-slide site (or a running dev +server) doubles as a theme source. + +- **Copy theme URL** — each theme page in the **Themes** panel has a button + that copies a link others can import from. +- **Import from URL** — paste a deployed site URL, a `themes/index.json`, or a + single `.md` to pull the theme into your local `themes/` folder. The + same import runs on the command line via + [`open-slide theme add `](/docs/cli/theme-add). +- **Delete** — the per-theme menu removes a theme's files and clears + `meta.theme` from any slides that referenced it. + + +A theme's `.demo` file is code that runs in your dev server and build. Only +import from sources you trust — or restrict imports with +[`themeImport.allowedHosts`](/docs/cli/theme-add#restricting-sources). + diff --git a/apps/web/content/docs/reference/config.mdx b/apps/web/content/docs/reference/config.mdx index ef26d6cc0..403f91852 100644 --- a/apps/web/content/docs/reference/config.mdx +++ b/apps/web/content/docs/reference/config.mdx @@ -41,6 +41,17 @@ type OpenSlideConfig = { locale?: Locale; /** Build-time UI toggles. */ build?: OpenSlideBuildConfig; + /** Restrict where themes may be imported from. */ + themeImport?: OpenSlideThemeImportConfig; +}; + +type OpenSlideThemeImportConfig = { + /** + * Hosts themes may be imported from (exact match or a subdomain of an + * entry). Leave unset to allow any host — import still warns first, since a + * theme's demo file is executable code. + */ + allowedHosts?: string[]; }; ``` diff --git a/packages/core/README.md b/packages/core/README.md index bedc7427d..48f0f2ee2 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -25,6 +25,7 @@ Once installed, the `open-slide` bin is available in the workspace: | `open-slide dev` | Start the dev server. Flags: `-p, --port `, `--host [host]`, `--open`. | | `open-slide build` | Build a static site. Flags: `--out-dir ` (defaults to `dist`). | | `open-slide preview` | Preview the production build. Flags: `-p, --port `, `--host [host]`, `--open`. | +| `open-slide theme add ` | Import a theme from a deployed open-slide site. Flags: `--id `, `--all`, `--force`, `-y, --yes`. | ## Config diff --git a/packages/core/src/app/components/themes/theme-detail.tsx b/packages/core/src/app/components/themes/theme-detail.tsx index eaeffe627..3539cc7be 100644 --- a/packages/core/src/app/components/themes/theme-detail.tsx +++ b/packages/core/src/app/components/themes/theme-detail.tsx @@ -1,6 +1,7 @@ -import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { ChevronDown, ChevronLeft, ChevronRight, Link2 } from 'lucide-react'; import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { format, useLocale } from '@/lib/use-locale'; import { cn } from '@/lib/utils'; @@ -75,6 +76,17 @@ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () = const Current = pages[pageIndex]; + const handleCopyUrl = async () => { + const base = import.meta.env.BASE_URL.replace(/\/$/, ''); + const shareUrl = `${window.location.origin}${base}/themes/${theme.id}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success(t.themes.copyUrlSuccess); + } catch { + toast.error(t.themes.copyUrlFailed); + } + }; + return (
@@ -82,6 +94,10 @@ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () = {t.themes.backToGallery} +
diff --git a/packages/core/src/app/components/themes/theme-import-dialog.tsx b/packages/core/src/app/components/themes/theme-import-dialog.tsx new file mode 100644 index 000000000..84ed0c44f --- /dev/null +++ b/packages/core/src/app/components/themes/theme-import-dialog.tsx @@ -0,0 +1,170 @@ +import config from 'virtual:open-slide/config'; +import { AlertTriangle, Download, Loader2 } from 'lucide-react'; +import { type FormEvent, useId, useState } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { format, useLocale } from '@/lib/use-locale'; + +type DiscoveredTheme = { id: string; name: string; description: string }; + +type ImportResponse = { + ok?: boolean; + written?: Array<{ id: string; requestedId: string; renamed: boolean }>; + discovered?: DiscoveredTheme[]; + error?: string; +}; + +// Matches the CLI, which skips its trust prompt when imports are already +// restricted to allow-listed hosts. +const restrictedToAllowedHosts = (config.themeImport?.allowedHosts?.length ?? 0) > 0; + +export function ThemeImportDialog() { + const t = useLocale(); + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(''); + const [busy, setBusy] = useState(false); + const [discovered, setDiscovered] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const inputId = useId(); + + function resetDiscovery() { + setDiscovered(null); + setSelectedIds([]); + } + + function toggleSelected(id: string) { + setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + } + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + const value = url.trim(); + if (!value || busy) return; + if (discovered && selectedIds.length === 0) return; + setBusy(true); + try { + const res = await fetch('/__themes/import', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(discovered ? { url: value, ids: selectedIds } : { url: value }), + }); + const body = (await res.json().catch(() => ({}))) as ImportResponse; + if (!res.ok || !body.ok) { + throw new Error(body.error ?? `HTTP ${res.status}`); + } + if (body.discovered) { + setDiscovered(body.discovered); + setSelectedIds(body.discovered.map((d) => d.id)); + return; + } + const written = body.written ?? []; + const renames = written + .filter((w) => w.renamed) + .map((w) => `${w.requestedId} → ${w.id}`) + .join(', '); + toast.success( + format(t.themes.importSuccess, { ids: written.map((w) => w.id).join(', ') || '—' }), + renames ? { description: format(t.themes.importRenamed, { renames }) } : undefined, + ); + setUrl(''); + resetDiscovery(); + setOpen(false); + } catch (err) { + toast.error(format(t.themes.importFailed, { msg: (err as Error).message })); + } finally { + setBusy(false); + } + } + + return ( + { + if (busy) return; + setOpen(next); + if (!next) resetDiscovery(); + }} + > + + + + +
+ + {t.themes.importDialogTitle} + {t.themes.importDialogDescription} + +
+ { + setUrl(e.target.value); + resetDiscovery(); + }} + /> + {discovered && ( +
+

+ {format(t.themes.importMultipleFound, { count: String(discovered.length) })} +

+
+ {discovered.map((theme) => ( + + ))} +
+
+ )} + {!restrictedToAllowedHosts && ( +

+ + {t.themes.importWarning} +

+ )} +
+ + + +
+
+
+ ); +} diff --git a/packages/core/src/app/components/themes/themes-gallery.tsx b/packages/core/src/app/components/themes/themes-gallery.tsx index d970e07aa..5b95b3932 100644 --- a/packages/core/src/app/components/themes/themes-gallery.tsx +++ b/packages/core/src/app/components/themes/themes-gallery.tsx @@ -1,9 +1,34 @@ +import { MoreHorizontal, Trash2 } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { format, useLocale } from '@/lib/use-locale'; import { SlidePageProvider } from '../../lib/page-context'; import { loadThemeDemo, type Theme, type ThemeDemoModule, themes } from '../../lib/themes'; import { SlideCanvas } from '../slide-canvas'; +async function deleteThemeReq(id: string): Promise { + const res = await fetch(`/__themes/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? `HTTP ${res.status}`); + } +} + export function ThemesGallery({ onOpen }: { onOpen: (id: string) => void }) { const t = useLocale(); @@ -14,18 +39,98 @@ export function ThemesGallery({ onOpen }: { onOpen: (id: string) => void }) { return (
    {themes.map((theme) => ( -
  • +
  • onOpen(theme.id)} ariaLabel={format(t.themes.openThemeAria, { name: theme.name })} /> + {import.meta.env.DEV ? : null}
  • ))}
); } +function ThemeCardMenu({ theme }: { theme: Theme }) { + const t = useLocale(); + const [confirmOpen, setConfirmOpen] = useState(false); + + return ( + <> + + + + + + setConfirmOpen(true)}> + + {t.common.delete} + + + + + + ); +} + +function DeleteThemeDialog({ + theme, + open, + onOpenChange, +}: { + theme: Theme; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const t = useLocale(); + const [busy, setBusy] = useState(false); + const [descPrefix, descSuffix] = t.themes.deleteDialogDescription.split('{name}'); + + const confirm = async () => { + setBusy(true); + try { + await deleteThemeReq(theme.id); + toast.success(format(t.themes.deleteSuccess, { name: theme.name })); + onOpenChange(false); + } catch { + toast.error(t.themes.deleteFailed); + } finally { + setBusy(false); + } + }; + + return ( + !busy && onOpenChange(next)}> + + + {t.themes.deleteEyebrow} + {t.themes.deleteDialogTitle} + + {descPrefix} + {theme.name} + {descSuffix} + + + + + + + + + ); +} + function ThemeCard({ theme, onOpen, diff --git a/packages/core/src/app/routes/themes.tsx b/packages/core/src/app/routes/themes.tsx index 06c17e963..b89ad0030 100644 --- a/packages/core/src/app/routes/themes.tsx +++ b/packages/core/src/app/routes/themes.tsx @@ -2,6 +2,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { useLocale } from '@/lib/use-locale'; import { FolderIconChip } from '../components/sidebar/folder-item'; import { ThemeDetail } from '../components/themes/theme-detail'; +import { ThemeImportDialog } from '../components/themes/theme-import-dialog'; import { ThemesGallery } from '../components/themes/themes-gallery'; import { themes as themeRegistry } from '../lib/themes'; @@ -19,6 +20,11 @@ export function ThemesGalleryPage() { {themeRegistry.length.toString().padStart(2, '0')} + {import.meta.env.DEV ? ( +
+ +
+ ) : null}
navigate(`/themes/${encodeURIComponent(id)}`)} /> diff --git a/packages/core/src/app/virtual.d.ts b/packages/core/src/app/virtual.d.ts index 2ef011582..7689899c8 100644 --- a/packages/core/src/app/virtual.d.ts +++ b/packages/core/src/app/virtual.d.ts @@ -14,6 +14,7 @@ declare module 'virtual:open-slide/config' { slidesDir?: string; port?: number; locale?: Locale; + themeImport?: { allowedHosts?: string[] }; version: string; build: { showSlideBrowser: boolean; diff --git a/packages/core/src/cli/run.ts b/packages/core/src/cli/run.ts index cc39c353f..d7e2bb852 100644 --- a/packages/core/src/cli/run.ts +++ b/packages/core/src/cli/run.ts @@ -81,6 +81,13 @@ interface SyncFlags { dryRun?: boolean; } +interface ThemeAddFlags { + id?: string; + all?: boolean; + force?: boolean; + yes?: boolean; +} + function resolveBuiltinSkillsDir(): string { // dist/cli/bin.js → ../../skills (package root + /skills) const here = path.dirname(fileURLToPath(import.meta.url)); @@ -133,6 +140,20 @@ export async function run(argv: string[]): Promise { await preview(flags); }); + const theme = program.command('theme').description('Manage themes'); + theme + .command('add') + .argument('', 'theme .md URL, themes/index.json, or a deployed open-slide site URL') + .description('Add a theme from a deployed open-slide URL into this workspace') + .option('--id ', 'pick a theme by id when the source exposes several') + .option('--all', 'import every theme the source exposes') + .option('--force', 'overwrite existing theme files') + .option('-y, --yes', 'skip the source-trust confirmation prompt') + .action(async (url: string, flags: ThemeAddFlags) => { + const { themeAdd } = await import('./theme.ts'); + await themeAdd(url, flags); + }); + program .command('sync:skills') .description('Sync built-in skills from @open-slide/core into this workspace') diff --git a/packages/core/src/cli/theme.ts b/packages/core/src/cli/theme.ts new file mode 100644 index 000000000..9383958df --- /dev/null +++ b/packages/core/src/cli/theme.ts @@ -0,0 +1,123 @@ +import path from 'node:path'; +import * as readline from 'node:readline/promises'; +import chalk from 'chalk'; +import { + discoverThemes, + fetchTheme, + type RemoteThemeEntry, + ThemeImportError, + writeTheme, +} from '../themes/import.ts'; +import { loadUserConfig } from '../vite/open-slide-plugin.ts'; + +export interface ThemeAddOptions { + id?: string; + all?: boolean; + force?: boolean; + yes?: boolean; +} + +function isInteractive(): boolean { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +async function confirm(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = (await rl.question(`${question} ${chalk.dim('(y/N) ')}`)).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } +} + +async function selectEntries( + entries: RemoteThemeEntry[], + opts: ThemeAddOptions, +): Promise { + if (opts.id) { + const match = entries.filter((e) => e.id === opts.id); + if (match.length === 0) { + const available = entries.map((e) => e.id).join(', ') || '(none)'; + throw new ThemeImportError( + 'not-found', + `Theme "${opts.id}" not found at source. Available: ${available}`, + ); + } + return match; + } + if (entries.length === 1 || opts.all) return entries; + + if (!isInteractive()) { + const available = entries.map((e) => e.id).join(', '); + throw new ThemeImportError( + 'invalid', + `Source exposes ${entries.length} themes (${available}). Pass --id or --all.`, + ); + } + + process.stdout.write(`${chalk.bold('Themes available at this source:')}\n`); + entries.forEach((e, i) => { + process.stdout.write(` ${chalk.cyan(String(i + 1))}. ${e.name} ${chalk.dim(`(${e.id})`)}\n`); + }); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = ( + await rl.question(`Select a number, ${chalk.bold('a')} for all, or blank to cancel: `) + ) + .trim() + .toLowerCase(); + if (answer === '') throw new ThemeImportError('invalid', 'Cancelled.'); + if (answer === 'a' || answer === 'all') return entries; + const n = Number(answer); + if (!Number.isInteger(n) || n < 1 || n > entries.length) { + throw new ThemeImportError('invalid', `Invalid selection: ${answer}`); + } + return [entries[n - 1]]; + } finally { + rl.close(); + } +} + +export async function themeAdd(url: string, opts: ThemeAddOptions = {}): Promise { + const cwd = process.cwd(); + const config = await loadUserConfig(cwd); + const themesDir = config.themesDir ?? 'themes'; + const themesRoot = path.resolve(cwd, themesDir); + const allowedHosts = config.themeImport?.allowedHosts; + + const { origin, entries } = await discoverThemes(url, allowedHosts); + if (entries.length === 0) { + throw new ThemeImportError('not-found', `No themes found at ${url}`); + } + + const selected = await selectEntries(entries, opts); + + const restricted = Boolean(allowedHosts && allowedHosts.length > 0); + if (!opts.yes && !restricted) { + process.stdout.write( + `${chalk.yellow('!')} Importing from ${chalk.bold(origin)}. A theme's ${chalk.bold('.demo')} file is code that runs in your dev server and build.\n`, + ); + if (!isInteractive()) { + throw new ThemeImportError( + 'forbidden', + 'Refusing to import from an untrusted host without confirmation. Re-run with --yes, or add the host to themeImport.allowedHosts.', + ); + } + const ok = await confirm(` Import ${selected.length} theme(s) from this source?`); + if (!ok) { + process.stdout.write(chalk.dim('Cancelled.\n')); + return; + } + } + + for (const entry of selected) { + const fetched = await fetchTheme(entry, allowedHosts); + const result = await writeTheme(themesRoot, fetched, { force: opts.force }); + const files = result.written.join(', '); + const renamed = result.renamed ? chalk.dim(` (renamed from ${result.requestedId})`) : ''; + process.stdout.write( + `${chalk.green('✓')} Added ${chalk.bold(result.id)}${renamed} ${chalk.dim(files)}\n`, + ); + } +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f98868278..6368296fd 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -6,12 +6,23 @@ export type OpenSlideBuildConfig = { allowHtmlDownload?: boolean; }; +export type OpenSlideThemeImportConfig = { + /** + * When set, themes may only be imported from these hosts (exact match or a + * subdomain of an entry). Leave unset to allow any host — the CLI/UI still + * asks for confirmation before importing, since a theme's demo file is + * executable code. + */ + allowedHosts?: string[]; +}; + export type OpenSlideConfig = { base?: string; slidesDir?: string; themesDir?: string; assetsDir?: string; port?: number; + themeImport?: OpenSlideThemeImportConfig; /** * @deprecated Pick the UI language from the language switcher in the slide UI * instead. When set, this only seeds the initial language until the user diff --git a/packages/core/src/editing/slide-ops.test.ts b/packages/core/src/editing/slide-ops.test.ts index a3b5c552c..5a2a8f8ca 100644 --- a/packages/core/src/editing/slide-ops.test.ts +++ b/packages/core/src/editing/slide-ops.test.ts @@ -3,9 +3,11 @@ import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { + clearThemeFromSlides, duplicateNotesElementInSource, duplicatePageInDefaultExportInSource, duplicateSlideDir, + removeMetaThemeFromSource, removeNotesElementInSource, removePageFromDefaultExportInSource, reorderDefaultExportPagesInSource, @@ -150,6 +152,90 @@ describe('updateMetaTitleInSource', () => { }); }); +describe('removeMetaThemeFromSource', () => { + it('drops the theme line from a multi-line meta and leaves siblings intact', () => { + const source = `export const meta: SlideMeta = {\n title: 'Raycast',\n theme: 'aurora',\n createdAt: '2026-04-25',\n};\nexport default [];\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe( + `export const meta: SlideMeta = {\n title: 'Raycast',\n createdAt: '2026-04-25',\n};\nexport default [];\n`, + ); + }); + + it('handles theme as the trailing property with no comma', () => { + const source = `export const meta = {\n title: 'X',\n theme: 'aurora'\n};\nexport default [];\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = {\n title: 'X',\n};\nexport default [];\n`); + }); + + it('removes the leading comma when theme is inline and trailing', () => { + const source = `export const meta = { title: 'X', theme: 'aurora' };\nexport default [];\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = { title: 'X' };\nexport default [];\n`); + }); + + it('keeps the separating comma when theme is inline and in the middle', () => { + const source = `export const meta = { title: 'X', theme: 'aurora', createdAt: 'y' };\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = { title: 'X', createdAt: 'y' };\n`); + }); + + it('returns null when the theme names a different id', () => { + const source = `export const meta = {\n theme: 'replit',\n};\n`; + expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); + }); + + it('leaves properties whose name merely ends in "theme" alone', () => { + const source = `export const meta = {\n subtheme: 'aurora',\n title: 'X',\n};\n`; + expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); + }); + + it('leaves string values that happen to contain a theme field alone', () => { + const source = `export const meta = {\n title: "Our theme: 'aurora' rollout",\n};\n`; + expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); + }); + + it('returns null when there is no theme field', () => { + const source = `export const meta = {\n title: 'X',\n};\n`; + expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); + }); + + it('returns null when there is no meta export', () => { + expect(removeMetaThemeFromSource('export default [];\n', 'aurora')).toBeNull(); + }); +}); + +describe('clearThemeFromSlides', () => { + it('strips meta.theme only from slides that use the given theme', async () => { + await withSlidesRoot(async (root) => { + const write = async (id: string, theme: string) => { + await fs.mkdir(path.join(root, id), { recursive: true }); + await fs.writeFile( + path.join(root, id, 'index.tsx'), + `export const meta = {\n title: '${id}',\n theme: '${theme}',\n};\nexport default [];\n`, + 'utf8', + ); + }; + await write('uses-aurora', 'aurora'); + await write('uses-other', 'replit'); + + const cleared = await clearThemeFromSlides(root, 'aurora'); + expect(cleared).toEqual(['uses-aurora']); + + const a = await fs.readFile(path.join(root, 'uses-aurora', 'index.tsx'), 'utf8'); + const b = await fs.readFile(path.join(root, 'uses-other', 'index.tsx'), 'utf8'); + expect(a).not.toContain('theme:'); + expect(a).toContain("title: 'uses-aurora'"); + expect(b).toContain("theme: 'replit'"); + }); + }); + + it('returns an empty list when the slides root is missing', async () => { + expect( + await clearThemeFromSlides(path.join(os.tmpdir(), 'open-slide-nope-xyz'), 'aurora'), + ).toEqual([]); + }); +}); + describe('reorderDefaultExportPagesInSource', () => { const withSatisfies = `import type { Page } from '@open-slide/core'; const A = () => null; diff --git a/packages/core/src/editing/slide-ops.ts b/packages/core/src/editing/slide-ops.ts index b040d0161..7a8cd23db 100644 --- a/packages/core/src/editing/slide-ops.ts +++ b/packages/core/src/editing/slide-ops.ts @@ -191,6 +191,105 @@ function escapeSingleQuoted(s: string): string { return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } +const SLIDE_ENTRY_NAMES = ['index.tsx', 'index.jsx', 'index.ts', 'index.js']; + +/** + * Remove the `theme: ''` field from a slide module's `export const meta`, + * but only when it names `themeId`. Returns the rewritten source, or `null` when + * nothing changed (no meta object, no theme field, or it points at a different + * theme). Best-effort: returns `null` rather than guessing when the meta shape is + * too surprising to edit safely. + */ +export function removeMetaThemeFromSource(source: string, themeId: string): string | null { + const metaStart = source.search(/export\s+const\s+meta\b/); + if (metaStart === -1) return null; + const eqIdx = source.indexOf('=', metaStart); + if (eqIdx === -1) return null; + const openBrace = source.indexOf('{', eqIdx); + if (openBrace === -1) return null; + + let depth = 0; + let closeBrace = -1; + for (let i = openBrace; i < source.length; i++) { + const ch = source[i]; + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) { + closeBrace = i; + break; + } + } + } + if (closeBrace === -1) return null; + + const body = source.slice(openBrace + 1, closeBrace); + // The leading boundary keeps this from matching inside a longer key (e.g. + // `subtheme:`); the trailing lookahead keeps it from matching `theme: '…'` + // embedded in a string value, where the quote isn't followed by a separator. + const propRe = /(^|[\s,{])theme\s*:\s*(['"`])((?:\\.|(?!\2).)*)\2(?=\s*(?:[,}\n]|$))/; + const match = propRe.exec(body); + if (!match || match[3] !== themeId) return null; + + let start = match.index + match[1].length; + let end = match.index + match[0].length; + + // Consume exactly one separator comma so the object stays well-formed: prefer a + // trailing comma, otherwise fall back to a leading one. + const trailingComma = body.slice(end).match(/^[ \t]*,[ \t]*/); + if (trailingComma) { + end += trailingComma[0].length; + } else { + const leadingComma = body.slice(0, start).match(/,[ \t]*$/); + if (leadingComma) start -= leadingComma[0].length; + } + + // If the property sat alone on its line, drop the now-empty line entirely. + const lineStart = body.lastIndexOf('\n', start - 1); + if (/^[ \t]*$/.test(body.slice(lineStart + 1, start))) { + start = lineStart === -1 ? 0 : lineStart; + const trailingWs = body.slice(end).match(/^[ \t]*(?=\n|$)/); + if (trailingWs) end += trailingWs[0].length; + } + + const newBody = body.slice(0, start) + body.slice(end); + return source.slice(0, openBrace + 1) + newBody + source.slice(closeBrace); +} + +/** + * Strip `meta.theme` from every slide that names `themeId`, in place. Returns the + * ids of the slides that were edited. + */ +export async function clearThemeFromSlides(slidesRoot: string, themeId: string): Promise { + const dirents = await fs.readdir(slidesRoot, { withFileTypes: true }).catch(() => null); + if (!dirents) return []; + + const cleared: string[] = []; + for (const dirent of dirents) { + if (!dirent.isDirectory() || !SLIDE_ID_RE.test(dirent.name)) continue; + for (const name of SLIDE_ENTRY_NAMES) { + const entry = path.join(slidesRoot, dirent.name, name); + let src: string; + try { + src = await fs.readFile(entry, 'utf8'); + } catch { + continue; + } + const next = removeMetaThemeFromSource(src, themeId); + if (next !== null) { + // One unwritable slide must not abort the sweep mid-way — a dangling + // meta.theme is the same state as deleting the theme files by hand. + try { + await fs.writeFile(entry, next); + cleared.push(dirent.name); + } catch {} + } + break; + } + } + return cleared; +} + /** * Rewrite (or insert) the `title` field in the slide module's `export const meta`. * diff --git a/packages/core/src/locale/en.ts b/packages/core/src/locale/en.ts index 02d549a7f..3f25cfbf3 100644 --- a/packages/core/src/locale/en.ts +++ b/packages/core/src/locale/en.ts @@ -414,5 +414,28 @@ export const en: Locale = { usedByEmpty: 'No slides use this theme yet.', expandPromptAria: 'Expand prompt', collapsePromptAria: 'Collapse prompt', + importFromUrl: 'Import from URL', + importDialogTitle: 'Import a theme', + importDialogDescription: + 'Paste a deployed open-slide site URL, a themes/index.json, or a theme .md file.', + importUrlPlaceholder: 'https://example.com', + importWarning: + 'A theme’s demo file is code that runs in your dev server and build. Only import from sources you trust.', + importAction: 'Import', + importing: 'Importing…', + importMultipleFound: 'Found {count} themes at this source. Choose which to import.', + importSuccess: 'Imported {ids}', + importRenamed: 'Renamed to avoid a clash: {renames}', + importFailed: 'Import failed: {msg}', + copyUrl: 'Copy theme URL', + copyUrlSuccess: 'Theme URL copied', + copyUrlFailed: 'Failed to copy URL', + themeActions: 'Theme actions', + deleteEyebrow: 'Destructive', + deleteDialogTitle: 'Delete theme?', + deleteDialogDescription: + 'This permanently removes {name} and its files from disk. This cannot be undone.', + deleteSuccess: 'Deleted {name}', + deleteFailed: 'Failed to delete theme', }, }; diff --git a/packages/core/src/locale/ja.ts b/packages/core/src/locale/ja.ts index dd61c8b2d..cfbe74c2d 100644 --- a/packages/core/src/locale/ja.ts +++ b/packages/core/src/locale/ja.ts @@ -420,5 +420,29 @@ export const ja: Locale = { usedByEmpty: 'このテーマを使うスライドはまだありません。', expandPromptAria: 'プロンプトを展開', collapsePromptAria: 'プロンプトを折りたたむ', + importFromUrl: 'URL から取り込む', + importDialogTitle: 'テーマを取り込む', + importDialogDescription: + 'デプロイ済みの open-slide サイト URL、themes/index.json、またはテーマの .md ファイルを貼り付けてください。', + importUrlPlaceholder: 'https://example.com', + importWarning: + 'テーマの demo ファイルは開発サーバーと build で実行されるコードです。信頼できるソースからのみ取り込んでください。', + importAction: '取り込む', + importing: '取り込み中…', + importMultipleFound: + 'このソースには {count} 件のテーマがあります。取り込むテーマを選択してください。', + importSuccess: '{ids} を取り込みました', + importRenamed: '重複を避けるため名前を変更しました: {renames}', + importFailed: '取り込みに失敗しました: {msg}', + copyUrl: 'テーマ URL をコピー', + copyUrlSuccess: 'テーマ URL をコピーしました', + copyUrlFailed: 'URL のコピーに失敗しました', + themeActions: 'テーマの操作', + deleteEyebrow: '破壊的な操作', + deleteDialogTitle: 'テーマを削除しますか?', + deleteDialogDescription: + '{name} とそのファイルをディスクから完全に削除します。この操作は取り消せません。', + deleteSuccess: '{name} を削除しました', + deleteFailed: 'テーマの削除に失敗しました', }, }; diff --git a/packages/core/src/locale/types.ts b/packages/core/src/locale/types.ts index fdbd57d47..06017f9fe 100644 --- a/packages/core/src/locale/types.ts +++ b/packages/core/src/locale/types.ts @@ -441,5 +441,31 @@ export type Locale = { usedByEmpty: string; expandPromptAria: string; collapsePromptAria: string; + importFromUrl: string; + importDialogTitle: string; + importDialogDescription: string; + importUrlPlaceholder: string; + importWarning: string; + importAction: string; + importing: string; + /** template: "Found {count} themes at this source. Choose which to import." */ + importMultipleFound: string; + /** template: "Imported {ids}" */ + importSuccess: string; + /** template: "Renamed: {renames}" */ + importRenamed: string; + /** template: "Import failed: {msg}" */ + importFailed: string; + copyUrl: string; + copyUrlSuccess: string; + copyUrlFailed: string; + themeActions: string; + deleteEyebrow: string; + deleteDialogTitle: string; + /** template: "This permanently removes {name} and its files from disk. This cannot be undone." */ + deleteDialogDescription: string; + /** template: "Deleted {name}" */ + deleteSuccess: string; + deleteFailed: string; }; }; diff --git a/packages/core/src/locale/zh-cn.ts b/packages/core/src/locale/zh-cn.ts index 0a1458752..2a2dcc889 100644 --- a/packages/core/src/locale/zh-cn.ts +++ b/packages/core/src/locale/zh-cn.ts @@ -412,5 +412,27 @@ export const zhCN: Locale = { usedByEmpty: '尚未有 slides 使用此主题。', expandPromptAria: '展开 prompt', collapsePromptAria: '收起 prompt', + importFromUrl: '从 URL 导入', + importDialogTitle: '导入主题', + importDialogDescription: + '粘贴已部署的 open-slide 网站 URL、themes/index.json,或主题的 .md 文件。', + importUrlPlaceholder: 'https://example.com', + importWarning: + '主题的 demo 文件是会在你的开发服务器与 build 中执行的代码。请只从你信任的来源导入。', + importAction: '导入', + importing: '导入中…', + importMultipleFound: '此来源包含 {count} 个主题,请选择要导入的主题。', + importSuccess: '已导入 {ids}', + importRenamed: '为避免重名已重命名:{renames}', + importFailed: '导入失败:{msg}', + copyUrl: '复制主题 URL', + copyUrlSuccess: '已复制主题 URL', + copyUrlFailed: '复制 URL 失败', + themeActions: '主题操作', + deleteEyebrow: '危险操作', + deleteDialogTitle: '删除主题?', + deleteDialogDescription: '这会永久从磁盘移除 {name} 及其文件,此操作无法撤销。', + deleteSuccess: '已删除 {name}', + deleteFailed: '删除主题失败', }, }; diff --git a/packages/core/src/locale/zh-tw.ts b/packages/core/src/locale/zh-tw.ts index 4be364fee..575a48453 100644 --- a/packages/core/src/locale/zh-tw.ts +++ b/packages/core/src/locale/zh-tw.ts @@ -412,5 +412,27 @@ export const zhTW: Locale = { usedByEmpty: '尚未有 slides 使用此主題。', expandPromptAria: '展開 prompt', collapsePromptAria: '收合 prompt', + importFromUrl: '從 URL 匯入', + importDialogTitle: '匯入主題', + importDialogDescription: + '貼上已部署的 open-slide 網站 URL、themes/index.json,或主題的 .md 檔案。', + importUrlPlaceholder: 'https://example.com', + importWarning: + '主題的 demo 檔案是會在你的開發伺服器與 build 中執行的程式碼。請只從你信任的來源匯入。', + importAction: '匯入', + importing: '匯入中…', + importMultipleFound: '此來源包含 {count} 個主題,請選擇要匯入的主題。', + importSuccess: '已匯入 {ids}', + importRenamed: '為避免重名已重新命名:{renames}', + importFailed: '匯入失敗:{msg}', + copyUrl: '複製主題 URL', + copyUrlSuccess: '已複製主題 URL', + copyUrlFailed: '複製 URL 失敗', + themeActions: '主題操作', + deleteEyebrow: '危險操作', + deleteDialogTitle: '刪除主題?', + deleteDialogDescription: '這會永久從磁碟移除 {name} 及其檔案,此操作無法復原。', + deleteSuccess: '已刪除 {name}', + deleteFailed: '刪除主題失敗', }, }; diff --git a/packages/core/src/themes/import.ts b/packages/core/src/themes/import.ts new file mode 100644 index 000000000..4cc29836f --- /dev/null +++ b/packages/core/src/themes/import.ts @@ -0,0 +1,372 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { DEMO_EXTS, FM_RE, parseFrontmatter } from './scan.ts'; + +// Matches what the local scanner accepts (any safe .md basename) so every +// locally working theme stays shareable; dots and separators stay excluded +// because the id is joined into filesystem paths. +export const THEME_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; +const MAX_BYTES = 1024 * 1024; +const FETCH_TIMEOUT_MS = 30_000; +export const THEME_MANIFEST_PATH = 'themes/index.json'; + +export class ThemeImportError extends Error { + readonly code: 'invalid' | 'forbidden' | 'not-found' | 'network'; + constructor(code: ThemeImportError['code'], message: string) { + super(message); + this.name = 'ThemeImportError'; + this.code = code; + } +} + +export type RemoteThemeEntry = { + id: string; + name: string; + description: string; + mdUrl: string; + demoCandidates: string[]; +}; + +export type FetchedTheme = { + id: string; + name: string; + description: string; + md: string; + demo: { filename: string; source: string } | null; +}; + +export type DiscoverResult = { + origin: string; + entries: RemoteThemeEntry[]; +}; + +function assertHostAllowed(url: URL, allowedHosts?: string[]): void { + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ThemeImportError('invalid', `Only http(s) URLs are supported: ${url.href}`); + } + if (!allowedHosts || allowedHosts.length === 0) return; + const hostname = url.hostname.toLowerCase(); + const ok = allowedHosts.some((h) => { + const n = h.trim().toLowerCase().replace(/^\*\./, ''); + return n.length > 0 && (hostname === n || hostname.endsWith(`.${n}`)); + }); + if (!ok) { + throw new ThemeImportError('forbidden', `Host not in themeImport.allowedHosts: ${url.host}`); + } +} + +function parseUrl(raw: string): URL { + try { + return new URL(raw); + } catch { + throw new ThemeImportError('invalid', `Invalid URL: ${raw}`); + } +} + +async function readLimited(res: Response, label: string): Promise { + const tooLarge = () => + new ThemeImportError('invalid', `File too large (> ${MAX_BYTES} bytes): ${label}`); + const declared = Number(res.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_BYTES) throw tooLarge(); + if (!res.body) return ''; + + // Stream with a running total instead of arrayBuffer() so an oversized (or + // lying-about-content-length) response is cut off at the cap, not buffered whole. + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BYTES) { + await reader.cancel().catch(() => {}); + throw tooLarge(); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString('utf8'); +} + +async function fetchResponse(url: string, allowedHosts?: string[]): Promise { + assertHostAllowed(parseUrl(url), allowedHosts); + let res: Response; + try { + res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + } catch (err) { + const reason = + (err as Error).name === 'TimeoutError' + ? `timed out after ${FETCH_TIMEOUT_MS / 1000}s` + : (err as Error).message; + throw new ThemeImportError('network', `Fetch failed: ${url} (${reason})`); + } + // Re-check after redirects — an allowed host may 302 to an off-list one. + if (res.url) assertHostAllowed(parseUrl(res.url), allowedHosts); + return res; +} + +async function fetchText(url: string, allowedHosts?: string[]): Promise { + const res = await fetchResponse(url, allowedHosts); + if (!res.ok) { + throw new ThemeImportError( + res.status === 404 ? 'not-found' : 'network', + `Fetch failed (${res.status}): ${url}`, + ); + } + return readLimited(res, url); +} + +// Returns null on any miss, including HTML responses — SPA-fallback hosts answer +// 200 + index.html for paths that don't exist, which must never count as a hit. +async function tryFetchText(url: string, allowedHosts?: string[]): Promise { + try { + const res = await fetchResponse(url, allowedHosts); + if (!res.ok) return null; + const text = await readLimited(res, url); + return looksLikeHtml(text) ? null : text; + } catch (err) { + if (err instanceof ThemeImportError && err.code === 'forbidden') throw err; + return null; + } +} + +function looksLikeHtml(text: string): boolean { + const head = text.trimStart().slice(0, 200).toLowerCase(); + return head.startsWith(' new URL(`${id}.demo.${ext}`, baseUrl).href); +} + +function entryFromMdUrl(url: URL): RemoteThemeEntry { + const id = path.posix.basename(url.pathname).replace(/\.md$/i, ''); + if (!THEME_ID_RE.test(id)) { + throw new ThemeImportError('invalid', `Theme id derived from URL is invalid: "${id}"`); + } + return { + id, + name: id, + description: '', + mdUrl: url.href, + demoCandidates: demoCandidatesFor(id, url), + }; +} + +type ManifestEntry = { + id?: unknown; + name?: unknown; + description?: unknown; + md?: unknown; + demo?: unknown; +}; + +function normalizeManifestEntry(raw: ManifestEntry, manifestUrl: URL): RemoteThemeEntry | null { + const id = typeof raw.id === 'string' ? raw.id : ''; + if (!THEME_ID_RE.test(id)) return null; + const md = typeof raw.md === 'string' && raw.md ? raw.md : `${id}.md`; + const mdUrl = new URL(md, manifestUrl).href; + const demoCandidates = + typeof raw.demo === 'string' && raw.demo + ? [new URL(raw.demo, manifestUrl).href] + : raw.demo === null + ? [] + : demoCandidatesFor(id, manifestUrl); + return { + id, + name: typeof raw.name === 'string' && raw.name ? raw.name : id, + description: typeof raw.description === 'string' ? raw.description : '', + mdUrl, + demoCandidates, + }; +} + +function parseManifest(raw: string, manifestUrl: URL): RemoteThemeEntry[] { + let parsed: { themes?: unknown }; + try { + parsed = JSON.parse(raw) as { themes?: unknown }; + } catch { + throw new ThemeImportError('invalid', `Manifest is not valid JSON: ${manifestUrl.href}`); + } + const list = Array.isArray(parsed.themes) ? (parsed.themes as ManifestEntry[]) : []; + return list + .map((t) => normalizeManifestEntry(t, manifestUrl)) + .filter((e): e is RemoteThemeEntry => e !== null); +} + +async function tryManifest( + manifestUrl: URL, + allowedHosts?: string[], +): Promise { + const raw = await tryFetchText(manifestUrl.href, allowedHosts); + if (raw === null) return null; + try { + const entries = parseManifest(raw, manifestUrl); + return entries.length > 0 ? entries : null; + } catch { + return null; + } +} + +const NOT_A_FILE_HINT = + 'Point at a built/preview site (open-slide preview) — the site root, a themes/index.json, or a .md. A dev-server URL or an in-app page route (e.g. /themes/) does not serve theme files.'; + +export async function discoverThemes( + rawUrl: string, + allowedHosts?: string[], +): Promise { + const url = parseUrl(rawUrl); + assertHostAllowed(url, allowedHosts); + const pathname = url.pathname.toLowerCase(); + + if (pathname.endsWith('.md')) { + return { origin: url.origin, entries: [entryFromMdUrl(url)] }; + } + + if (pathname.endsWith('.json')) { + const raw = await fetchText(url.href, allowedHosts); + if (looksLikeHtml(raw)) { + throw new ThemeImportError( + 'invalid', + `Expected a JSON manifest but got HTML. ${NOT_A_FILE_HINT}`, + ); + } + return { origin: url.origin, entries: parseManifest(raw, url) }; + } + + const base = `${url.pathname.replace(/\/+$/, '')}`; + const lastSegment = base.split('/').pop() ?? ''; + + // A path like /themes/ is the gallery page URL — try the sibling .md + // first so it resolves to that one theme rather than the whole site manifest. + if (THEME_ID_RE.test(lastSegment)) { + const mdUrl = new URL(`${base}.md`, url); + const md = await tryFetchText(mdUrl.href, allowedHosts); + if (md !== null) { + return { origin: url.origin, entries: [entryFromMdUrl(mdUrl)] }; + } + } + + // Probe both interpretations of the path: a site root (manifest lives under + // themes/) and the themes directory itself (e.g. a pasted /themes gallery URL, + // with or without a trailing slash). + const manifestCandidates = [ + new URL(`${base}/${THEME_MANIFEST_PATH}`, url), + new URL(`${base}/index.json`, url), + ]; + for (const candidate of manifestCandidates) { + const entries = await tryManifest(candidate, allowedHosts); + if (entries) return { origin: url.origin, entries }; + } + + throw new ThemeImportError('not-found', `No theme found at ${rawUrl}. ${NOT_A_FILE_HINT}`); +} + +export async function fetchTheme( + entry: RemoteThemeEntry, + allowedHosts?: string[], +): Promise { + const md = await fetchText(entry.mdUrl, allowedHosts); + if (looksLikeHtml(md)) { + throw new ThemeImportError( + 'invalid', + `Got HTML instead of a theme file from ${entry.mdUrl}. ${NOT_A_FILE_HINT}`, + ); + } + let demo: FetchedTheme['demo'] = null; + for (const candidate of entry.demoCandidates) { + const source = await tryFetchText(candidate, allowedHosts); + if (source !== null) { + const ext = (path.posix.extname(new URL(candidate).pathname).slice(1) || 'tsx').toLowerCase(); + const safeExt = (DEMO_EXTS as readonly string[]).includes(ext) ? ext : 'tsx'; + demo = { filename: `${entry.id}.demo.${safeExt}`, source }; + break; + } + } + return { id: entry.id, name: entry.name, description: entry.description, md, demo }; +} + +export type WriteResult = { + id: string; + requestedId: string; + renamed: boolean; + written: string[]; +}; + +function formatFrontmatterValue(value: string): string { + return /^['"]/.test(value) || value.trim() !== value ? JSON.stringify(value) : value; +} + +// Rewrites (or inserts) the `name:` field in a theme's frontmatter without +// touching the body — used to disambiguate display names on import collisions. +function setThemeDisplayName(md: string, name: string): string { + const value = formatFrontmatterValue(name); + const match = md.match(FM_RE); + if (!match) { + return `---\nname: ${value}\n---\n\n${md.replace(/^\uFEFF?\s*/, '')}`; + } + let replaced = false; + const lines = match[1].split(/\r?\n/).map((line) => { + if (!replaced && /^name\s*:/.test(line)) { + replaced = true; + return `name: ${value}`; + } + return line; + }); + if (!replaced) lines.unshift(`name: ${value}`); + return `---\n${lines.join('\n')}\n---\n${match[2] ? `\n${match[2]}` : ''}`; +} + +function availableThemeId(themesRoot: string, id: string): string { + if (!existsSync(path.join(themesRoot, `${id}.md`))) return id; + for (let i = 1; i < 1000; i++) { + const candidate = `${id}-${i}`; + if (!existsSync(path.join(themesRoot, `${candidate}.md`))) return candidate; + } + throw new ThemeImportError('invalid', `Too many themes named "${id}"`); +} + +export async function writeTheme( + themesRoot: string, + fetched: FetchedTheme, + opts: { force?: boolean } = {}, +): Promise { + if (!THEME_ID_RE.test(fetched.id)) { + throw new ThemeImportError('invalid', `Invalid theme id: "${fetched.id}"`); + } + + await fs.mkdir(themesRoot, { recursive: true }); + // On a name clash, copy in as -1, -2, … rather than overwriting — + // unless --force was passed to replace the existing theme in place. + const targetId = opts.force ? fetched.id : availableThemeId(themesRoot, fetched.id); + + const written: string[] = []; + // On a collision rename (replit → replit-1), suffix the display name to match + // so the gallery shows "Replit 1" instead of a second indistinguishable "Replit". + const md = + targetId === fetched.id + ? fetched.md + : setThemeDisplayName( + fetched.md, + `${parseFrontmatter(fetched.md, fetched.id).fm.name} ${targetId.slice(fetched.id.length + 1)}`, + ); + await fs.writeFile(path.join(themesRoot, `${targetId}.md`), md); + written.push(`${targetId}.md`); + + // Clear every demo variant first: a leftover demo with a higher-priority + // extension (scan probes tsx before js) would shadow the one written below. + await Promise.all( + DEMO_EXTS.map((ext) => + fs.rm(path.join(themesRoot, `${targetId}.demo.${ext}`), { force: true }), + ), + ); + if (fetched.demo) { + const ext = (fetched.demo.filename.split('.').pop() ?? 'tsx').toLowerCase(); + const demoName = `${targetId}.demo.${ext}`; + await fs.writeFile(path.join(themesRoot, demoName), fetched.demo.source); + written.push(demoName); + } + + return { id: targetId, requestedId: fetched.id, renamed: targetId !== fetched.id, written }; +} diff --git a/packages/core/src/themes/scan.ts b/packages/core/src/themes/scan.ts new file mode 100644 index 000000000..7926efb9c --- /dev/null +++ b/packages/core/src/themes/scan.ts @@ -0,0 +1,101 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import fg from 'fast-glob'; + +export type ThemeFrontmatter = { + name: string; + description: string; +}; + +export type ScannedTheme = { + id: string; + frontmatter: ThemeFrontmatter; + body: string; + demoAbs: string | null; +}; + +export type ThemeManifestEntry = { + id: string; + name: string; + description: string; + md: string; + demo: string | null; +}; + +export const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; +export const DEMO_EXTS = ['tsx', 'jsx', 'ts', 'js'] as const; + +export function parseFrontmatter( + raw: string, + themeId: string, +): { fm: ThemeFrontmatter; body: string } { + const match = raw.match(FM_RE); + const fmText = match ? match[1] : ''; + const body = match ? match[2] : raw; + + const data: Record = {}; + for (const line of fmText.split(/\r?\n/)) { + const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); + if (!m) continue; + let value = m[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + data[m[1]] = value; + } + + return { + fm: { + name: data.name || themeId, + description: data.description || '', + }, + body: body.trim(), + }; +} + +export async function findThemeFiles(themesRoot: string): Promise { + if (!existsSync(themesRoot)) return []; + const hits = await fg('*.md', { cwd: themesRoot, absolute: true, onlyFiles: true }); + return hits.sort(); +} + +export async function readScannedTheme(mdAbs: string, themesRoot: string): Promise { + const id = path.basename(mdAbs, '.md'); + const raw = await fs.readFile(mdAbs, 'utf8'); + const { fm, body } = parseFrontmatter(raw, id); + let demoAbs: string | null = null; + for (const ext of DEMO_EXTS) { + const p = path.join(themesRoot, `${id}.demo.${ext}`); + if (existsSync(p)) { + demoAbs = p; + break; + } + } + return { id, frontmatter: fm, body, demoAbs }; +} + +export async function scanThemes(themesRoot: string): Promise { + const files = await findThemeFiles(themesRoot); + return Promise.all(files.map((f) => readScannedTheme(f, themesRoot))); +} + +export function manifestEntry(theme: ScannedTheme): ThemeManifestEntry { + return { + id: theme.id, + name: theme.frontmatter.name, + description: theme.frontmatter.description, + md: `${theme.id}.md`, + demo: theme.demoAbs ? path.basename(theme.demoAbs) : null, + }; +} + +export async function buildThemeManifest( + themesRoot: string, +): Promise<{ themes: ThemeManifestEntry[] }> { + const themes = await scanThemes(themesRoot); + return { themes: themes.map(manifestEntry) }; +} diff --git a/packages/core/src/vite/api-plugin.ts b/packages/core/src/vite/api-plugin.ts index 4906f9cba..2b49295c8 100644 --- a/packages/core/src/vite/api-plugin.ts +++ b/packages/core/src/vite/api-plugin.ts @@ -6,6 +6,7 @@ import { registerEditRoutes } from './routes/edit.ts'; import { registerFolderRoutes } from './routes/folders.ts'; import { registerSlideRoutes } from './routes/slides.ts'; import { registerSvglRoutes } from './routes/svgl.ts'; +import { registerThemeRoutes } from './routes/themes.ts'; import { registerUpdateRoutes } from './routes/update.ts'; import { registerWatchers } from './routes/watchers.ts'; @@ -26,6 +27,7 @@ export function apiPlugin(opts: ApiPluginOptions): Plugin { registerSlideRoutes(server, ctx); registerAssetRoutes(server, ctx); registerSvglRoutes(server); + registerThemeRoutes(server, ctx); registerFolderRoutes(server, ctx); registerUpdateRoutes(server, ctx); }, diff --git a/packages/core/src/vite/config.ts b/packages/core/src/vite/config.ts index 276677ed8..5c0553b7b 100644 --- a/packages/core/src/vite/config.ts +++ b/packages/core/src/vite/config.ts @@ -63,7 +63,14 @@ export async function createViteConfig(opts: CreateViteConfigOptions): Promise` page links be imported directly, +// without first running `open-slide build`. + +const STATUS_FOR_CODE: Record = { + invalid: 400, + forbidden: 403, + 'not-found': 404, + network: 502, +}; + +// Keep the id part in sync with THEME_ID_RE (themes/import.ts) so every theme +// the importer accepts is also servable from the dev server. +const THEME_FILE_RE = /^([A-Za-z0-9][A-Za-z0-9_-]*)(\.md|\.demo\.(tsx|jsx|ts|js))$/; + +const CONTENT_TYPES: Record = { + md: 'text/markdown; charset=utf-8', + tsx: 'text/plain; charset=utf-8', + jsx: 'text/plain; charset=utf-8', + ts: 'text/plain; charset=utf-8', + js: 'text/plain; charset=utf-8', +}; + +function registerThemeFileRoutes(server: ViteDevServer, ctx: ApiContext): void { + // Mount under the resolved base so copied theme URLs (which carry BASE_URL) + // resolve on the dev server exactly like they do on the built site. + const base = server.config.base.replace(/\/+$/, ''); + server.middlewares.use(`${base}/themes`, async (req, res, next) => { + if ((req.method ?? 'GET') !== 'GET') return next(); + const url = new URL(req.url ?? '/', 'http://local'); + const rel = decodeURIComponent(url.pathname.replace(/^\/+/, '')); + + try { + if (rel === 'index.json') { + const manifest = await buildThemeManifest(ctx.themesRoot); + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.setHeader('cache-control', 'no-store'); + res.end(`${JSON.stringify(manifest, null, 2)}\n`); + return; + } + + const match = rel.match(THEME_FILE_RE); + if (!match) return next(); + + const file = path.join(ctx.themesRoot, rel); + if (file !== path.join(ctx.themesRoot, path.basename(rel))) return next(); + + const ext = (rel.split('.').pop() ?? '').toLowerCase(); + let buf: Buffer; + try { + buf = await fs.readFile(file); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return next(); + throw err; + } + res.statusCode = 200; + res.setHeader('content-type', CONTENT_TYPES[ext] ?? 'text/plain; charset=utf-8'); + res.setHeader('cache-control', 'no-store'); + res.end(buf); + } catch (err) { + json(res, 500, { error: String((err as Error).message ?? err) }); + } + }); +} + +export function registerThemeRoutes(server: ViteDevServer, ctx: ApiContext): void { + registerThemeFileRoutes(server, ctx); + + server.middlewares.use('/__themes', async (req, res, next) => { + const url = new URL(req.url ?? '/', 'http://local'); + const method = req.method ?? 'GET'; + + const deleteMatch = url.pathname.match(/^\/([^/]+)$/); + if (method === 'DELETE' && deleteMatch) { + try { + const requestCheck = validateMutationRequest(req); + if (!requestCheck.ok) { + return json(res, requestCheck.status, { error: requestCheck.error }); + } + const id = decodeURIComponent(deleteMatch[1]); + // Resolve the id against the scanned file list rather than a path built + // from user input — this both blocks traversal and accepts theme + // filenames outside the import id grammar (e.g. "My Theme.md"). + const files = await findThemeFiles(ctx.themesRoot); + const target = files.find((f) => path.basename(f, '.md') === id); + if (!target) return json(res, 404, { error: 'theme not found' }); + + await fs.rm(target, { force: true }); + await Promise.all( + DEMO_EXTS.map((ext) => + fs.rm(path.join(ctx.themesRoot, `${id}.demo.${ext}`), { force: true }), + ), + ); + const clearedSlides = await clearThemeFromSlides(ctx.slidesRoot, id); + return json(res, 200, { ok: true, id, clearedSlides }); + } catch (err) { + return json(res, 500, { error: String((err as Error).message ?? err) }); + } + } + + if (method !== 'POST' || url.pathname !== '/import') return next(); + + try { + const requestCheck = validateMutationRequest(req, { requireJsonBody: true }); + if (!requestCheck.ok) { + return json(res, requestCheck.status, { error: requestCheck.error }); + } + + const body = (await readBody(req)) as { + url?: unknown; + id?: unknown; + ids?: unknown; + force?: unknown; + }; + if (typeof body.url !== 'string' || body.url.length === 0) { + return json(res, 400, { error: 'url is required' }); + } + const id = typeof body.id === 'string' ? body.id : undefined; + const ids = Array.isArray(body.ids) + ? body.ids.filter((x): x is string => typeof x === 'string') + : undefined; + const force = body.force === true; + + const { origin, entries } = await discoverThemes(body.url, ctx.themeImportAllowedHosts); + const wanted = id ? [id] : ids; + // Mirror the CLI contract: importing several themes at once needs an + // explicit selection, so a bare { url } against a multi-theme source + // writes nothing and reports what's there instead. + if (!wanted && entries.length > 1) { + return json(res, 200, { + ok: true, + origin, + discovered: entries.map((e) => ({ id: e.id, name: e.name, description: e.description })), + }); + } + const selected = wanted ? entries.filter((e) => wanted.includes(e.id)) : entries; + if (selected.length === 0) { + return json(res, 404, { + error: wanted ? `theme(s) not found: ${wanted.join(', ')}` : 'no themes found', + }); + } + + const written = []; + for (const entry of selected) { + const fetched = await fetchTheme(entry, ctx.themeImportAllowedHosts); + written.push(await writeTheme(ctx.themesRoot, fetched, { force })); + } + + return json(res, 200, { ok: true, origin, written }); + } catch (err) { + if (err instanceof ThemeImportError) { + return json(res, STATUS_FOR_CODE[err.code], { error: err.message, code: err.code }); + } + return json(res, 500, { error: String((err as Error).message ?? err) }); + } + }); +} diff --git a/packages/core/src/vite/themes-plugin.ts b/packages/core/src/vite/themes-plugin.ts index 47c9ae881..0d5c62c99 100644 --- a/packages/core/src/vite/themes-plugin.ts +++ b/packages/core/src/vite/themes-plugin.ts @@ -1,9 +1,9 @@ import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import fg from 'fast-glob'; import { normalizePath, type Plugin } from 'vite'; import type { OpenSlideConfig } from '../config.ts'; +import { manifestEntry, type ScannedTheme, scanThemes } from '../themes/scan.ts'; export type ThemesPluginOptions = { userCwd: string; @@ -16,72 +16,7 @@ function resolved(id: string): string { return `\0${id}`; } -type Frontmatter = { - name: string; - description: string; -}; - -type ParsedTheme = { - id: string; - frontmatter: Frontmatter; - body: string; - demoAbs: string | null; -}; - -const FM_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; - -function parseFrontmatter(raw: string, themeId: string): { fm: Frontmatter; body: string } { - const match = raw.match(FM_RE); - const fmText = match ? match[1] : ''; - const body = match ? match[2] : raw; - - const data: Record = {}; - for (const line of fmText.split(/\r?\n/)) { - const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/); - if (!m) continue; - let value = m[2].trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - data[m[1]] = value; - } - - return { - fm: { - name: data.name || themeId, - description: data.description || '', - }, - body: body.trim(), - }; -} - -async function findThemes(userCwd: string, themesDir: string): Promise { - const abs = path.resolve(userCwd, themesDir); - if (!existsSync(abs)) return []; - const hits = await fg('*.md', { cwd: abs, absolute: true, onlyFiles: true }); - return hits.sort(); -} - -async function readTheme(mdAbs: string, themesRoot: string): Promise { - const id = path.basename(mdAbs, '.md'); - const raw = await fs.readFile(mdAbs, 'utf8'); - const { fm, body } = parseFrontmatter(raw, id); - const demoCandidates = [`${id}.demo.tsx`, `${id}.demo.jsx`, `${id}.demo.ts`, `${id}.demo.js`]; - let demoAbs: string | null = null; - for (const cand of demoCandidates) { - const p = path.join(themesRoot, cand); - if (existsSync(p)) { - demoAbs = p; - break; - } - } - return { id, frontmatter: fm, body, demoAbs }; -} - -function generateThemesModule(themes: ParsedTheme[], isDev: boolean): string { +function generateThemesModule(themes: ScannedTheme[], isDev: boolean): string { const meta = themes.map((t) => ({ id: t.id, name: t.frontmatter.name, @@ -120,6 +55,10 @@ export function themesPlugin(opts: ThemesPluginOptions): Plugin { const themesRoot = path.resolve(userCwd, themesDir); let isDev = false; + // During a build, load() and generateBundle() see the same on-disk state, so + // the scan is shared. Never cached in dev — the watcher invalidates the + // module expecting a fresh scan on the next load(). + let buildScan: ScannedTheme[] | null = null; return { name: 'open-slide:themes', @@ -132,10 +71,32 @@ export function themesPlugin(opts: ThemesPluginOptions): Plugin { }, async load(id) { if (id !== resolved(THEMES_VMOD)) return null; - const files = await findThemes(userCwd, themesDir); - const themes = await Promise.all(files.map((f) => readTheme(f, themesRoot))); + const themes = await scanThemes(themesRoot); + if (!isDev) buildScan = themes; return generateThemesModule(themes, isDev); }, + async generateBundle() { + const themes = buildScan ?? (await scanThemes(themesRoot)); + + for (const theme of themes) { + const mdAbs = path.join(themesRoot, `${theme.id}.md`); + const md = await fs.readFile(mdAbs, 'utf8'); + this.emitFile({ type: 'asset', fileName: `themes/${theme.id}.md`, source: md }); + + if (theme.demoAbs) { + const demoName = path.basename(theme.demoAbs); + const demoSource = await fs.readFile(theme.demoAbs, 'utf8'); + this.emitFile({ type: 'asset', fileName: `themes/${demoName}`, source: demoSource }); + } + } + + const manifest = themes.map(manifestEntry); + this.emitFile({ + type: 'asset', + fileName: 'themes/index.json', + source: `${JSON.stringify({ themes: manifest }, null, 2)}\n`, + }); + }, configureServer(server) { const isThemeFile = (p: string) => { const rel = path.relative(themesRoot, p); From 148b597bb2bbec95490ef5baf43f9401d248ba95 Mon Sep 17 00:00:00 2001 From: ridemountainpig Date: Sun, 5 Jul 2026 15:34:45 +0800 Subject: [PATCH 2/4] fix: address code review feedback on theme import Co-Authored-By: Claude Fable 5 --- apps/web/content/docs/reference/config.mdx | 18 +++--- .../app/components/themes/theme-detail.tsx | 2 +- .../app/components/themes/themes-gallery.tsx | 2 +- packages/core/src/cli/theme.ts | 8 ++- packages/core/src/editing/slide-ops.test.ts | 11 ++++ packages/core/src/editing/slide-ops.ts | 31 +++++++++- packages/core/src/themes/import.ts | 59 +++++++++++-------- packages/core/src/themes/scan.ts | 3 +- packages/core/src/vite/routes/themes.ts | 16 ++--- 9 files changed, 102 insertions(+), 48 deletions(-) diff --git a/apps/web/content/docs/reference/config.mdx b/apps/web/content/docs/reference/config.mdx index 403f91852..7bd6f7d91 100644 --- a/apps/web/content/docs/reference/config.mdx +++ b/apps/web/content/docs/reference/config.mdx @@ -26,6 +26,15 @@ type OpenSlideBuildConfig = { allowHtmlDownload?: boolean; }; +type OpenSlideThemeImportConfig = { + /** + * Hosts themes may be imported from (exact match or a subdomain of an + * entry). Leave unset to allow any host — import still warns first, since a + * theme's demo file is executable code. + */ + allowedHosts?: string[]; +}; + type OpenSlideConfig = { /** Base public path for subpath hosting (leading + trailing slash). Default: '/'. */ base?: string; @@ -44,15 +53,6 @@ type OpenSlideConfig = { /** Restrict where themes may be imported from. */ themeImport?: OpenSlideThemeImportConfig; }; - -type OpenSlideThemeImportConfig = { - /** - * Hosts themes may be imported from (exact match or a subdomain of an - * entry). Leave unset to allow any host — import still warns first, since a - * theme's demo file is executable code. - */ - allowedHosts?: string[]; -}; ``` ## Common recipes diff --git a/packages/core/src/app/components/themes/theme-detail.tsx b/packages/core/src/app/components/themes/theme-detail.tsx index 3539cc7be..001c84732 100644 --- a/packages/core/src/app/components/themes/theme-detail.tsx +++ b/packages/core/src/app/components/themes/theme-detail.tsx @@ -78,7 +78,7 @@ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () = const handleCopyUrl = async () => { const base = import.meta.env.BASE_URL.replace(/\/$/, ''); - const shareUrl = `${window.location.origin}${base}/themes/${theme.id}`; + const shareUrl = `${window.location.origin}${base}/themes/${encodeURIComponent(theme.id)}`; try { await navigator.clipboard.writeText(shareUrl); toast.success(t.themes.copyUrlSuccess); diff --git a/packages/core/src/app/components/themes/themes-gallery.tsx b/packages/core/src/app/components/themes/themes-gallery.tsx index 5b95b3932..5d34164ae 100644 --- a/packages/core/src/app/components/themes/themes-gallery.tsx +++ b/packages/core/src/app/components/themes/themes-gallery.tsx @@ -63,7 +63,7 @@ function ThemeCardMenu({ theme }: { theme: Theme }) { diff --git a/packages/core/src/cli/theme.ts b/packages/core/src/cli/theme.ts index 9383958df..a4a5a6544 100644 --- a/packages/core/src/cli/theme.ts +++ b/packages/core/src/cli/theme.ts @@ -34,7 +34,7 @@ async function confirm(question: string): Promise { async function selectEntries( entries: RemoteThemeEntry[], opts: ThemeAddOptions, -): Promise { +): Promise { if (opts.id) { const match = entries.filter((e) => e.id === opts.id); if (match.length === 0) { @@ -67,7 +67,7 @@ async function selectEntries( ) .trim() .toLowerCase(); - if (answer === '') throw new ThemeImportError('invalid', 'Cancelled.'); + if (answer === '') return null; if (answer === 'a' || answer === 'all') return entries; const n = Number(answer); if (!Number.isInteger(n) || n < 1 || n > entries.length) { @@ -92,6 +92,10 @@ export async function themeAdd(url: string, opts: ThemeAddOptions = {}): Promise } const selected = await selectEntries(entries, opts); + if (!selected) { + process.stdout.write(chalk.dim('Cancelled.\n')); + return; + } const restricted = Boolean(allowedHosts && allowedHosts.length > 0); if (!opts.yes && !restricted) { diff --git a/packages/core/src/editing/slide-ops.test.ts b/packages/core/src/editing/slide-ops.test.ts index 5a2a8f8ca..0c4ebdf62 100644 --- a/packages/core/src/editing/slide-ops.test.ts +++ b/packages/core/src/editing/slide-ops.test.ts @@ -194,6 +194,17 @@ describe('removeMetaThemeFromSource', () => { expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); }); + it('leaves a theme field nested inside a sub-object alone', () => { + const source = `export const meta = {\n nested: { theme: 'aurora' },\n};\n`; + expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); + }); + + it('removes only the top-level theme when a nested one appears first', () => { + const source = `export const meta = {\n nested: { theme: 'aurora' },\n theme: 'aurora',\n};\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = {\n nested: { theme: 'aurora' },\n};\n`); + }); + it('returns null when there is no theme field', () => { const source = `export const meta = {\n title: 'X',\n};\n`; expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); diff --git a/packages/core/src/editing/slide-ops.ts b/packages/core/src/editing/slide-ops.ts index 7a8cd23db..facf9344e 100644 --- a/packages/core/src/editing/slide-ops.ts +++ b/packages/core/src/editing/slide-ops.ts @@ -193,6 +193,25 @@ function escapeSingleQuoted(s: string): string { const SLIDE_ENTRY_NAMES = ['index.tsx', 'index.jsx', 'index.ts', 'index.js']; +function nestingDepthAt(body: string, index: number): number { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < index; i++) { + const ch = body[i]; + if (quote) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + } else if (ch === "'" || ch === '"' || ch === '`') { + quote = ch; + } else if (ch === '{' || ch === '[' || ch === '(') { + depth++; + } else if (ch === '}' || ch === ']' || ch === ')') { + depth--; + } + } + return depth; +} + /** * Remove the `theme: ''` field from a slide module's `export const meta`, * but only when it names `themeId`. Returns the rewritten source, or `null` when @@ -227,8 +246,16 @@ export function removeMetaThemeFromSource(source: string, themeId: string): stri // The leading boundary keeps this from matching inside a longer key (e.g. // `subtheme:`); the trailing lookahead keeps it from matching `theme: '…'` // embedded in a string value, where the quote isn't followed by a separator. - const propRe = /(^|[\s,{])theme\s*:\s*(['"`])((?:\\.|(?!\2).)*)\2(?=\s*(?:[,}\n]|$))/; - const match = propRe.exec(body); + // The depth check keeps it from matching a `theme:` nested in a sub-object, + // which is not the `meta.theme` the runtime reads. + const propRe = /(^|[\s,{])theme\s*:\s*(['"`])((?:\\.|(?!\2).)*)\2(?=\s*(?:[,}\n]|$))/g; + let match: RegExpExecArray | null = null; + for (let m = propRe.exec(body); m !== null; m = propRe.exec(body)) { + if (nestingDepthAt(body, m.index + m[1].length) === 0) { + match = m; + break; + } + } if (!match || match[3] !== themeId) return null; let start = match.index + match[1].length; diff --git a/packages/core/src/themes/import.ts b/packages/core/src/themes/import.ts index 4cc29836f..dcaa25ec7 100644 --- a/packages/core/src/themes/import.ts +++ b/packages/core/src/themes/import.ts @@ -1,4 +1,3 @@ -import { existsSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { DEMO_EXTS, FM_RE, parseFrontmatter } from './scan.ts'; @@ -295,7 +294,7 @@ export type WriteResult = { }; function formatFrontmatterValue(value: string): string { - return /^['"]/.test(value) || value.trim() !== value ? JSON.stringify(value) : value; + return JSON.stringify(value); } // Rewrites (or inserts) the `name:` field in a theme's frontmatter without @@ -318,13 +317,39 @@ function setThemeDisplayName(md: string, name: string): string { return `---\n${lines.join('\n')}\n---\n${match[2] ? `\n${match[2]}` : ''}`; } -function availableThemeId(themesRoot: string, id: string): string { - if (!existsSync(path.join(themesRoot, `${id}.md`))) return id; - for (let i = 1; i < 1000; i++) { - const candidate = `${id}-${i}`; - if (!existsSync(path.join(themesRoot, `${candidate}.md`))) return candidate; +// On a name clash, copy in as -1, -2, … rather than overwriting — +// unless force replaces the existing theme in place. The exclusive-create +// flag makes the free-id probe and the write a single atomic step, so +// concurrent imports of the same id get distinct suffixes instead of +// silently clobbering each other. +async function writeThemeMd( + themesRoot: string, + fetched: FetchedTheme, + force: boolean, +): Promise { + if (force) { + await fs.writeFile(path.join(themesRoot, `${fetched.id}.md`), fetched.md); + return fetched.id; + } + for (let i = 0; i < 1000; i++) { + const targetId = i === 0 ? fetched.id : `${fetched.id}-${i}`; + // On a collision rename (replit → replit-1), suffix the display name to match + // so the gallery shows "Replit 1" instead of a second indistinguishable "Replit". + const md = + i === 0 + ? fetched.md + : setThemeDisplayName( + fetched.md, + `${parseFrontmatter(fetched.md, fetched.id).fm.name} ${i}`, + ); + try { + await fs.writeFile(path.join(themesRoot, `${targetId}.md`), md, { flag: 'wx' }); + return targetId; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; + } } - throw new ThemeImportError('invalid', `Too many themes named "${id}"`); + throw new ThemeImportError('invalid', `Too many themes named "${fetched.id}"`); } export async function writeTheme( @@ -337,22 +362,8 @@ export async function writeTheme( } await fs.mkdir(themesRoot, { recursive: true }); - // On a name clash, copy in as -1, -2, … rather than overwriting — - // unless --force was passed to replace the existing theme in place. - const targetId = opts.force ? fetched.id : availableThemeId(themesRoot, fetched.id); - - const written: string[] = []; - // On a collision rename (replit → replit-1), suffix the display name to match - // so the gallery shows "Replit 1" instead of a second indistinguishable "Replit". - const md = - targetId === fetched.id - ? fetched.md - : setThemeDisplayName( - fetched.md, - `${parseFrontmatter(fetched.md, fetched.id).fm.name} ${targetId.slice(fetched.id.length + 1)}`, - ); - await fs.writeFile(path.join(themesRoot, `${targetId}.md`), md); - written.push(`${targetId}.md`); + const targetId = await writeThemeMd(themesRoot, fetched, opts.force === true); + const written: string[] = [`${targetId}.md`]; // Clear every demo variant first: a leftover demo with a higher-priority // extension (scan probes tsx before js) would shadow the one written below. diff --git a/packages/core/src/themes/scan.ts b/packages/core/src/themes/scan.ts index 7926efb9c..e3b06cf63 100644 --- a/packages/core/src/themes/scan.ts +++ b/packages/core/src/themes/scan.ts @@ -70,7 +70,8 @@ export async function readScannedTheme(mdAbs: string, themesRoot: string): Promi let demoAbs: string | null = null; for (const ext of DEMO_EXTS) { const p = path.join(themesRoot, `${id}.demo.${ext}`); - if (existsSync(p)) { + const stat = await fs.stat(p).catch(() => null); + if (stat?.isFile()) { demoAbs = p; break; } diff --git a/packages/core/src/vite/routes/themes.ts b/packages/core/src/vite/routes/themes.ts index a0a3d7c73..d38df1799 100644 --- a/packages/core/src/vite/routes/themes.ts +++ b/packages/core/src/vite/routes/themes.ts @@ -7,13 +7,6 @@ import { discoverThemes, fetchTheme, ThemeImportError, writeTheme } from '../../ import { buildThemeManifest, DEMO_EXTS, findThemeFiles } from '../../themes/scan.ts'; import { type ApiContext, json, readBody } from './context.ts'; -// POST /__themes/import { url, id?, ids?, force? } fetch themes from a URL into themes/ -// — multi-theme sources answer { discovered } and import -// nothing until the client re-posts with ids -// DELETE /__themes/:id remove a theme's files + clear meta.theme from slides using it -// GET /themes/index.json theme manifest (mirrors the build output) -// GET /themes/:file raw theme .md / .demo.* source -// // The GET routes exist so a running dev server serves the same theme URLs a // built site does — letting `/themes/` page links be imported directly, // without first running `open-slide build`. @@ -44,9 +37,9 @@ function registerThemeFileRoutes(server: ViteDevServer, ctx: ApiContext): void { server.middlewares.use(`${base}/themes`, async (req, res, next) => { if ((req.method ?? 'GET') !== 'GET') return next(); const url = new URL(req.url ?? '/', 'http://local'); - const rel = decodeURIComponent(url.pathname.replace(/^\/+/, '')); try { + const rel = decodeURIComponent(url.pathname.replace(/^\/+/, '')); if (rel === 'index.json') { const manifest = await buildThemeManifest(ctx.themesRoot); res.statusCode = 200; @@ -75,6 +68,10 @@ function registerThemeFileRoutes(server: ViteDevServer, ctx: ApiContext): void { res.setHeader('cache-control', 'no-store'); res.end(buf); } catch (err) { + if (err instanceof URIError) { + json(res, 400, { error: 'malformed path encoding' }); + return; + } json(res, 500, { error: String((err as Error).message ?? err) }); } }); @@ -111,6 +108,9 @@ export function registerThemeRoutes(server: ViteDevServer, ctx: ApiContext): voi const clearedSlides = await clearThemeFromSlides(ctx.slidesRoot, id); return json(res, 200, { ok: true, id, clearedSlides }); } catch (err) { + if (err instanceof URIError) { + return json(res, 400, { error: 'malformed path encoding' }); + } return json(res, 500, { error: String((err as Error).message ?? err) }); } } From 834eca0882e3aa47561fbe409fac9c09dba4efcb Mon Sep 17 00:00:00 2001 From: ridemountainpig Date: Sun, 5 Jul 2026 15:38:52 +0800 Subject: [PATCH 3/4] fix: skip comment-embedded matches when removing meta.theme Co-Authored-By: Claude Fable 5 --- packages/core/src/editing/slide-ops.test.ts | 11 ++++++++ packages/core/src/editing/slide-ops.ts | 30 ++++++++++++++++----- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/core/src/editing/slide-ops.test.ts b/packages/core/src/editing/slide-ops.test.ts index 0c4ebdf62..5aa680125 100644 --- a/packages/core/src/editing/slide-ops.test.ts +++ b/packages/core/src/editing/slide-ops.test.ts @@ -205,6 +205,17 @@ describe('removeMetaThemeFromSource', () => { expect(out).toBe(`export const meta = {\n nested: { theme: 'aurora' },\n};\n`); }); + it('skips a theme mention inside a comment and removes the real property', () => { + const source = `export const meta = {\n // theme: 'aurora' used to be the default\n theme: 'aurora',\n};\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = {\n // theme: 'aurora' used to be the default\n};\n`); + }); + + it('leaves a theme field that only appears inside a comment alone', () => { + const source = `export const meta = {\n /* theme: 'aurora' */\n title: 'X',\n};\n`; + expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); + }); + it('returns null when there is no theme field', () => { const source = `export const meta = {\n title: 'X',\n};\n`; expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); diff --git a/packages/core/src/editing/slide-ops.ts b/packages/core/src/editing/slide-ops.ts index facf9344e..6ff1f704b 100644 --- a/packages/core/src/editing/slide-ops.ts +++ b/packages/core/src/editing/slide-ops.ts @@ -193,14 +193,30 @@ function escapeSingleQuoted(s: string): string { const SLIDE_ENTRY_NAMES = ['index.tsx', 'index.jsx', 'index.ts', 'index.js']; -function nestingDepthAt(body: string, index: number): number { +function syntaxStateAt(body: string, index: number): { depth: number; inComment: boolean } { let depth = 0; let quote: string | null = null; + let lineComment = false; + let blockComment = false; for (let i = 0; i < index; i++) { const ch = body[i]; - if (quote) { + const next = body[i + 1]; + if (lineComment) { + if (ch === '\n') lineComment = false; + } else if (blockComment) { + if (ch === '*' && next === '/') { + blockComment = false; + i++; + } + } else if (quote) { if (ch === '\\') i++; else if (ch === quote) quote = null; + } else if (ch === '/' && next === '/') { + lineComment = true; + i++; + } else if (ch === '/' && next === '*') { + blockComment = true; + i++; } else if (ch === "'" || ch === '"' || ch === '`') { quote = ch; } else if (ch === '{' || ch === '[' || ch === '(') { @@ -209,7 +225,7 @@ function nestingDepthAt(body: string, index: number): number { depth--; } } - return depth; + return { depth, inComment: lineComment || blockComment }; } /** @@ -246,12 +262,14 @@ export function removeMetaThemeFromSource(source: string, themeId: string): stri // The leading boundary keeps this from matching inside a longer key (e.g. // `subtheme:`); the trailing lookahead keeps it from matching `theme: '…'` // embedded in a string value, where the quote isn't followed by a separator. - // The depth check keeps it from matching a `theme:` nested in a sub-object, - // which is not the `meta.theme` the runtime reads. + // The syntax-state check keeps it from matching a `theme:` nested in a + // sub-object or sitting inside a comment — neither is the `meta.theme` the + // runtime reads. const propRe = /(^|[\s,{])theme\s*:\s*(['"`])((?:\\.|(?!\2).)*)\2(?=\s*(?:[,}\n]|$))/g; let match: RegExpExecArray | null = null; for (let m = propRe.exec(body); m !== null; m = propRe.exec(body)) { - if (nestingDepthAt(body, m.index + m[1].length) === 0) { + const state = syntaxStateAt(body, m.index + m[1].length); + if (state.depth === 0 && !state.inComment) { match = m; break; } From f363f452edee4f5394c09a4078ab8f72cae0bff4 Mon Sep 17 00:00:00 2001 From: ridemountainpig Date: Sun, 5 Jul 2026 15:43:41 +0800 Subject: [PATCH 4/4] fix: make meta brace matching string- and comment-aware Co-Authored-By: Claude Fable 5 --- packages/core/src/editing/slide-ops.test.ts | 12 +++ packages/core/src/editing/slide-ops.ts | 108 ++++++++++++-------- 2 files changed, 80 insertions(+), 40 deletions(-) diff --git a/packages/core/src/editing/slide-ops.test.ts b/packages/core/src/editing/slide-ops.test.ts index 5aa680125..53242bd05 100644 --- a/packages/core/src/editing/slide-ops.test.ts +++ b/packages/core/src/editing/slide-ops.test.ts @@ -216,6 +216,18 @@ describe('removeMetaThemeFromSource', () => { expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); }); + it('is not fooled by a closing brace inside a string value', () => { + const source = `export const meta = {\n title: 'a } b',\n theme: 'aurora',\n};\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = {\n title: 'a } b',\n};\n`); + }); + + it('is not fooled by braces inside a comment', () => { + const source = `export const meta = {\n // } {\n theme: 'aurora',\n};\n`; + const out = removeMetaThemeFromSource(source, 'aurora'); + expect(out).toBe(`export const meta = {\n // } {\n};\n`); + }); + it('returns null when there is no theme field', () => { const source = `export const meta = {\n title: 'X',\n};\n`; expect(removeMetaThemeFromSource(source, 'aurora')).toBeNull(); diff --git a/packages/core/src/editing/slide-ops.ts b/packages/core/src/editing/slide-ops.ts index 6ff1f704b..281929245 100644 --- a/packages/core/src/editing/slide-ops.ts +++ b/packages/core/src/editing/slide-ops.ts @@ -193,39 +193,79 @@ function escapeSingleQuoted(s: string): string { const SLIDE_ENTRY_NAMES = ['index.tsx', 'index.jsx', 'index.ts', 'index.js']; -function syntaxStateAt(body: string, index: number): { depth: number; inComment: boolean } { - let depth = 0; +// Tracks string/comment state while walking source text, so structural +// characters embedded in either (a `}` in a title, a `theme:` in a comment) +// never count as code. +function createSyntaxScanner() { let quote: string | null = null; let lineComment = false; let blockComment = false; - for (let i = 0; i < index; i++) { - const ch = body[i]; - const next = body[i + 1]; - if (lineComment) { - if (ch === '\n') lineComment = false; - } else if (blockComment) { - if (ch === '*' && next === '/') { - blockComment = false; - i++; + return { + get inCode(): boolean { + return quote === null && !lineComment && !blockComment; + }, + get inComment(): boolean { + return lineComment || blockComment; + }, + step(text: string, i: number): number { + const ch = text[i]; + const next = text[i + 1]; + if (lineComment) { + if (ch === '\n') lineComment = false; + return i + 1; + } + if (blockComment) { + if (ch === '*' && next === '/') { + blockComment = false; + return i + 2; + } + return i + 1; + } + if (quote) { + if (ch === '\\') return i + 2; + if (ch === quote) quote = null; + return i + 1; + } + if (ch === '/' && next === '/') { + lineComment = true; + return i + 2; } - } else if (quote) { - if (ch === '\\') i++; - else if (ch === quote) quote = null; - } else if (ch === '/' && next === '/') { - lineComment = true; - i++; - } else if (ch === '/' && next === '*') { - blockComment = true; - i++; - } else if (ch === "'" || ch === '"' || ch === '`') { - quote = ch; - } else if (ch === '{' || ch === '[' || ch === '(') { - depth++; - } else if (ch === '}' || ch === ']' || ch === ')') { - depth--; + if (ch === '/' && next === '*') { + blockComment = true; + return i + 2; + } + if (ch === "'" || ch === '"' || ch === '`') quote = ch; + return i + 1; + }, + }; +} + +function syntaxStateAt(body: string, index: number): { depth: number; inComment: boolean } { + const scanner = createSyntaxScanner(); + let depth = 0; + for (let i = 0; i < index; ) { + const ch = body[i]; + if (scanner.inCode) { + if (ch === '{' || ch === '[' || ch === '(') depth++; + else if (ch === '}' || ch === ']' || ch === ')') depth--; } + i = scanner.step(body, i); } - return { depth, inComment: lineComment || blockComment }; + return { depth, inComment: scanner.inComment }; +} + +function findMatchingBrace(source: string, openBrace: number): number { + const scanner = createSyntaxScanner(); + let depth = 0; + for (let i = openBrace; i < source.length; ) { + const ch = source[i]; + if (scanner.inCode) { + if (ch === '{') depth++; + else if (ch === '}' && --depth === 0) return i; + } + i = scanner.step(source, i); + } + return -1; } /** @@ -243,19 +283,7 @@ export function removeMetaThemeFromSource(source: string, themeId: string): stri const openBrace = source.indexOf('{', eqIdx); if (openBrace === -1) return null; - let depth = 0; - let closeBrace = -1; - for (let i = openBrace; i < source.length; i++) { - const ch = source[i]; - if (ch === '{') depth++; - else if (ch === '}') { - depth--; - if (depth === 0) { - closeBrace = i; - break; - } - } - } + const closeBrace = findMatchingBrace(source, openBrace); if (closeBrace === -1) return null; const body = source.slice(openBrace + 1, closeBrace);