@taylorvance/tv-shared-runtime
Advanced tools
| import { type ComponentPropsWithoutRef, type ReactNode } from 'react'; | ||
| import { type ProjectStorage, type StorageKeyPart, type StorageLike } from './storage.js'; | ||
| export interface ProjectStorageInspectorVersionOption { | ||
| label: string; | ||
| value: StorageKeyPart | null; | ||
| } | ||
| export interface ProjectStorageNamespaceEntry { | ||
| relativeKey: string; | ||
| rawValue: string; | ||
| } | ||
| export interface ProjectStorageNamespaceSnapshot { | ||
| entries: ProjectStorageNamespaceEntry[]; | ||
| projectKey: string; | ||
| version?: StorageKeyPart; | ||
| } | ||
| export interface ImportProjectStorageNamespaceOptions { | ||
| mode?: 'merge' | 'replace'; | ||
| requireNamespaceMatch?: boolean; | ||
| } | ||
| export type ProjectStorageInspectorProps = Omit<ComponentPropsWithoutRef<'section'>, 'children'> & { | ||
| defaultRelativeKey?: string; | ||
| emptyMessage?: ReactNode; | ||
| projectKey: string; | ||
| storage?: StorageLike | null; | ||
| title?: ReactNode; | ||
| unstyled?: boolean; | ||
| version?: StorageKeyPart; | ||
| versions?: readonly ProjectStorageInspectorVersionOption[]; | ||
| }; | ||
| export declare const exportProjectStorageNamespace: (projectStorage: ProjectStorage) => ProjectStorageNamespaceSnapshot; | ||
| export declare const stringifyProjectStorageNamespace: (projectStorage: ProjectStorage) => string; | ||
| export declare const parseProjectStorageNamespace: (value: string) => ProjectStorageNamespaceSnapshot; | ||
| export declare const importProjectStorageNamespace: (projectStorage: ProjectStorage, snapshot: ProjectStorageNamespaceSnapshot, options?: ImportProjectStorageNamespaceOptions) => number; | ||
| export declare function ProjectStorageInspector({ className, defaultRelativeKey, emptyMessage, projectKey, storage, style, title, unstyled, version, versions, ...props }: ProjectStorageInspectorProps): import("react/jsx-runtime").JSX.Element; | ||
| //# sourceMappingURL=storage-dev.d.ts.map |
| {"version":3,"file":"storage-dev.d.ts","sourceRoot":"","sources":["../src/storage-dev.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,wBAAwB,EAAE,KAAK,SAAS,EAAuB,MAAM,OAAO,CAAC;AAC/G,OAAO,EAEL,KAAK,cAAc,EAEnB,KAAK,cAAc,EACnB,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,oCAAoC;IACnD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,cAAc,GAAG,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,+BAA+B;IAC9C,OAAO,EAAE,4BAA4B,EAAE,CAAC;IACxC,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED,MAAM,WAAW,oCAAoC;IACnD,IAAI,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC3B,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED,MAAM,MAAM,4BAA4B,GAAG,IAAI,CAAC,wBAAwB,CAAC,SAAS,CAAC,EAAE,UAAU,CAAC,GAAG;IACjG,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B,KAAK,CAAC,EAAE,SAAS,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB,QAAQ,CAAC,EAAE,SAAS,oCAAoC,EAAE,CAAC;CAC5D,CAAC;AAsMF,eAAO,MAAM,6BAA6B,GACxC,gBAAgB,cAAc,KAC7B,+BAOD,CAAC;AAEH,eAAO,MAAM,gCAAgC,GAC3C,gBAAgB,cAAc,WAC2C,CAAC;AAE5E,eAAO,MAAM,4BAA4B,GACvC,OAAO,MAAM,KACZ,+BAuCF,CAAC;AAEF,eAAO,MAAM,6BAA6B,GACxC,gBAAgB,cAAc,EAC9B,UAAU,+BAA+B,EACzC,UAAS,oCAAyC,WAoBnD,CAAC;AAEF,wBAAgB,uBAAuB,CAAC,EACtC,SAAS,EACT,kBAAuB,EACvB,YAA2C,EAC3C,UAAU,EACV,OAAO,EACP,KAAK,EACL,KAAmC,EACnC,QAAgB,EAChB,OAAO,EACP,QAAQ,EACR,GAAG,KAAK,EACT,EAAE,4BAA4B,2CA+R9B"} |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { useEffect, useState } from 'react'; | ||
| import { createProjectStorage, } from './storage.js'; | ||
| const DEFAULT_ROOT_STYLE = { | ||
| backgroundColor: '#f8fafc', | ||
| border: '1px solid #cbd5e1', | ||
| borderRadius: '1rem', | ||
| color: '#0f172a', | ||
| display: 'grid', | ||
| gap: '1rem', | ||
| padding: '1rem', | ||
| }; | ||
| const DEFAULT_HEADER_STYLE = { | ||
| alignItems: 'center', | ||
| display: 'flex', | ||
| flexWrap: 'wrap', | ||
| gap: '0.75rem', | ||
| justifyContent: 'space-between', | ||
| }; | ||
| const DEFAULT_TOOLBAR_STYLE = { | ||
| alignItems: 'center', | ||
| display: 'flex', | ||
| flexWrap: 'wrap', | ||
| gap: '0.5rem', | ||
| }; | ||
| const DEFAULT_GRID_STYLE = { | ||
| display: 'grid', | ||
| gap: '1rem', | ||
| gridTemplateColumns: 'minmax(14rem, 18rem) minmax(0, 1fr)', | ||
| }; | ||
| const DEFAULT_LIST_STYLE = { | ||
| border: '1px solid #cbd5e1', | ||
| borderRadius: '0.75rem', | ||
| display: 'grid', | ||
| gap: '0.5rem', | ||
| maxHeight: '22rem', | ||
| overflow: 'auto', | ||
| padding: '0.75rem', | ||
| }; | ||
| const DEFAULT_EDITOR_STYLE = { | ||
| display: 'grid', | ||
| gap: '0.75rem', | ||
| }; | ||
| const DEFAULT_TRANSFER_STYLE = { | ||
| borderTop: '1px solid #cbd5e1', | ||
| display: 'grid', | ||
| gap: '0.75rem', | ||
| paddingTop: '1rem', | ||
| }; | ||
| const DEFAULT_BUTTON_STYLE = { | ||
| backgroundColor: '#e2e8f0', | ||
| border: '1px solid #94a3b8', | ||
| borderRadius: '0.5rem', | ||
| color: 'inherit', | ||
| cursor: 'pointer', | ||
| font: 'inherit', | ||
| padding: '0.45rem 0.7rem', | ||
| }; | ||
| const DEFAULT_INPUT_STYLE = { | ||
| backgroundColor: '#ffffff', | ||
| border: '1px solid #94a3b8', | ||
| borderRadius: '0.5rem', | ||
| color: 'inherit', | ||
| font: 'inherit', | ||
| padding: '0.5rem 0.65rem', | ||
| width: '100%', | ||
| }; | ||
| const DEFAULT_TEXTAREA_STYLE = { | ||
| ...DEFAULT_INPUT_STYLE, | ||
| fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', | ||
| minHeight: '14rem', | ||
| resize: 'vertical', | ||
| }; | ||
| const DEFAULT_TRANSFER_TEXTAREA_STYLE = { | ||
| ...DEFAULT_TEXTAREA_STYLE, | ||
| minHeight: '12rem', | ||
| }; | ||
| const DEFAULT_KEY_BUTTON_STYLE = { | ||
| backgroundColor: '#ffffff', | ||
| border: '1px solid #cbd5e1', | ||
| borderRadius: '0.5rem', | ||
| cursor: 'pointer', | ||
| display: 'grid', | ||
| font: 'inherit', | ||
| gap: '0.2rem', | ||
| padding: '0.55rem 0.65rem', | ||
| textAlign: 'left', | ||
| width: '100%', | ||
| }; | ||
| const DEFAULT_SELECTED_KEY_BUTTON_STYLE = { | ||
| borderColor: '#2563eb', | ||
| boxShadow: '0 0 0 1px #2563eb inset', | ||
| }; | ||
| const DEFAULT_META_STYLE = { | ||
| color: '#475569', | ||
| fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', | ||
| fontSize: '0.8rem', | ||
| }; | ||
| const resolveInspectorStorage = (storage) => { | ||
| if (storage !== undefined) { | ||
| return storage; | ||
| } | ||
| if (typeof window === 'undefined') { | ||
| return null; | ||
| } | ||
| try { | ||
| return window.localStorage; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| }; | ||
| const getVersionOptions = (version, versions) => { | ||
| if (versions && versions.length > 0) { | ||
| return [...versions]; | ||
| } | ||
| if (version === undefined) { | ||
| return []; | ||
| } | ||
| return [{ | ||
| label: `v${version}`, | ||
| value: version, | ||
| }]; | ||
| }; | ||
| const buildFullKey = (projectStoragePrefix, relativeKey) => (relativeKey.length === 0 ? projectStoragePrefix : `${projectStoragePrefix}:${relativeKey}`); | ||
| const formatEntryLabel = (entry) => (entry.relativeKey.length === 0 ? '(root key)' : entry.relativeKey); | ||
| const relativeKeyToParts = (relativeKey) => { | ||
| if (relativeKey.length === 0) { | ||
| return []; | ||
| } | ||
| const parts = relativeKey.split(':'); | ||
| if (parts.some((part) => part.length === 0)) { | ||
| throw new Error('Imported relative keys must not contain empty segments.'); | ||
| } | ||
| return parts; | ||
| }; | ||
| const isStorageKeyPart = (value) => (typeof value === 'string' || typeof value === 'number'); | ||
| const isNamespaceEntry = (value) => { | ||
| if (!value || typeof value !== 'object') { | ||
| return false; | ||
| } | ||
| const entry = value; | ||
| return (typeof entry.relativeKey === 'string' | ||
| && typeof entry.rawValue === 'string'); | ||
| }; | ||
| const matchesNamespace = (projectStorage, snapshot) => { | ||
| if (snapshot.projectKey !== projectStorage.projectKey) { | ||
| return false; | ||
| } | ||
| if (projectStorage.version === undefined) { | ||
| return snapshot.version === undefined; | ||
| } | ||
| return snapshot.version === projectStorage.version; | ||
| }; | ||
| export const exportProjectStorageNamespace = (projectStorage) => ({ | ||
| entries: projectStorage.list().map(({ relativeKey, rawValue }) => ({ | ||
| relativeKey, | ||
| rawValue, | ||
| })), | ||
| projectKey: projectStorage.projectKey, | ||
| ...(projectStorage.version === undefined ? {} : { version: projectStorage.version }), | ||
| }); | ||
| export const stringifyProjectStorageNamespace = (projectStorage) => JSON.stringify(exportProjectStorageNamespace(projectStorage), null, 2); | ||
| export const parseProjectStorageNamespace = (value) => { | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(value); | ||
| } | ||
| catch { | ||
| throw new Error('Namespace JSON must be valid JSON.'); | ||
| } | ||
| if (!parsed || typeof parsed !== 'object') { | ||
| throw new Error('Namespace JSON must be an object.'); | ||
| } | ||
| const parsedRecord = parsed; | ||
| const projectKey = parsedRecord.projectKey; | ||
| const version = parsedRecord.version; | ||
| const entries = parsedRecord.entries; | ||
| if (typeof projectKey !== 'string' || projectKey.length === 0) { | ||
| throw new Error('Namespace JSON must include a non-empty projectKey.'); | ||
| } | ||
| if (version !== undefined && !isStorageKeyPart(version)) { | ||
| throw new Error('Namespace JSON version must be a string or number when present.'); | ||
| } | ||
| if (!Array.isArray(entries) || !entries.every(isNamespaceEntry)) { | ||
| throw new Error('Namespace JSON entries must be an array of { relativeKey, rawValue }.'); | ||
| } | ||
| entries.forEach((entry) => { | ||
| relativeKeyToParts(entry.relativeKey); | ||
| }); | ||
| return { | ||
| entries, | ||
| projectKey, | ||
| ...(version === undefined ? {} : { version }), | ||
| }; | ||
| }; | ||
| export const importProjectStorageNamespace = (projectStorage, snapshot, options = {}) => { | ||
| const { mode = 'merge', requireNamespaceMatch = true, } = options; | ||
| if (requireNamespaceMatch && !matchesNamespace(projectStorage, snapshot)) { | ||
| throw new Error('Imported namespace does not match the selected project key and version.'); | ||
| } | ||
| if (mode === 'replace') { | ||
| projectStorage.clear(); | ||
| } | ||
| for (const entry of snapshot.entries) { | ||
| projectStorage.writeString(entry.rawValue, ...relativeKeyToParts(entry.relativeKey)); | ||
| } | ||
| return snapshot.entries.length; | ||
| }; | ||
| export function ProjectStorageInspector({ className, defaultRelativeKey = '', emptyMessage = 'No keys in this namespace.', projectKey, storage, style, title = 'Project Storage Inspector', unstyled = false, version, versions, ...props }) { | ||
| const versionOptions = getVersionOptions(version, versions); | ||
| const [selectedVersion, setSelectedVersion] = useState(versionOptions[0]?.value ?? version ?? null); | ||
| const [entries, setEntries] = useState([]); | ||
| const [selectedRelativeKey, setSelectedRelativeKey] = useState(defaultRelativeKey); | ||
| const [draftRelativeKey, setDraftRelativeKey] = useState(defaultRelativeKey); | ||
| const [editorValue, setEditorValue] = useState(''); | ||
| const [status, setStatus] = useState(null); | ||
| const [transferValue, setTransferValue] = useState(''); | ||
| const projectStorage = createProjectStorage(projectKey, { | ||
| ...(storage === undefined ? {} : { storage }), | ||
| ...(selectedVersion === null ? {} : { version: selectedVersion }), | ||
| }); | ||
| const syncTransferValue = () => { | ||
| setTransferValue(stringifyProjectStorageNamespace(projectStorage)); | ||
| }; | ||
| const refreshEntries = (nextSelectedKey = selectedRelativeKey) => { | ||
| const nextEntries = projectStorage.list(); | ||
| setEntries(nextEntries); | ||
| const matchingEntry = nextEntries.find((entry) => entry.relativeKey === nextSelectedKey); | ||
| if (matchingEntry) { | ||
| setSelectedRelativeKey(matchingEntry.relativeKey); | ||
| setDraftRelativeKey(matchingEntry.relativeKey); | ||
| setEditorValue(matchingEntry.rawValue); | ||
| return; | ||
| } | ||
| if ((nextSelectedKey === null || nextSelectedKey.length === 0) && nextEntries[0]) { | ||
| setSelectedRelativeKey(nextEntries[0].relativeKey); | ||
| setDraftRelativeKey(nextEntries[0].relativeKey); | ||
| setEditorValue(nextEntries[0].rawValue); | ||
| return; | ||
| } | ||
| setSelectedRelativeKey(nextSelectedKey); | ||
| setDraftRelativeKey(nextSelectedKey); | ||
| if (nextSelectedKey.length === 0) { | ||
| setEditorValue(''); | ||
| return; | ||
| } | ||
| const activeStorage = resolveInspectorStorage(storage); | ||
| if (!activeStorage) { | ||
| setEditorValue(''); | ||
| return; | ||
| } | ||
| try { | ||
| setEditorValue(activeStorage.getItem(buildFullKey(projectStorage.key(), nextSelectedKey)) ?? ''); | ||
| } | ||
| catch { | ||
| setEditorValue(''); | ||
| } | ||
| }; | ||
| useEffect(() => { | ||
| refreshEntries(defaultRelativeKey); | ||
| syncTransferValue(); | ||
| setStatus(null); | ||
| }, [defaultRelativeKey, projectKey, selectedVersion, storage]); | ||
| const handleSelectEntry = (entry) => { | ||
| setSelectedRelativeKey(entry.relativeKey); | ||
| setDraftRelativeKey(entry.relativeKey); | ||
| setEditorValue(entry.rawValue); | ||
| setStatus(null); | ||
| }; | ||
| const handleSave = () => { | ||
| const activeStorage = resolveInspectorStorage(storage); | ||
| const nextRelativeKey = draftRelativeKey.trim(); | ||
| if (!activeStorage) { | ||
| setStatus('Storage is unavailable.'); | ||
| return; | ||
| } | ||
| try { | ||
| activeStorage.setItem(buildFullKey(projectStorage.key(), nextRelativeKey), editorValue); | ||
| setStatus('Saved.'); | ||
| refreshEntries(nextRelativeKey); | ||
| } | ||
| catch { | ||
| setStatus('Save failed.'); | ||
| } | ||
| }; | ||
| const handleRemove = () => { | ||
| const activeStorage = resolveInspectorStorage(storage); | ||
| const nextRelativeKey = draftRelativeKey.trim(); | ||
| if (!activeStorage) { | ||
| setStatus('Storage is unavailable.'); | ||
| return; | ||
| } | ||
| try { | ||
| activeStorage.removeItem(buildFullKey(projectStorage.key(), nextRelativeKey)); | ||
| setStatus('Removed.'); | ||
| refreshEntries(''); | ||
| } | ||
| catch { | ||
| setStatus('Remove failed.'); | ||
| } | ||
| }; | ||
| const handleClear = () => { | ||
| projectStorage.clear(); | ||
| setStatus('Namespace cleared.'); | ||
| refreshEntries(''); | ||
| syncTransferValue(); | ||
| }; | ||
| const handleCopyNamespaceJson = async () => { | ||
| try { | ||
| if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { | ||
| setStatus('Clipboard copy is unavailable. Copy from the textarea instead.'); | ||
| return; | ||
| } | ||
| await navigator.clipboard.writeText(transferValue); | ||
| setStatus('Namespace JSON copied.'); | ||
| } | ||
| catch { | ||
| setStatus('Clipboard copy failed. Copy from the textarea instead.'); | ||
| } | ||
| }; | ||
| const handleImportNamespace = (mode) => { | ||
| try { | ||
| const snapshot = parseProjectStorageNamespace(transferValue); | ||
| const importedEntryCount = importProjectStorageNamespace(projectStorage, snapshot, { mode }); | ||
| refreshEntries(''); | ||
| syncTransferValue(); | ||
| setStatus(`${mode === 'replace' ? 'Replaced' : 'Merged'} ${importedEntryCount} entries.`); | ||
| } | ||
| catch (error) { | ||
| setStatus(error instanceof Error ? error.message : 'Import failed.'); | ||
| } | ||
| }; | ||
| const mergedStyle = unstyled ? style : { ...DEFAULT_ROOT_STYLE, ...style }; | ||
| return (_jsxs("section", { className: className, style: mergedStyle, ...props, children: [_jsxs("div", { style: unstyled ? undefined : DEFAULT_HEADER_STYLE, children: [_jsxs("div", { children: [_jsx("strong", { children: title }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: ["Namespace: ", _jsx("code", { children: projectStorage.key() })] })] }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_TOOLBAR_STYLE, children: [versionOptions.length > 0 ? (_jsxs("label", { children: [_jsx("span", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: "Version " }), _jsx("select", { "aria-label": "Storage version", onChange: (event) => { | ||
| const nextValue = event.target.value; | ||
| setSelectedVersion(nextValue === '__none__' ? null : nextValue); | ||
| }, style: unstyled ? undefined : DEFAULT_INPUT_STYLE, value: selectedVersion === null ? '__none__' : `${selectedVersion}`, children: versionOptions.map((option) => (_jsx("option", { value: option.value === null ? '__none__' : `${option.value}`, children: option.label }, `${option.label}:${option.value ?? '__none__'}`))) })] })) : null, _jsx("button", { onClick: () => refreshEntries(), style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Refresh" }), _jsx("button", { onClick: handleClear, style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Clear Namespace" })] })] }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_GRID_STYLE, children: [_jsxs("div", { style: unstyled ? undefined : DEFAULT_LIST_STYLE, children: [entries.length === 0 ? _jsx("div", { children: emptyMessage }) : null, entries.map((entry) => { | ||
| const isSelected = entry.relativeKey === selectedRelativeKey; | ||
| return (_jsxs("button", { onClick: () => handleSelectEntry(entry), style: unstyled ? undefined : { | ||
| ...DEFAULT_KEY_BUTTON_STYLE, | ||
| ...(isSelected ? DEFAULT_SELECTED_KEY_BUTTON_STYLE : {}), | ||
| }, type: "button", children: [_jsx("strong", { children: formatEntryLabel(entry) }), _jsxs("span", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: [entry.rawValue.length, " chars"] })] }, entry.fullKey)); | ||
| })] }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_EDITOR_STYLE, children: [_jsxs("label", { children: [_jsx("div", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: "Key suffix" }), _jsx("input", { "aria-label": "Key suffix", onChange: (event) => setDraftRelativeKey(event.target.value), style: unstyled ? undefined : DEFAULT_INPUT_STYLE, type: "text", value: draftRelativeKey })] }), _jsxs("label", { children: [_jsx("div", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: "Raw value" }), _jsx("textarea", { "aria-label": "Raw value", onChange: (event) => setEditorValue(event.target.value), spellCheck: false, style: unstyled ? undefined : DEFAULT_TEXTAREA_STYLE, value: editorValue })] }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_TOOLBAR_STYLE, children: [_jsx("button", { onClick: handleSave, style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Save Raw Value" }), _jsx("button", { onClick: handleRemove, style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Remove Key" })] }), _jsx("div", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: status ?? 'Edits write raw strings directly to storage.' })] })] }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_TRANSFER_STYLE, children: [_jsxs("label", { children: [_jsx("div", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: "Namespace JSON" }), _jsx("textarea", { "aria-label": "Namespace JSON", onChange: (event) => setTransferValue(event.target.value), spellCheck: false, style: unstyled ? undefined : DEFAULT_TRANSFER_TEXTAREA_STYLE, value: transferValue })] }), _jsxs("div", { style: unstyled ? undefined : DEFAULT_TOOLBAR_STYLE, children: [_jsx("button", { onClick: syncTransferValue, style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Refresh Export JSON" }), _jsx("button", { onClick: () => void handleCopyNamespaceJson(), style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Copy Namespace JSON" }), _jsx("button", { onClick: () => handleImportNamespace('merge'), style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Import Merge" }), _jsx("button", { onClick: () => handleImportNamespace('replace'), style: unstyled ? undefined : DEFAULT_BUTTON_STYLE, type: "button", children: "Import Replace" })] }), _jsx("div", { style: unstyled ? undefined : DEFAULT_META_STYLE, children: "Import validates the selected project key and version before writing raw string values." })] })] })); | ||
| } |
| export type StorageKeyPart = string | number; | ||
| export interface StorageLike { | ||
| length?: number; | ||
| getItem(key: string): string | null; | ||
| key?(index: number): string | null; | ||
| setItem(key: string, value: string): void; | ||
| removeItem(key: string): void; | ||
| } | ||
| export interface ProjectStorageOptions { | ||
| storage?: StorageLike | null; | ||
| version?: StorageKeyPart; | ||
| } | ||
| export interface ProjectStorage { | ||
| readonly projectKey: string; | ||
| readonly version?: StorageKeyPart; | ||
| key: (...parts: StorageKeyPart[]) => string; | ||
| list: () => ProjectStorageEntry[]; | ||
| readString: (...parts: StorageKeyPart[]) => string | null; | ||
| readJson: <T>(...parts: StorageKeyPart[]) => T | null; | ||
| writeString: (value: string, ...parts: StorageKeyPart[]) => void; | ||
| writeJson: (value: unknown, ...parts: StorageKeyPart[]) => void; | ||
| remove: (...parts: StorageKeyPart[]) => void; | ||
| clear: () => void; | ||
| } | ||
| export interface ProjectStorageEntry { | ||
| fullKey: string; | ||
| relativeKey: string; | ||
| rawValue: string; | ||
| } | ||
| export declare const createProjectStorage: (projectKey: string, options?: ProjectStorageOptions) => ProjectStorage; | ||
| //# sourceMappingURL=storage.d.ts.map |
| {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../src/storage.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACnC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,CAAC,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC,GAAG,EAAE,CAAC,GAAG,KAAK,EAAE,cAAc,EAAE,KAAK,MAAM,CAAC;IAC5C,IAAI,EAAE,MAAM,mBAAmB,EAAE,CAAC;IAClC,UAAU,EAAE,CAAC,GAAG,KAAK,EAAE,cAAc,EAAE,KAAK,MAAM,GAAG,IAAI,CAAC;IAC1D,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,cAAc,EAAE,KAAK,CAAC,GAAG,IAAI,CAAC;IACtD,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,KAAK,EAAE,cAAc,EAAE,KAAK,IAAI,CAAC;IACjE,SAAS,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,KAAK,EAAE,cAAc,EAAE,KAAK,IAAI,CAAC;IAChE,MAAM,EAAE,CAAC,GAAG,KAAK,EAAE,cAAc,EAAE,KAAK,IAAI,CAAC;IAC7C,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAoDD,eAAO,MAAM,oBAAoB,GAC/B,YAAY,MAAM,EAClB,UAAS,qBAA0B,KAClC,cA2IF,CAAC"} |
+157
| const STORAGE_KEY_SEPARATOR = ':'; | ||
| const normalizeStorageKeyPart = (part, label) => { | ||
| const value = `${part}`; | ||
| if (value.length === 0) { | ||
| throw new Error(`${label} must not be empty.`); | ||
| } | ||
| return value; | ||
| }; | ||
| const resolveStorage = (storage) => { | ||
| if (storage !== undefined) { | ||
| return storage; | ||
| } | ||
| if (typeof window === 'undefined') { | ||
| return null; | ||
| } | ||
| try { | ||
| return window.localStorage; | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| }; | ||
| const isEnumerableStorage = (storage) => (typeof storage.key === 'function' | ||
| && typeof storage.length === 'number' | ||
| && Number.isInteger(storage.length) | ||
| && storage.length >= 0); | ||
| const buildProjectStoragePrefix = (projectKey, version) => { | ||
| const normalizedProjectKey = normalizeStorageKeyPart(projectKey, 'projectKey'); | ||
| if (version === undefined) { | ||
| return normalizedProjectKey; | ||
| } | ||
| return `${normalizedProjectKey}${STORAGE_KEY_SEPARATOR}v${normalizeStorageKeyPart(version, 'version')}`; | ||
| }; | ||
| export const createProjectStorage = (projectKey, options = {}) => { | ||
| const prefix = buildProjectStoragePrefix(projectKey, options.version); | ||
| const nestedPrefix = `${prefix}${STORAGE_KEY_SEPARATOR}`; | ||
| const key = (...parts) => { | ||
| if (parts.length === 0) { | ||
| return prefix; | ||
| } | ||
| const suffix = parts | ||
| .map((part, index) => normalizeStorageKeyPart(part, `key part ${index + 1}`)) | ||
| .join(STORAGE_KEY_SEPARATOR); | ||
| return `${prefix}${STORAGE_KEY_SEPARATOR}${suffix}`; | ||
| }; | ||
| const list = () => { | ||
| const activeStorage = resolveStorage(options.storage); | ||
| if (!activeStorage || !isEnumerableStorage(activeStorage)) { | ||
| return []; | ||
| } | ||
| try { | ||
| const entries = []; | ||
| for (let index = 0; index < activeStorage.length; index++) { | ||
| const fullKey = activeStorage.key(index); | ||
| if (!fullKey || (fullKey !== prefix && !fullKey.startsWith(nestedPrefix))) { | ||
| continue; | ||
| } | ||
| const rawValue = activeStorage.getItem(fullKey); | ||
| if (rawValue === null) { | ||
| continue; | ||
| } | ||
| entries.push({ | ||
| fullKey, | ||
| relativeKey: fullKey === prefix ? '' : fullKey.slice(nestedPrefix.length), | ||
| rawValue, | ||
| }); | ||
| } | ||
| entries.sort((left, right) => left.fullKey.localeCompare(right.fullKey)); | ||
| return entries; | ||
| } | ||
| catch { | ||
| return []; | ||
| } | ||
| }; | ||
| const readString = (...parts) => { | ||
| const activeStorage = resolveStorage(options.storage); | ||
| if (!activeStorage) { | ||
| return null; | ||
| } | ||
| try { | ||
| return activeStorage.getItem(key(...parts)); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| }; | ||
| const readJson = (...parts) => { | ||
| const value = readString(...parts); | ||
| if (value === null) { | ||
| return null; | ||
| } | ||
| try { | ||
| return JSON.parse(value); | ||
| } | ||
| catch { | ||
| return null; | ||
| } | ||
| }; | ||
| const writeString = (value, ...parts) => { | ||
| const activeStorage = resolveStorage(options.storage); | ||
| if (!activeStorage) { | ||
| return; | ||
| } | ||
| try { | ||
| activeStorage.setItem(key(...parts), value); | ||
| } | ||
| catch { | ||
| // Ignore storage quota and privacy-mode failures. | ||
| } | ||
| }; | ||
| const writeJson = (value, ...parts) => { | ||
| try { | ||
| writeString(JSON.stringify(value), ...parts); | ||
| } | ||
| catch { | ||
| // Ignore serialization failures for non-JSON-safe values. | ||
| } | ||
| }; | ||
| const remove = (...parts) => { | ||
| const activeStorage = resolveStorage(options.storage); | ||
| if (!activeStorage) { | ||
| return; | ||
| } | ||
| try { | ||
| activeStorage.removeItem(key(...parts)); | ||
| } | ||
| catch { | ||
| // Ignore storage-access failures. | ||
| } | ||
| }; | ||
| const clear = () => { | ||
| const activeStorage = resolveStorage(options.storage); | ||
| if (!activeStorage) { | ||
| return; | ||
| } | ||
| for (const entry of list()) { | ||
| try { | ||
| activeStorage.removeItem(entry.fullKey); | ||
| } | ||
| catch { | ||
| // Ignore storage-access failures. | ||
| } | ||
| } | ||
| }; | ||
| return { | ||
| projectKey, | ||
| ...(options.version === undefined ? {} : { version: options.version }), | ||
| key, | ||
| list, | ||
| readString, | ||
| readJson, | ||
| writeString, | ||
| writeJson, | ||
| remove, | ||
| clear, | ||
| }; | ||
| }; |
+1
-0
| export { BrandBadge, brandBadgeClassNames, type BrandBadgeProps, } from './BrandBadge.js'; | ||
| export { TVPROGRAMS_DEFAULT_LABEL, TVPROGRAMS_HOSTNAME, TVPROGRAMS_URL, } from './constants.js'; | ||
| export { TvProgramsMark, type TvProgramsMarkProps, } from './TvProgramsMark.js'; | ||
| export { createProjectStorage, type ProjectStorageEntry, type ProjectStorage, type ProjectStorageOptions, type StorageKeyPart, type StorageLike, } from './storage.js'; | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,qBAAqB,CAAC"} | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC"} |
+1
-0
| export { BrandBadge, brandBadgeClassNames, } from './BrandBadge.js'; | ||
| export { TVPROGRAMS_DEFAULT_LABEL, TVPROGRAMS_HOSTNAME, TVPROGRAMS_URL, } from './constants.js'; | ||
| export { TvProgramsMark, } from './TvProgramsMark.js'; | ||
| export { createProjectStorage, } from './storage.js'; |
+11
-1
| { | ||
| "name": "@taylorvance/tv-shared-runtime", | ||
| "version": "0.3.0", | ||
| "version": "0.4.0", | ||
| "description": "Shared React runtime primitives for Taylor Vance portfolio projects.", | ||
@@ -26,2 +26,12 @@ "type": "module", | ||
| }, | ||
| "./storage": { | ||
| "types": "./dist/storage.d.ts", | ||
| "import": "./dist/storage.js", | ||
| "default": "./dist/storage.js" | ||
| }, | ||
| "./storage-dev": { | ||
| "types": "./dist/storage-dev.d.ts", | ||
| "import": "./dist/storage-dev.js", | ||
| "default": "./dist/storage-dev.js" | ||
| }, | ||
| "./tv.svg": "./assets/tv.svg", | ||
@@ -28,0 +38,0 @@ "./tv.png": "./assets/tv.png" |
+49
-0
@@ -17,2 +17,3 @@ # `@taylorvance/tv-shared-runtime` | ||
| - `brandBadgeClassNames` | ||
| - `createProjectStorage` | ||
@@ -23,2 +24,4 @@ Explicit subpaths: | ||
| - `@taylorvance/tv-shared-runtime/assets` | ||
| - `@taylorvance/tv-shared-runtime/storage` | ||
| - `@taylorvance/tv-shared-runtime/storage-dev` | ||
@@ -86,1 +89,47 @@ ## Design goals | ||
| ``` | ||
| ## Project storage | ||
| Use `createProjectStorage` when a consumer needs browser `localStorage` keys that stay unique per project on shared origins such as localhost. | ||
| ```ts | ||
| import { createProjectStorage } from '@taylorvance/tv-shared-runtime/storage'; | ||
| const storage = createProjectStorage('wordlink', { version: 1 }); | ||
| const themePreference = storage.readString('theme-preference') ?? 'system'; | ||
| storage.writeString('dark', 'theme-preference'); | ||
| storage.writeJson({ expanded: true }, 'panels', 'complexity'); | ||
| const entries = storage.list(); | ||
| ``` | ||
| When `version` is provided, keys follow the pattern `<projectKey>:v<version>:<key parts...>`, for example `wordlink:v1:theme-preference`. | ||
| The helper is SSR-safe and treats storage-access failures as soft failures by returning `null` or doing nothing. | ||
| It also provides namespace-level maintenance helpers: | ||
| - `list()` returns the current keys and raw string values for the active project/version namespace. | ||
| - `clear()` removes only the current project/version namespace. | ||
| ## Storage dev tools | ||
| For dev-only inspection, manual edits, and namespace JSON import/export, use the explicit `storage-dev` entry: | ||
| ```tsx | ||
| import { ProjectStorageInspector } from '@taylorvance/tv-shared-runtime/storage-dev'; | ||
| export function StorageDebugPanel() { | ||
| return ( | ||
| <ProjectStorageInspector | ||
| projectKey="mcts-web" | ||
| versions={[ | ||
| { label: 'Version 1', value: 1 }, | ||
| { label: 'Version 2', value: 2 }, | ||
| ]} | ||
| /> | ||
| ); | ||
| } | ||
| ``` | ||
| This inspector is meant for local tooling and debug screens, not default production UI. |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
41605
243.39%25
31.58%664
700%133
58.33%1
Infinity%