diff --git a/.changeset/slide-tags.md b/.changeset/slide-tags.md new file mode 100644 index 000000000..3f5e6de8f --- /dev/null +++ b/.changeset/slide-tags.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': minor +--- + +Add free-form slide tags with a list filter and a per-slide tag editor. diff --git a/packages/core/src/app/components/tag-combobox.tsx b/packages/core/src/app/components/tag-combobox.tsx new file mode 100644 index 000000000..369debb2f --- /dev/null +++ b/packages/core/src/app/components/tag-combobox.tsx @@ -0,0 +1,197 @@ +import { Plus, X } from 'lucide-react'; +import { useId, useMemo, useRef, useState } from 'react'; +import { cn } from '@/lib/utils'; + +/** + * Normalise a free-typed tag to match the server's `sanitizeTag`: trim, + * lowercase, collapse whitespace to dashes, strip anything outside + * letters/numbers/._-, and cap at 64 chars — so a created chip never changes or + * vanishes after the server round-trip. + */ +export function normalizeTag(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\p{L}\p{N}._-]/gu, '') + .slice(0, 64); +} + +/** + * Token-input combobox for tags: selected tags render as chips inside the box, + * the user types to filter `suggestions`, and clicks / Enter to select. With + * `allowCreate`, a typed value that matches no suggestion can be created. + * + * The suggestions list is a plain positioned element (not a Popover) so focus + * stays in the text input while navigating with the keyboard. + * + * Presentational and locale-agnostic — call sites pass localized `placeholder`, + * `ariaLabel` and `createLabel`. + */ +export function TagCombobox({ + value, + onChange, + suggestions, + allowCreate = false, + placeholder, + ariaLabel, + createLabel, + className, +}: { + value: string[]; + onChange: (next: string[]) => void; + suggestions: string[]; + allowCreate?: boolean; + placeholder?: string; + ariaLabel?: string; + createLabel?: (raw: string) => string; + className?: string; +}) { + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const listId = useId(); + + const q = query.trim().toLowerCase(); + const filtered = useMemo( + () => + suggestions + .filter((s) => !value.includes(s)) + .filter((s) => (q ? s.toLowerCase().includes(q) : true)), + [suggestions, value, q], + ); + const normalized = normalizeTag(query); + const showCreate = + allowCreate && + normalized.length > 0 && + !value.includes(normalized) && + !suggestions.some((s) => s.toLowerCase() === normalized); + const optionCount = filtered.length + (showCreate ? 1 : 0); + + const add = (tag: string) => { + if (!tag || value.includes(tag)) return; + onChange([...value, tag]); + setQuery(''); + setActiveIndex(0); + }; + const removeTag = (tag: string) => onChange(value.filter((t) => t !== tag)); + const commitActive = () => { + if (activeIndex < filtered.length) add(filtered[activeIndex]); + else if (showCreate) add(normalized); + }; + + return ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: clicking empty box area focuses the input */} +
{ + if (e.target === e.currentTarget) { + e.preventDefault(); + inputRef.current?.focus(); + } + }} + > + {value.map((tag) => ( + + {tag} + + + ))} + setOpen(true)} + onBlur={() => setOpen(false)} + onChange={(e) => { + setQuery(e.target.value); + setOpen(true); + setActiveIndex(0); + }} + onKeyDown={(e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setOpen(true); + setActiveIndex((i) => Math.min(i + 1, Math.max(0, optionCount - 1))); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((i) => Math.max(0, i - 1)); + } else if (e.key === 'Enter') { + if (open && optionCount > 0) { + e.preventDefault(); + commitActive(); + } + } else if (e.key === 'Escape') { + setOpen(false); + } else if (e.key === 'Backspace' && query === '' && value.length > 0) { + removeTag(value[value.length - 1]); + } + }} + className="h-6 min-w-[90px] flex-1 bg-transparent px-1 text-[12.5px] outline-none placeholder:text-muted-foreground/70" + /> +
+ {open && optionCount > 0 && ( + + )} +
+ ); +} diff --git a/packages/core/src/app/lib/folders.ts b/packages/core/src/app/lib/folders.ts index dc636220f..185694107 100644 --- a/packages/core/src/app/lib/folders.ts +++ b/packages/core/src/app/lib/folders.ts @@ -33,6 +33,15 @@ async function patchSlideName(slideId: string, name: string): Promise { if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`); } +async function patchSlideTags(slideId: string, tags: string[]): Promise { + const res = await fetch(`/__slides/${slideId}`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ tags }), + }); + if (!res.ok) throw new Error(`PATCH /__slides/${slideId} ${res.status}`); +} + async function duplicateSlideReq(slideId: string, newId?: string): Promise { const init: RequestInit = { method: 'POST' }; if (newId !== undefined) { @@ -106,6 +115,7 @@ export type UseFoldersResult = { reorder: (ids: string[]) => Promise; assign: (slideId: string, folderId: string | null) => Promise; renameSlide: (slideId: string, name: string) => Promise; + setSlideTags: (slideId: string, tags: string[]) => Promise; duplicateSlide: (slideId: string, newId?: string) => Promise; deleteSlide: (slideId: string) => Promise; refresh: () => Promise; @@ -206,6 +216,14 @@ export function useFolders(): UseFoldersResult { [refresh], ); + const setSlideTags = useCallback( + async (slideId: string, tags: string[]) => { + await patchSlideTags(slideId, tags); + await refresh(); + }, + [refresh], + ); + const duplicateSlide = useCallback( async (slideId: string, newId?: string) => { const duplicatedId = await duplicateSlideReq(slideId, newId); @@ -232,6 +250,7 @@ export function useFolders(): UseFoldersResult { reorder, assign, renameSlide, + setSlideTags, duplicateSlide, deleteSlide, refresh, diff --git a/packages/core/src/app/lib/sdk.ts b/packages/core/src/app/lib/sdk.ts index 082e3c462..ca13aac3e 100644 --- a/packages/core/src/app/lib/sdk.ts +++ b/packages/core/src/app/lib/sdk.ts @@ -9,6 +9,8 @@ export type SlideMeta = { theme?: string; /** ISO 8601 timestamp. Set once at scaffold time; used to sort the slide list. */ createdAt?: string; + /** Free-form tags for filtering the slide list — language, topic, etc. */ + tags?: string[]; }; export type SlideModule = { diff --git a/packages/core/src/app/lib/slides.ts b/packages/core/src/app/lib/slides.ts index c19195651..35e165ee0 100644 --- a/packages/core/src/app/lib/slides.ts +++ b/packages/core/src/app/lib/slides.ts @@ -2,6 +2,7 @@ import { slideCreatedAt as createdAt, slideIds as ids, loadSlide as load, + slideTags as tags, slideThemes as themes, } from 'virtual:open-slide/slides'; import type { SlideModule } from './sdk'; @@ -9,6 +10,7 @@ import type { SlideModule } from './sdk'; export const slideIds: string[] = ids; export const slideThemes: Record = themes; export const slideCreatedAt: Record = createdAt; +export const slideTags: Record = tags; export function slidesByTheme(themeId: string): string[] { return slideIds.filter((id) => slideThemes[id] === themeId); diff --git a/packages/core/src/app/routes/home.tsx b/packages/core/src/app/routes/home.tsx index 83b713738..5126494e9 100644 --- a/packages/core/src/app/routes/home.tsx +++ b/packages/core/src/app/routes/home.tsx @@ -35,9 +35,10 @@ import { cn } from '@/lib/utils'; import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/folder-item'; import { ALL_SLIDES_ID, DRAFT_ID } from '../components/sidebar/sidebar'; import { SlideCanvas } from '../components/slide-canvas'; +import { TagCombobox } from '../components/tag-combobox'; import { SlidePageProvider } from '../lib/page-context'; import type { Folder, FolderIcon, SlideModule } from '../lib/sdk'; -import { loadSlide, slideCreatedAt, slideIds } from '../lib/slides'; +import { loadSlide, slideCreatedAt, slideIds, slideTags } from '../lib/slides'; import type { HomeOutletContext } from './home-shell'; type SortKey = 'created-desc' | 'created-asc' | 'title-asc' | 'title-desc'; @@ -67,6 +68,31 @@ function useSortPref(): [SortKey, (next: SortKey) => void] { return [sortKey, update]; } +const TAGS_STORAGE_KEY = 'open-slide:home-tags'; + +function readTagsPref(): string[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(TAGS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed.filter((t): t is string => typeof t === 'string'); + } + } catch {} + return []; +} + +function useTagFilterPref(): [string[], (next: string[]) => void] { + const [tags, setTags] = useState(readTagsPref); + const update = (next: string[]) => { + setTags(next); + try { + window.localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(next)); + } catch {} + }; + return [tags, update]; +} + const TITLE_COLLATOR = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }); export function Home() { @@ -104,16 +130,36 @@ export function Home() { const [query, setQuery] = useState(''); const [sortKey, setSortKey] = useSortPref(); + const [selectedTags, setSelectedTags] = useTagFilterPref(); + + const availableTags = useMemo(() => { + const set = new Set(); + for (const id of visibleSlides) for (const tg of slideTags[id] ?? []) set.add(tg); + return Array.from(set).sort((a, b) => TITLE_COLLATOR.compare(a, b)); + }, [visibleSlides]); + + // Only keep selected tags that still exist in this view, so stale selections + // don't silently hide everything. + const activeTags = useMemo( + () => selectedTags.filter((tg) => availableTags.includes(tg)), + [selectedTags, availableTags], + ); const trimmedQuery = query.trim().toLowerCase(); const filteredSlides = useMemo(() => { - if (!trimmedQuery) return visibleSlides; return visibleSlides.filter((id) => { - if (id.toLowerCase().includes(trimmedQuery)) return true; - const tl = titleMap[id]?.toLowerCase(); - return tl ? tl.includes(trimmedQuery) : false; + if (activeTags.length > 0) { + const tags = slideTags[id] ?? []; + if (!activeTags.every((tg) => tags.includes(tg))) return false; + } + if (trimmedQuery) { + if (id.toLowerCase().includes(trimmedQuery)) return true; + const tl = titleMap[id]?.toLowerCase(); + return tl ? tl.includes(trimmedQuery) : false; + } + return true; }); - }, [visibleSlides, titleMap, trimmedQuery]); + }, [visibleSlides, titleMap, trimmedQuery, activeTags]); const sortedSlides = useMemo(() => { const list = filteredSlides.slice(); const titleOf = (id: string) => titleMap[id] ?? id; @@ -133,6 +179,12 @@ export function Home() { return list; }, [filteredSlides, sortKey, titleMap]); const isSearching = trimmedQuery.length > 0; + const isFiltering = isSearching || activeTags.length > 0; + + const clearFilters = () => { + setQuery(''); + setSelectedTags([]); + }; return ( <> @@ -188,17 +240,27 @@ export function Home() { {!loading && ( - {(isSearching ? filteredSlides.length : visibleSlides.length) + {(isFiltering ? filteredSlides.length : visibleSlides.length) .toString() .padStart(2, '0')} - {isSearching && ( + {isFiltering && ( /{visibleSlides.length.toString().padStart(2, '0')} )} )} -
+
+ {availableTags.length > 0 && ( + + )}
@@ -210,7 +272,7 @@ export function Home() { ) : visibleSlides.length === 0 ? ( ) : filteredSlides.length === 0 ? ( - setQuery('')} /> + ) : (
    {sortedSlides.map((id) => ( diff --git a/packages/core/src/app/routes/slide.tsx b/packages/core/src/app/routes/slide.tsx index a12a73418..0d3ec17f4 100644 --- a/packages/core/src/app/routes/slide.tsx +++ b/packages/core/src/app/routes/slide.tsx @@ -14,6 +14,7 @@ import { MoreHorizontal, Play, Presentation, + Tag, } from 'lucide-react'; import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; @@ -40,6 +41,7 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useFolders } from '@/lib/folders'; @@ -57,17 +59,24 @@ import { PptxProgressToast } from '../components/pptx-progress-toast'; import { SlideCanvas } from '../components/slide-canvas'; import { isDeckWarmed, markDeckWarmed, SlidePreloadLayer } from '../components/slide-preload-layer'; import { SlideTransitionLayer } from '../components/slide-transition-layer'; +import { TagCombobox } from '../components/tag-combobox'; import { type ThumbnailActions, ThumbnailRail } from '../components/thumbnail-rail'; import { exportSlideAsHtml } from '../lib/export-html'; import { exportSlideAsPdf, isSafari } from '../lib/export-pdf'; import { exportSlideAsImagePptx } from '../lib/export-pptx'; import { remapNotesSessionCacheAfterReorder } from '../lib/inspector/use-notes'; import type { SlideModule } from '../lib/sdk'; +import { slideTags } from '../lib/slides'; import { usePrefersReducedMotion } from '../lib/use-prefers-reduced-motion'; import { useSlideModule } from '../lib/use-slide-module'; const { showSlideUi, showSlideBrowser, allowHtmlDownload } = config.build; +// Stable empty-tags fallback: a fresh `[]` each render would give the tags +// editor's draft effect a new dependency identity on every parent re-render and +// wipe what the user is typing. +const NO_TAGS: string[] = []; + export function Slide() { const { slideId = '' } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); @@ -89,7 +98,7 @@ export function Slide() { if (linkCopiedTimerRef.current) clearTimeout(linkCopiedTimerRef.current); }; }, []); - const { renameSlide } = useFolders(); + const { renameSlide, setSlideTags } = useFolders(); const slideViewportRef = useRef(null); const t = useLocale(); const isMobile = useIsMobile(); @@ -415,6 +424,12 @@ export function Slide() { } const title = slide.meta?.title ?? slideId; + const slideTagsValue = slide.meta?.tags ?? NO_TAGS; + // Plain const (not a hook): this runs after the component's early returns, so + // a useMemo here would violate the rules of hooks. The arrays are tiny. + const allKnownTags = Array.from(new Set(Object.values(slideTags).flat())).sort((a, b) => + a.localeCompare(b), + ); const copyLink = async () => { try { @@ -580,6 +595,13 @@ export function Slide() { )} {import.meta.env.DEV && } + {import.meta.env.DEV && ( + setSlideTags(slideId, next)} + /> + )}
{/* On md+ the title centers to the viewport via absolute positioning. On mobile the @@ -1039,6 +1061,74 @@ function SlideViewportNavigation({ return null; } +// DEV-only tags editor in the slide top bar. Keeps a local draft while open and +// persists once (writing meta.tags to the file) when the popover closes, so +// adding several tags doesn't trigger a reload per keystroke. +function SlideTagsControl({ + tags, + suggestions, + onSave, +}: { + tags: string[]; + suggestions: string[]; + onSave: (next: string[]) => Promise | void; +}) { + const t = useLocale(); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [draft, setDraft] = useState(tags); + useEffect(() => { + setDraft(tags); + }, [tags]); + return ( + { + // Ignore re-open attempts while the previous full-replacement PATCH is + // still in flight, so a slower earlier request can't land last and + // clobber the newer tags. + if (next && saving) return; + setOpen(next); + if (!next) { + const changed = draft.length !== tags.length || draft.some((x, i) => x !== tags[i]); + if (changed) { + setSaving(true); + Promise.resolve(onSave(draft)) + .catch(() => toast.error(t.slide.tagsSaveFailed)) + .finally(() => setSaving(false)); + } + } + }} + > + + + {tags.length > 0 && {tags.length}} + + } + /> + + format(t.slide.createTag, { name: raw })} + /> + + + ); +} + function InlineTitleEditor({ title, onSubmit, diff --git a/packages/core/src/app/virtual.d.ts b/packages/core/src/app/virtual.d.ts index 2ef011582..d5d056666 100644 --- a/packages/core/src/app/virtual.d.ts +++ b/packages/core/src/app/virtual.d.ts @@ -3,6 +3,7 @@ declare module 'virtual:open-slide/slides' { export const slideIds: string[]; export const slideThemes: Record; export const slideCreatedAt: Record; + export const slideTags: Record; export function loadSlide(id: string): Promise; } diff --git a/packages/core/src/editing/slide-ops.test.ts b/packages/core/src/editing/slide-ops.test.ts index a3b5c552c..5a8df7cb8 100644 --- a/packages/core/src/editing/slide-ops.test.ts +++ b/packages/core/src/editing/slide-ops.test.ts @@ -10,6 +10,7 @@ import { removePageFromDefaultExportInSource, reorderDefaultExportPagesInSource, reorderNotesArrayInSource, + updateMetaTagsInSource, updateMetaTitleInSource, validateSlideName, } from './slide-ops.ts'; @@ -145,11 +146,128 @@ describe('updateMetaTitleInSource', () => { expect(out).toContain('export default []'); }); + it('replaces an existing title that contains a closing brace (no duplicate)', () => { + const source = `export const meta = { title: 'contains }' };\nexport default [];\n`; + const out = updateMetaTitleInSource(source, 'new'); + expect(out).toBe(`export const meta = { title: 'new' };\nexport default [];\n`); + }); + it('returns null if there is no meta and no default export', () => { expect(updateMetaTitleInSource('// nothing here', 'x')).toBeNull(); }); }); +describe('updateMetaTagsInSource', () => { + it('replaces an existing tags array literal', () => { + const source = `export const meta: SlideMeta = { tags: ['old'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['a', 'b']); + expect(out).toContain("tags: ['a', 'b']"); + expect(out).not.toContain("'old'"); + }); + + it('replaces a tags array alongside a preserved title', () => { + const source = `export const meta = { title: 'T', tags: ['x'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['y', 'z']); + expect(out).toContain("title: 'T'"); + expect(out).toContain("tags: ['y', 'z']"); + }); + + it('escapes single quotes inside a tag', () => { + const source = `export const meta = { tags: [] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ["it's"]); + expect(out).toContain("tags: ['it\\'s']"); + }); + + it('injects tags into a meta object that lacks them', () => { + const source = `export const meta = {\n title: 'x',\n};\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['first']); + expect(out).toMatch(/tags:\s*\['first'\]/); + expect(out).toContain("title: 'x'"); + }); + + it('injects a fresh meta export when none exists', () => { + const source = `export default [];\n`; + const out = updateMetaTagsInSource(source, ['fresh']); + expect(out).toContain("export const meta: SlideMeta = { tags: ['fresh'] };"); + expect(out).toContain('export default []'); + }); + + it('writes an empty array when clearing all tags', () => { + const source = `export const meta = { tags: ['a', 'b'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, []); + expect(out).toContain('tags: []'); + }); + + it('handles an existing tag literal that contains a closing bracket', () => { + const source = `export const meta = { tags: ['a]b', 'c'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).toBe(`export const meta = { tags: ['new'] };\nexport default [];\n`); + }); + + it('does not mutate tags-like text inside another field string value', () => { + const source = `export const meta = { title: 'my tags: [cool] stuff' };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['x']); + // No real tags key, so a fresh one is injected and the title is left intact. + expect(out).toContain("title: 'my tags: [cool] stuff'"); + expect(out).toMatch(/tags:\s*\['x'\]/); + }); + + it('rewrites only the real tags key when a decoy lives in another string', () => { + const source = `export const meta = { title: 'has tags: [decoy]', tags: ['old'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).toBe( + `export const meta = { title: 'has tags: [decoy]', tags: ['new'] };\nexport default [];\n`, + ); + }); + + it('brace-matches past an unbalanced curly inside another field string value', () => { + const source = `export const meta = { title: 'use {curly here', tags: ['old'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).toBe( + `export const meta = { title: 'use {curly here', tags: ['new'] };\nexport default [];\n`, + ); + }); + + it('ignores a commented-out tags decoy and injects a real key', () => { + const source = `export const meta = {\n // tags: ['old'],\n title: 'x',\n};\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).not.toBeNull(); + // The comment is left verbatim; a real tags key is injected. + expect(out).toContain("// tags: ['old'],"); + expect(out).toMatch(/^\s*tags: \['new'\],/m); + }); + + it('rewrites the real tags key and leaves a commented-out decoy intact', () => { + const source = `export const meta = {\n // tags: ['old'],\n tags: ['real'],\n};\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).toContain("// tags: ['old'],"); + expect(out).toContain("tags: ['new'],"); + expect(out).not.toContain("tags: ['real']"); + }); + + it('rewrites a quoted tags key rather than inserting a duplicate', () => { + const source = `export const meta = { 'tags': ['old'] };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).toBe(`export const meta = { 'tags': ['new'] };\nexport default [];\n`); + }); + + it('injects a real tags key past an unrelated quoted key', () => { + const source = `export const meta = { 'data-x': 'v' };\nexport default [];\n`; + const out = updateMetaTagsInSource(source, ['new']); + expect(out).toContain("'data-x': 'v'"); + expect(out).toMatch(/tags: \['new'\]/); + }); + + it('returns null for a quoted tags key with a non-array value', () => { + const source = `export const meta = { 'tags': 'oops' };\nexport default [];\n`; + expect(updateMetaTagsInSource(source, ['new'])).toBeNull(); + }); + + it('returns null if there is no meta and no default export', () => { + expect(updateMetaTagsInSource('// nothing here', ['x'])).toBeNull(); + }); +}); + 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..c06689049 100644 --- a/packages/core/src/editing/slide-ops.ts +++ b/packages/core/src/editing/slide-ops.ts @@ -215,19 +215,7 @@ export function updateMetaTitleInSource(source: string, title: string): string | 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 = matchMetaBrace(source, openBrace); if (closeBrace === -1) return null; const body = source.slice(openBrace + 1, closeBrace); @@ -254,6 +242,226 @@ export function updateMetaTitleInSource(source: string, title: string): string | return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx); } +/** Advance past the string literal that starts at `source[i]` (a quote char), honoring backslash escapes. */ +export function skipStringLiteral(source: string, i: number): number { + const quote = source[i]; + i++; + while (i < source.length) { + const ch = source[i]; + if (ch === '\\') { + i += 2; + continue; + } + if (ch === quote) return i + 1; + i++; + } + return i; +} + +/** + * If `source[i]` starts a `//` line or `/* *\/` block comment, return the index + * just past it; otherwise return `i` unchanged (so callers can tell whether a + * comment was consumed). Prevents commented-out `tags:`/braces from being read + * as real syntax. + */ +export function skipComment(source: string, i: number): number { + if (source[i] !== '/') return i; + const next = source[i + 1]; + if (next === '/') { + i += 2; + while (i < source.length && source[i] !== '\n') i++; + return i; + } + if (next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++; + return Math.min(i + 2, source.length); + } + return i; +} + +/** + * Locate the matching `}` of an object literal whose opening `{` is at + * `openBrace`, skipping over string literals and comments so a brace inside a + * string or comment can't skew the match. Returns the close index, or -1. + */ +export function matchMetaBrace(source: string, openBrace: number): number { + let depth = 0; + let i = openBrace; + while (i < source.length) { + const ch = source[i]; + if (ch === '"' || ch === "'" || ch === '`') { + i = skipStringLiteral(source, i); + continue; + } + if (ch === '/') { + const after = skipComment(source, i); + if (after > i) { + i = after; + continue; + } + } + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + i++; + } + return -1; +} + +/** + * Find the `tags: [...]` array of a meta object by scanning `[scanStart, scanEnd)` + * — the object's contents. Tracks bracket depth and skips over string literals + * and comments, and recognises the `tags` key whether it is written bare + * (`tags:`) or quoted (`'tags':`), so neither a `]` inside a tag, `tags: [...]` + * text inside another field's string/comment, nor a quoted key is mishandled. + * + * Returns the source range of the array literal (bracket indices, both + * inclusive), `null` when there is no top-level `tags` key, or `'unsafe'` when a + * `tags` key exists but its value is not an array literal. + */ +export function findMetaTagsArrayRange( + source: string, + scanStart: number, + scanEnd: number, +): { start: number; end: number } | null | 'unsafe' { + // `from` sits just past a confirmed `tags` key token: skip to the `:`, then + // require a `[` and walk the array to its matching `]` (string/comment aware). + const readArrayValue = (from: number): { start: number; end: number } | 'unsafe' => { + let k = from; + while (k < scanEnd && /\s/.test(source[k])) k++; + if (source[k] !== ':') return 'unsafe'; + k++; + while (k < scanEnd && /\s/.test(source[k])) k++; + if (source[k] !== '[') return 'unsafe'; + let m = k; + let arrDepth = 0; + while (m < scanEnd) { + const c = source[m]; + if (c === '"' || c === "'" || c === '`') { + m = skipStringLiteral(source, m); + continue; + } + if (c === '/') { + const after = skipComment(source, m); + if (after > m) { + m = after; + continue; + } + } + if (c === '[') arrDepth++; + else if (c === ']') { + arrDepth--; + if (arrDepth === 0) return { start: k, end: m }; + } + m++; + } + return 'unsafe'; + }; + + let i = scanStart; + let depth = 0; + while (i < scanEnd) { + const ch = source[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const end = skipStringLiteral(source, i); + // A quoted string at depth 0 followed by `:` is an object key. Recognise a + // quoted `tags` key; skip any other quoted key (never scan its content). + if (depth === 0) { + let p = end; + while (p < scanEnd && /\s/.test(source[p])) p++; + if (source[p] === ':' && source.slice(i + 1, end - 1) === 'tags') { + return readArrayValue(end); + } + } + i = end; + continue; + } + if (ch === '/') { + const after = skipComment(source, i); + if (after > i) { + i = after; + continue; + } + } + if (ch === '{' || ch === '[' || ch === '(') { + depth++; + i++; + continue; + } + if (ch === '}' || ch === ']' || ch === ')') { + depth--; + i++; + continue; + } + if (depth === 0 && /[A-Za-z_$]/.test(ch)) { + let j = i + 1; + while (j < scanEnd && /[A-Za-z0-9_$]/.test(source[j])) j++; + if (source.slice(i, j) === 'tags') { + let k = j; + while (k < scanEnd && /\s/.test(source[k])) k++; + if (source[k] === ':') return readArrayValue(j); + } + i = j; + continue; + } + i++; + } + return null; +} + +/** + * Rewrite (or insert) the `tags` array in the slide module's `export const meta`. + * + * Mirrors {@link updateMetaTitleInSource}: + * 1. Find `export const meta` and brace-match its object literal. + * 2. If it already has a `tags: [...]` entry, replace the array literal. + * 3. If the object exists but has no tags, inject a new `tags: [...]` line + * as the first property (preserving the author's indentation). + * 4. If there is no `meta` export at all, insert a fresh one right before + * `export default`. + * + * Returns the rewritten source, or `null` if the file shape was too surprising + * to touch safely. + */ +export function updateMetaTagsInSource(source: string, tags: string[]): string | null { + const arrayLiteral = `[${tags.map((t) => `'${escapeSingleQuoted(t)}'`).join(', ')}]`; + + const metaStart = source.search(/export\s+const\s+meta\b/); + if (metaStart !== -1) { + const eqIdx = source.indexOf('=', metaStart); + if (eqIdx === -1) return null; + const openBrace = source.indexOf('{', eqIdx); + if (openBrace === -1) return null; + + const closeBrace = matchMetaBrace(source, openBrace); + if (closeBrace === -1) return null; + + const located = findMetaTagsArrayRange(source, openBrace + 1, closeBrace); + if (located === 'unsafe') return null; + if (located) { + return source.slice(0, located.start) + arrayLiteral + source.slice(located.end + 1); + } + + // No tags yet — inject as the first property, copying the indentation of + // the first existing property (or a sensible default for an empty object). + const body = source.slice(openBrace + 1, closeBrace); + const firstIndentMatch = body.match(/\n([ \t]+)\S/); + const indent = firstIndentMatch ? firstIndentMatch[1] : ' '; + const trimmedBody = body.replace(/^\s*\n?/, ''); + const needsSeparator = trimmedBody.trim().length > 0; + const insertion = `\n${indent}tags: ${arrayLiteral}${needsSeparator ? ',' : ''}`; + return source.slice(0, openBrace + 1) + insertion + body + source.slice(closeBrace); + } + + const exportDefaultIdx = source.search(/export\s+default\b/); + if (exportDefaultIdx === -1) return null; + const insertion = `export const meta: SlideMeta = { tags: ${arrayLiteral} };\n\n`; + return source.slice(0, exportDefaultIdx) + insertion + source.slice(exportDefaultIdx); +} + type ArrayElementRange = { start: number; end: number }; function findDefaultExportArray( diff --git a/packages/core/src/locale/en.ts b/packages/core/src/locale/en.ts index 47ccc38fd..1bb9c947d 100644 --- a/packages/core/src/locale/en.ts +++ b/packages/core/src/locale/en.ts @@ -60,6 +60,8 @@ export const en: Locale = { folderActions: 'Folder actions', searchPlaceholder: 'Search slides', clearSearch: 'Clear search', + filterByTag: 'Tags', + filterTagsPlaceholder: 'Filter by tag…', sortLabel: 'Sort', sortByCreatedDesc: 'Newest', sortByCreatedAsc: 'Oldest', @@ -133,6 +135,10 @@ export const en: Locale = { slidesTab: 'Slides', assetsTab: 'Assets', renameSlide: 'Rename slide', + tagsAria: 'Edit tags', + addTag: 'Add tag…', + createTag: 'Create "{name}"', + tagsSaveFailed: 'Failed to save tags', loadingEyebrow: 'Loading', loadingAssetsEyebrow: 'Loading assets', emptyEyebrow: 'Empty', diff --git a/packages/core/src/locale/ja.ts b/packages/core/src/locale/ja.ts index 816d5fcee..5052aae24 100644 --- a/packages/core/src/locale/ja.ts +++ b/packages/core/src/locale/ja.ts @@ -60,6 +60,8 @@ export const ja: Locale = { folderActions: 'フォルダ操作', searchPlaceholder: 'スライドを検索', clearSearch: '検索をクリア', + filterByTag: 'タグ', + filterTagsPlaceholder: 'タグで絞り込み…', sortLabel: '並べ替え', sortByCreatedDesc: '新しい順', sortByCreatedAsc: '古い順', @@ -133,6 +135,10 @@ export const ja: Locale = { slidesTab: 'スライド', assetsTab: 'アセット', renameSlide: 'スライドの名前を変更', + tagsAria: 'タグを編集', + addTag: 'タグを追加…', + createTag: '「{name}」を作成', + tagsSaveFailed: 'タグの保存に失敗しました', loadingEyebrow: '読み込み中', loadingAssetsEyebrow: 'アセットを読み込み中', emptyEyebrow: '空', diff --git a/packages/core/src/locale/types.ts b/packages/core/src/locale/types.ts index c6d65ef41..056d9a4c2 100644 --- a/packages/core/src/locale/types.ts +++ b/packages/core/src/locale/types.ts @@ -60,6 +60,8 @@ export type Locale = { folderActions: string; searchPlaceholder: string; clearSearch: string; + filterByTag: string; + filterTagsPlaceholder: string; sortLabel: string; sortByCreatedDesc: string; sortByCreatedAsc: string; @@ -133,6 +135,11 @@ export type Locale = { slidesTab: string; assetsTab: string; renameSlide: string; + tagsAria: string; + addTag: string; + /** template: 'Create "{name}"' */ + createTag: string; + tagsSaveFailed: string; loadingEyebrow: string; loadingAssetsEyebrow: string; emptyEyebrow: string; diff --git a/packages/core/src/locale/zh-cn.ts b/packages/core/src/locale/zh-cn.ts index a2682bb06..045dbc5fc 100644 --- a/packages/core/src/locale/zh-cn.ts +++ b/packages/core/src/locale/zh-cn.ts @@ -60,6 +60,8 @@ export const zhCN: Locale = { folderActions: '文件夹操作', searchPlaceholder: '搜索幻灯片', clearSearch: '清除搜索', + filterByTag: '标签', + filterTagsPlaceholder: '按标签筛选…', sortLabel: '排序', sortByCreatedDesc: '最新', sortByCreatedAsc: '最旧', @@ -131,6 +133,10 @@ export const zhCN: Locale = { slidesTab: '幻灯片', assetsTab: '素材', renameSlide: '重命名幻灯片', + tagsAria: '编辑标签', + addTag: '添加标签…', + createTag: '创建“{name}”', + tagsSaveFailed: '保存标签失败', loadingEyebrow: '加载中', loadingAssetsEyebrow: '加载资源中', emptyEyebrow: '空白', diff --git a/packages/core/src/locale/zh-tw.ts b/packages/core/src/locale/zh-tw.ts index dcb706148..677d358e4 100644 --- a/packages/core/src/locale/zh-tw.ts +++ b/packages/core/src/locale/zh-tw.ts @@ -60,6 +60,8 @@ export const zhTW: Locale = { folderActions: '資料夾操作', searchPlaceholder: '搜尋投影片', clearSearch: '清除搜尋', + filterByTag: '標籤', + filterTagsPlaceholder: '依標籤篩選…', sortLabel: '排序', sortByCreatedDesc: '最新', sortByCreatedAsc: '最舊', @@ -131,6 +133,10 @@ export const zhTW: Locale = { slidesTab: '投影片', assetsTab: '素材', renameSlide: '重新命名投影片', + tagsAria: '編輯標籤', + addTag: '新增標籤…', + createTag: '建立「{name}」', + tagsSaveFailed: '儲存標籤失敗', loadingEyebrow: '載入中', loadingAssetsEyebrow: '載入資源中', emptyEyebrow: '空白', diff --git a/packages/core/src/vite/open-slide-plugin.test.ts b/packages/core/src/vite/open-slide-plugin.test.ts index 43311d479..18ea2d282 100644 --- a/packages/core/src/vite/open-slide-plugin.test.ts +++ b/packages/core/src/vite/open-slide-plugin.test.ts @@ -24,6 +24,13 @@ async function writeSlide(root: string, id: string): Promise { return entry; } +async function writeSlideSource(root: string, id: string, source: string): Promise { + await fs.mkdir(path.join(root, id), { recursive: true }); + const entry = path.join(root, id, 'index.tsx'); + await fs.writeFile(entry, source, 'utf8'); + return entry; +} + describe('generateSlidesModule', () => { it('keeps slides whose id is ASCII-safe and reports none ignored', async () => { await withSlidesRoot(async (root) => { @@ -47,4 +54,75 @@ describe('generateSlidesModule', () => { expect(code).not.toContain('推薦系統'); }); }); + + it('emits slideTags parsed from meta, unescaping quotes and ignoring decoys', async () => { + await withSlidesRoot(async (root) => { + const files = [ + await writeSlideSource( + root, + 'a', + `export const meta = { title: 'has tags: [decoy]', tags: ['it\\'s', 'topic'] };\nexport default [];\n`, + ), + await writeSlideSource( + root, + 'b', + `export const meta = { title: 'b' };\nexport default [];\n`, + ), + ].sort(); + + const { code } = await generateSlidesModule(files, root, false); + + // The escaped quote is decoded and the [decoy] inside the title string is + // not mistaken for the tags array; slides without tags are omitted. + expect(code).toContain(`export const slideTags = {"a":["it's","topic"]};`); + }); + }); + + it('ignores a commented-out tags decoy when emitting slideTags', async () => { + await withSlidesRoot(async (root) => { + const files = [ + await writeSlideSource( + root, + 'a', + `export const meta = {\n // tags: ['decoy'],\n tags: ['real'],\n};\nexport default [];\n`, + ), + ]; + + const { code } = await generateSlidesModule(files, root, false); + + expect(code).toContain(`export const slideTags = {"a":["real"]};`); + }); + }); + + it('extracts tags when a string value contains an unbalanced brace', async () => { + await withSlidesRoot(async (root) => { + const files = [ + await writeSlideSource( + root, + 'a', + `export const meta = { title: 'contains }', tags: ['kept'] };\nexport default [];\n`, + ), + ]; + + const { code } = await generateSlidesModule(files, root, false); + + expect(code).toContain(`export const slideTags = {"a":["kept"]};`); + }); + }); + + it('ignores string tokens inside comments within the tags array', async () => { + await withSlidesRoot(async (root) => { + const files = [ + await writeSlideSource( + root, + 'a', + `export const meta = { tags: [/* 'internal' */ 'public'] };\nexport default [];\n`, + ), + ]; + + const { code } = await generateSlidesModule(files, root, false); + + expect(code).toContain(`export const slideTags = {"a":["public"]};`); + }); + }); }); diff --git a/packages/core/src/vite/open-slide-plugin.ts b/packages/core/src/vite/open-slide-plugin.ts index 661e04dc7..be7e2b73c 100644 --- a/packages/core/src/vite/open-slide-plugin.ts +++ b/packages/core/src/vite/open-slide-plugin.ts @@ -4,7 +4,13 @@ import path from 'node:path'; import fg from 'fast-glob'; import { loadConfigFromFile, normalizePath, type Plugin, type ViteDevServer } from 'vite'; import type { OpenSlideConfig } from '../config.ts'; -import { SLIDE_ID_RE } from '../editing/slide-ops.ts'; +import { + findMetaTagsArrayRange, + matchMetaBrace, + SLIDE_ID_RE, + skipComment, + skipStringLiteral, +} from '../editing/slide-ops.ts'; import { hasRecentWrite } from './recent-writes.ts'; export type { OpenSlideConfig }; @@ -17,7 +23,7 @@ export type OpenSlidePluginOptions = { const CONFIG_FILE = 'open-slide.config.ts'; -const SLIDES_VMOD = 'virtual:open-slide/slides'; +export const SLIDES_VMOD = 'virtual:open-slide/slides'; const CONFIG_VMOD = 'virtual:open-slide/config'; const FOLDERS_VMOD = 'virtual:open-slide/folders'; @@ -68,29 +74,46 @@ function toId(absFile: string, slidesRoot: string): string { const META_THEME_RE = /(?:^|[\s,{])theme\s*:\s*['"]([^'"]+)['"]/; const META_CREATED_AT_RE = /(?:^|[\s,{])createdAt\s*:\s*['"]([^'"]+)['"]/; -type ExtractedMeta = { theme: string | null; createdAt: string | null }; +function parseTags(body: string): string[] { + const range = findMetaTagsArrayRange(body, 0, body.length); + if (range === null || range === 'unsafe') return []; + const out: string[] = []; + let i = range.start + 1; + while (i < range.end) { + const ch = body[i]; + if (ch === '/') { + const after = skipComment(body, i); + if (after > i) { + i = after; + continue; + } + } + if (ch === '"' || ch === "'" || ch === '`') { + const strEnd = skipStringLiteral(body, i); + const t = body + .slice(i + 1, strEnd - 1) + .replace(/\\(.)/g, '$1') + .trim(); + if (t && !out.includes(t)) out.push(t); + i = strEnd; + continue; + } + i++; + } + return out; +} + +type ExtractedMeta = { theme: string | null; createdAt: string | null; tags: string[] }; function extractMeta(src: string): ExtractedMeta { - const empty: ExtractedMeta = { theme: null, createdAt: null }; + const empty: ExtractedMeta = { theme: null, createdAt: null, tags: [] }; const metaStart = src.search(/export\s+const\s+meta\b/); if (metaStart === -1) return empty; const eqIdx = src.indexOf('=', metaStart); if (eqIdx === -1) return empty; const openBrace = src.indexOf('{', eqIdx); if (openBrace === -1) return empty; - let depth = 0; - let closeBrace = -1; - for (let i = openBrace; i < src.length; i++) { - const ch = src[i]; - if (ch === '{') depth++; - else if (ch === '}') { - depth--; - if (depth === 0) { - closeBrace = i; - break; - } - } - } + const closeBrace = matchMetaBrace(src, openBrace); if (closeBrace === -1) return empty; const body = src.slice(openBrace + 1, closeBrace); const themeMatch = body.match(META_THEME_RE); @@ -98,6 +121,7 @@ function extractMeta(src: string): ExtractedMeta { return { theme: themeMatch ? themeMatch[1] : null, createdAt: createdAtMatch ? createdAtMatch[1] : null, + tags: parseTags(body), }; } @@ -106,7 +130,7 @@ async function readSlideMeta(abs: string): Promise { const src = await fs.readFile(abs, 'utf8'); return extractMeta(src); } catch { - return { theme: null, createdAt: null }; + return { theme: null, createdAt: null, tags: [] }; } } @@ -130,7 +154,13 @@ export async function generateSlidesModule( const id = toId(abs, slidesRoot); const importPath = isDev ? `@fs/${normalizePath(abs).replace(/^\/+/, '')}` : abs; const meta = await readSlideMeta(abs); - return { id, importPath, theme: meta.theme, createdAt: parseCreatedAtMs(meta.createdAt) }; + return { + id, + importPath, + theme: meta.theme, + createdAt: parseCreatedAtMs(meta.createdAt), + tags: Array.isArray(meta.tags) ? meta.tags : [], + }; }), ); @@ -144,12 +174,15 @@ export async function generateSlidesModule( const ids = JSON.stringify(entries.map((e) => e.id).sort()); const themesMap: Record = {}; const createdAtMap: Record = {}; + const tagsMap: Record = {}; for (const e of entries) { if (e.theme) themesMap[e.id] = e.theme; if (e.createdAt !== null) createdAtMap[e.id] = e.createdAt; + if (e.tags && e.tags.length > 0) tagsMap[e.id] = e.tags; } const themesJson = JSON.stringify(themesMap); const createdAtJson = JSON.stringify(createdAtMap); + const tagsJson = JSON.stringify(tagsMap); const importTokens = JSON.stringify(Object.fromEntries(entries.map((e) => [e.id, 0]))); const devRuntime = isDev ? ` @@ -178,6 +211,7 @@ if (import.meta.hot) { export const slideIds = ${ids}; export const slideThemes = ${themesJson}; export const slideCreatedAt = ${createdAtJson}; +export const slideTags = ${tagsJson}; ${devRuntime} export async function loadSlide(id) { diff --git a/packages/core/src/vite/routes/slides.ts b/packages/core/src/vite/routes/slides.ts index 0e9ef2c33..af3732df4 100644 --- a/packages/core/src/vite/routes/slides.ts +++ b/packages/core/src/vite/routes/slides.ts @@ -11,22 +11,34 @@ import { resolveSlideEntry, rmSlideDir, SLIDE_ID_RE, + updateMetaTagsInSource, updateMetaTitleInSource, validateSlideName, } from '../../editing/slide-ops.ts'; import { readManifest, writeManifest } from '../../files/folders.ts'; import { validateMutationRequest } from '../../http/request-guard.ts'; +import { SLIDES_VMOD } from '../open-slide-plugin.ts'; import { type ApiContext, json, readBody } from './context.ts'; // PUT /__slides/:id/reorder reorder pages { order: number[] } // DELETE /__slides/:id/pages/:i remove page // POST /__slides/:id/pages/:i/duplicate duplicate page // POST /__slides/:id/duplicate duplicate slide directory { newId? } -// PATCH /__slides/:id rename slide (writes meta.title) +// PATCH /__slides/:id rename slide + edit tags (writes meta.title/meta.tags) // DELETE /__slides/:id delete slide directory + folder assignment type DuplicateSlideBody = { newId?: unknown }; -type SlidePatchBody = { name?: unknown }; +type SlidePatchBody = { name?: unknown; tags?: unknown }; + +function sanitizeTag(v: unknown): string | null { + if (typeof v !== 'string') return null; + const t = v + .trim() + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\p{L}\p{N}._-]/gu, ''); + return t.length > 0 && t.length <= 64 ? t : null; +} export function registerSlideRoutes(server: ViteDevServer, ctx: ApiContext): void { server.middlewares.use('/__slides', async (req, res, next) => { @@ -169,8 +181,27 @@ export function registerSlideRoutes(server: ViteDevServer, ctx: ApiContext): voi return json(res, requestCheck.status, { error: requestCheck.error }); } const body = (await readBody(req)) as SlidePatchBody; - const name = validateSlideName(body.name); - if (!name) return json(res, 400, { error: 'invalid name' }); + const hasName = body.name !== undefined; + const hasTags = body.tags !== undefined; + if (!hasName && !hasTags) return json(res, 400, { error: 'nothing to update' }); + + const name = hasName ? validateSlideName(body.name) : null; + if (hasName && !name) return json(res, 400, { error: 'invalid name' }); + + let tags: string[] | null = null; + if (hasTags) { + if (!Array.isArray(body.tags)) return json(res, 400, { error: 'invalid tags' }); + const seen = new Set(); + tags = []; + for (const raw of body.tags) { + const tag = sanitizeTag(raw); + if (tag && !seen.has(tag)) { + seen.add(tag); + tags.push(tag); + } + if (tags.length >= 50) break; + } + } const entry = resolveSlideEntry(ctx.slidesRoot, slideId); if (!entry) return json(res, 400, { error: 'invalid slideId' }); @@ -182,20 +213,42 @@ export function registerSlideRoutes(server: ViteDevServer, ctx: ApiContext): voi return json(res, 404, { error: 'slide not found' }); } - const updated = updateMetaTitleInSource(source, name); - if (updated === null) { - return json(res, 422, { - error: 'could not locate a safe place to write meta.title in index.tsx', - }); + let updated = source; + if (name) { + const next = updateMetaTitleInSource(updated, name); + if (next === null) { + return json(res, 422, { + error: 'could not locate a safe place to write meta.title in index.tsx', + }); + } + updated = next; + } + if (tags !== null) { + const next = updateMetaTagsInSource(updated, tags); + if (next === null) { + return json(res, 422, { + error: 'could not locate a safe place to write meta.tags in index.tsx', + }); + } + updated = next; } if (updated !== source) { await fs.writeFile(entry, updated, 'utf8'); } // The TSX edit lands through Vite's normal HMR pipeline, but the // React state holding `slide.meta` in the editor won't re-fetch on - // its own — tell every client to refresh so the new title shows up. + // its own — tell every client to refresh so the new title/tags show up. + // Invalidate the slides virtual module first so the reload rebuilds it + // with the new meta.tags rather than racing the debounced file watcher. + const slidesMod = server.moduleGraph.getModuleById(`\0${SLIDES_VMOD}`); + if (slidesMod) server.moduleGraph.invalidateModule(slidesMod); server.ws.send({ type: 'full-reload' }); - return json(res, 200, { ok: true, slideId, name }); + return json(res, 200, { + ok: true, + slideId, + name: name ?? undefined, + tags: tags ?? undefined, + }); } if (method === 'DELETE') {