-
-
Notifications
You must be signed in to change notification settings - Fork 476
feat: share themes by URL — CLI theme add, gallery import + delete #286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ridemountainpig
wants to merge
6
commits into
1weiho:main
Choose a base branch
from
ridemountainpig:feat/theme-import-from-url
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
73567a4
feat: share themes by URL — CLI theme add, gallery import + delete
ridemountainpig 148b597
fix: address code review feedback on theme import
ridemountainpig 834eca0
fix: skip comment-embedded matches when removing meta.theme
ridemountainpig f363f45
fix: make meta brace matching string- and comment-aware
ridemountainpig 3eff6f2
Merge upstream/main into feat/theme-import-from-url
ridemountainpig 63f62b9
Merge upstream/main into feat/theme-import-from-url
ridemountainpig File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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). |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
170 changes: 170 additions & 0 deletions
170
packages/core/src/app/components/themes/theme-import-dialog.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.