Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/theme-import-from-url.md
Original file line number Diff line number Diff line change
@@ -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 <url>` or the Themes panel, and delete a theme from the gallery.
2 changes: 1 addition & 1 deletion apps/web/content/docs/cli/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "CLI",
"pages": ["overview", "init", "dev", "build", "preview", "sync-skills"]
"pages": ["overview", "init", "dev", "build", "preview", "theme-add", "sync-skills"]
}
4 changes: 2 additions & 2 deletions apps/web/content/docs/cli/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
80 changes: 80 additions & 0 deletions apps/web/content/docs/cli/theme-add.mdx
Original file line number Diff line number Diff line change
@@ -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 `<id>.md` recipe and `<id>.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 <url>
```

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 <url> # npm
pnpm exec open-slide theme add <url> # pnpm
yarn open-slide theme add <url> # yarn
bunx open-slide theme add <url> # bun
```

`<url>` 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 `<id>-1`, `<id>-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 <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).
22 changes: 22 additions & 0 deletions apps/web/content/docs/core-feature/themes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<id>.md` and `<id>.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 `<id>.md` to pull the theme into your local `themes/` folder. The
same import runs on the command line via
[`open-slide theme add <url>`](/docs/cli/theme-add).
- **Delete** — the per-theme menu removes a theme's files and clears
`meta.theme` from any slides that referenced it.

<Callout type="warn">
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).
</Callout>
11 changes: 11 additions & 0 deletions apps/web/content/docs/reference/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -41,6 +50,8 @@ type OpenSlideConfig = {
locale?: Locale;
/** Build-time UI toggles. */
build?: OpenSlideBuildConfig;
/** Restrict where themes may be imported from. */
themeImport?: OpenSlideThemeImportConfig;
};
```

Expand Down
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <port>`, `--host [host]`, `--open`. |
| `open-slide build` | Build a static site. Flags: `--out-dir <dir>` (defaults to `dist`). |
| `open-slide preview` | Preview the production build. Flags: `-p, --port <port>`, `--host [host]`, `--open`. |
| `open-slide theme add <url>` | Import a theme from a deployed open-slide site. Flags: `--id <id>`, `--all`, `--force`, `-y, --yes`. |

## Config

Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/app/components/themes/theme-detail.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,13 +76,28 @@ 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/${encodeURIComponent(theme.id)}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t.themes.copyUrlSuccess);
} catch {
toast.error(t.themes.copyUrlFailed);
}
};

return (
<div className="flex flex-col gap-6 md:gap-8">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
<ChevronLeft className="size-4" />
{t.themes.backToGallery}
</Button>
<Button variant="outline" size="sm" onClick={handleCopyUrl} className="ml-auto">
<Link2 className="size-4" />
{t.themes.copyUrl}
</Button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>

<header className="flex flex-wrap items-baseline gap-3">
Expand Down
170 changes: 170 additions & 0 deletions packages/core/src/app/components/themes/theme-import-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<DiscoveredTheme[] | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (busy) return;
setOpen(next);
if (!next) resetDiscovery();
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Download className="size-4" />
{t.themes.importFromUrl}
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t.themes.importDialogTitle}</DialogTitle>
<DialogDescription>{t.themes.importDialogDescription}</DialogDescription>
</DialogHeader>
<div className="mt-4 flex flex-col gap-3">
<Input
id={inputId}
type="url"
inputMode="url"
autoFocus
placeholder={t.themes.importUrlPlaceholder}
value={url}
onChange={(e) => {
setUrl(e.target.value);
resetDiscovery();
}}
/>
{discovered && (
<div className="flex flex-col gap-2">
<p className="text-[12px] text-muted-foreground">
{format(t.themes.importMultipleFound, { count: String(discovered.length) })}
</p>
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
{discovered.map((theme) => (
<label
key={theme.id}
className="flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm hover:bg-accent"
>
<input
type="checkbox"
className="accent-foreground"
checked={selectedIds.includes(theme.id)}
onChange={() => toggleSelected(theme.id)}
/>
<span className="truncate font-medium">{theme.name}</span>
<span className="truncate text-muted-foreground text-xs">{theme.id}</span>
</label>
))}
</div>
</div>
)}
{!restrictedToAllowedHosts && (
<p className="flex items-start gap-2 text-[12px] leading-relaxed text-muted-foreground">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-500" />
<span>{t.themes.importWarning}</span>
</p>
)}
</div>
<DialogFooter className="mt-5">
<Button
type="submit"
size="sm"
disabled={
busy || url.trim().length === 0 || (discovered !== null && selectedIds.length === 0)
}
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
{busy ? t.themes.importing : t.themes.importAction}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
Loading
Loading