[web] integrate editor actions and preserve drafts #165

Merged
eleith merged 1 commits from feat/editor-action-bar into main 2026-10-28 21:46:46 +00:00
20 changed files with 666 additions and 512 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
---
"@luzzle/web": minor
"@luzzle/web.theme": patch
---
Put Save, Preview, Publish and More in the editor frame. Keep saved-file publishing
available without stale index gating and preselect the current piece. Preserve
newer drafts during saves and give editor commands an explicit shared appearance.
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { onMount, onDestroy, type Snippet } from 'svelte'
import { EditorView } from 'codemirror'
import { EditorState, Compartment } from '@codemirror/state'
import { configuredTheme } from './themes/configured'
@@ -28,9 +28,10 @@
file?: string
returnTo?: string
assetFields?: string[]
toolbar?: Snippet
}
let { value = $bindable(), onchange, file, returnTo, assetFields = [] }: Props = $props()
let { value = $bindable(), onchange, file, returnTo, assetFields = [], toolbar }: Props = $props()
let editorContainer: HTMLDivElement
let view: EditorView
@@ -169,19 +170,37 @@
}
</script>
<div class="editor-wrapper" bind:this={editorContainer}></div>
<div class="editor-wrapper">
{#if toolbar}
<div class="editor-toolbar">
{@render toolbar()}
</div>
{/if}
<div class="editor-body" bind:this={editorContainer}></div>
</div>
<style>
.editor-wrapper {
background-color: var(--editor-background, var(--color-surface));
width: 100%;
box-sizing: border-box;
border: 1px solid var(--editor-border, var(--color-outline));
border-radius: var(--radius-medium);
overflow: hidden;
position: relative;
transition: background-color 0.2s ease;
}
.editor-wrapper::after {
.editor-toolbar {
background-color: var(--editor-panelBackground, var(--color-surface-container));
color: var(--editor-panelForeground, var(--color-on-surface));
border-bottom: 1px solid var(--editor-border, var(--color-outline));
}
.editor-body {
position: relative;
}
.editor-body::after {
content: '';
position: absolute;
inset: 0;
@@ -0,0 +1,26 @@
import { createRawSnippet } from 'svelte'
import { render } from 'svelte/server'
import { expect, test, vi } from 'vitest'
import MarkdownEditor from './MarkdownEditor.svelte'
vi.mock('./lsp', () => ({ createLSPExtension: vi.fn(), destroyLSPClient: vi.fn() }))
vi.mock('./keybindings', () => ({ editorKeybindings: vi.fn(), finishVimInput: vi.fn() }))
test('editor actions render inside the same frame, before the CodeMirror mount', () => {
const toolbar = createRawSnippet(() => ({ render: () => '<div>Editor actions fixture</div>' }))
const { body } = render(MarkdownEditor, { props: { value: 'Saved source', toolbar } })
expect(body.match(/class="editor-wrapper\b/g)).toHaveLength(1)
expect(body).toMatch(
/class="editor-wrapper\b[^>]*>[\s\S]*class="editor-toolbar\b[^>]*>[\s\S]*Editor actions fixture[\s\S]*class="editor-body\b/
)
})
test('the editor can still render without a toolbar', () => {
const { body } = render(MarkdownEditor, { props: { value: 'Saved source' } })
expect(body).toMatch(/class="editor-body\b/)
expect(body).not.toMatch(/class="editor-toolbar\b/)
})
// SSR verifies composition only; actual editor input, focus and visual framing need a browser.
@@ -1,26 +1,21 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui'
import DotsThreeVertical from 'virtual:icons/ph/dots-three-vertical'
import { goto } from '$app/navigation'
import { editorHref } from '$lib/pieces/navigation'
import CaretDown from 'virtual:icons/ph/caret-down'
type Props = {
file: string
currentMode: 'source' | 'preview'
canGenerate: boolean
canPublish: boolean
disabled?: boolean
onDelete: () => void
onAttach?: () => void
onGenerate?: () => void
}
let { file, currentMode, canGenerate, canPublish, onDelete, onAttach, onGenerate }: Props =
$props()
let { canGenerate, disabled = false, onDelete, onAttach, onGenerate }: Props = $props()
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="button" data-variant="outline" aria-label="Piece actions">
<DotsThreeVertical />
<DropdownMenu.Trigger class="button" data-variant="editor" {disabled}>
More <CaretDown aria-hidden="true" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content sideOffset={8} forceMount>
@@ -28,38 +23,25 @@
{#if open}
<div {...wrapperProps}>
<div {...props} class="dropdown-content">
{#if canPublish}
<DropdownMenu.Item onSelect={() => goto('/admin/publish?check=1')}>
{#if canGenerate && onGenerate}
<DropdownMenu.Item onSelect={onGenerate}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>publish</div>
<div class="dropdown-item" {...props}>Generate</div>
{/snippet}
</DropdownMenu.Item>
{/if}
{#if canGenerate}
<DropdownMenu.Item
onSelect={() => {
if (currentMode === 'source' && onGenerate) onGenerate()
else goto(editorHref(file) + '?generate=1')
}}
>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>generate</div>
{/snippet}
</DropdownMenu.Item>
{/if}
{#if currentMode === 'source' && onAttach}
{#if onAttach}
<DropdownMenu.Item onSelect={onAttach}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>attach</div>
<div class="dropdown-item" {...props}>Attach</div>
{/snippet}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onSelect={onDelete}>
{#snippet child({ props })}
<div class="dropdown-item destructive" {...props}>delete</div>
<div class="dropdown-item destructive" {...props}>Delete</div>
{/snippet}
</DropdownMenu.Item>
</div>
@@ -72,10 +54,17 @@
<style>
.dropdown-content {
background-color: var(--color-surface-container-highest);
border: 1px solid var(--color-outline);
background-color: var(--editor-panelBackground, var(--color-surface-container));
color: var(--editor-panelForeground, var(--color-on-surface));
border: 1px solid var(--editor-border, var(--color-outline));
border-radius: var(--radius-small);
min-width: 160px;
box-sizing: border-box;
width: 10rem;
max-width: calc(100vw - 1rem);
padding: var(--space-1);
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xxs);
line-height: 1.4;
box-shadow: var(--shadow-raised);
z-index: 1000;
}
@@ -83,24 +72,33 @@
.dropdown-item {
display: flex;
align-items: center;
padding: var(--space-3);
min-height: calc(var(--space-4) + var(--space-2));
padding: var(--space-1) var(--space-2);
cursor: pointer;
color: var(--color-on-surface);
color: inherit;
gap: var(--space-2);
border-radius: var(--radius-small);
box-sizing: border-box;
width: 100%;
text-align: left;
text-decoration: none;
background: none;
border: none;
text-transform: uppercase;
font: inherit;
text-transform: none;
}
.dropdown-item:hover {
background-color: var(--color-surface-container-low);
.dropdown-item:hover,
.dropdown-item[data-highlighted] {
background-color: var(--editor-selection, var(--color-surface-container-highest));
outline: none;
}
.dropdown-item:focus-visible {
outline: 2px solid var(--editor-caret, var(--color-primary));
outline-offset: -2px;
}
.dropdown-item[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
@@ -1,38 +1,24 @@
import { readFileSync } from 'node:fs'
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import PieceActions from './PieceActions.svelte'
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
const source = readFileSync(new URL('./PieceActions.svelte', import.meta.url), 'utf8')
// SSR covers only the trigger; portal actions below are source contracts,
// not evidence of client selection, keyboard handling or focus behavior.
test('SSR names the actions trigger', () => {
// SSR covers the trigger, not portal actions, client selection or focus.
test.each([false, true])('More is labeled and disabled=%s while saving', (disabled) => {
const { body } = render(PieceActions, {
props: {
file: 'books/a #?.md',
currentMode: 'source',
canGenerate: true,
canPublish: true,
disabled,
onDelete: vi.fn(),
onAttach: vi.fn(),
onGenerate: vi.fn()
}
})
expect(body).toMatch(/<button[^>]*aria-label="Piece actions"[^>]*aria-haspopup="menu"/)
})
test('preserves action gates and callbacks, with encoded generate fallback and no legacy views', () => {
expect(source).toContain('{#if canPublish}')
expect(source).toContain("onSelect={() => goto('/admin/publish?check=1')}")
expect(source).toContain('{#if canGenerate}')
expect(source).toContain("if (currentMode === 'source' && onGenerate) onGenerate()")
expect(source).toContain("else goto(editorHref(file) + '?generate=1')")
expect(source).toContain("{#if currentMode === 'source' && onAttach}")
expect(source).toContain('onSelect={onAttach}')
expect(source).toContain('onSelect={onDelete}')
expect(source).not.toMatch(
/isDirty|hasPublicVersion|isPublicStale|previewUrl|liveUrl|href=|>preview<|>live</
)
const trigger = body.match(/<button\b[^>]*aria-haspopup="menu"[^>]*>/)?.[0]
expect(trigger).toBeDefined()
expect(trigger).toContain('data-variant="editor"')
expect(trigger).toContain('type="button"')
expect(trigger).toContain('aria-expanded="false"')
expect(body).toContain('More')
if (disabled) expect(trigger).toMatch(/\sdisabled(?:\s|=|>)/)
else expect(trigger).not.toMatch(/\sdisabled(?:\s|=|>)/)
})
@@ -0,0 +1,81 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { goto } from '$app/navigation'
import Button from '$lib/components/ui/Button.svelte'
import { previewHref, publishHref, publishedHref } from '$lib/pieces/navigation'
type Props = {
file: string
saveFormId: string
isDirty: boolean
isSaving: boolean
hasPublishedVersion: boolean
children: Snippet
}
let { file, saveFormId, isDirty, isSaving, hasPublishedVersion, children }: Props = $props()
const needsSave = $derived(isDirty || isSaving)
</script>
<div class="toolbar" role="group" aria-label="Editor actions">
<div class="actions">
<Button variant="editor" type="submit" form={saveFormId} disabled={!isDirty || isSaving}>
{isSaving ? 'Saving…' : 'Save'}
</Button>
<Button variant="editor" disabled={needsSave} onclick={() => goto(previewHref(file))}>
Preview
</Button>
<Button variant="editor" disabled={needsSave} onclick={() => goto(publishHref(file))}>
Publish
</Button>
{@render children()}
</div>
<div class="status" aria-live="polite">
<span>
{#if isSaving}
Saving…
{:else if isDirty}
Unsaved changes
{:else}
Saved
{/if}
</span>
{#if hasPublishedVersion}
<a href={publishedHref(file)}>Published</a>
{:else}
<span>Not published</span>
{/if}
</div>
</div>
<style>
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
padding: var(--space-2) var(--space-3);
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xxs);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
}
.status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
margin-left: auto;
}
.status a {
color: inherit;
text-decoration: underline;
}
</style>
@@ -0,0 +1,99 @@
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { expect, test, vi } from 'vitest'
import PieceToolbar from './PieceToolbar.svelte'
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
const file = 'books/源 #?%.books.md'
const children = createRawSnippet(() => ({ render: () => '<span>More fixture</span>' }))
const disabledAttribute = /(?:^|\s)disabled(?:\s|=|$)/
function html(props: Partial<ComponentProps<typeof PieceToolbar>> = {}) {
return render(PieceToolbar, {
props: {
file,
saveFormId: 'save-piece',
isDirty: false,
isSaving: false,
hasPublishedVersion: true,
children,
...props
}
}).body
}
function buttonAttributes(output: string, label: string): string {
const buttons = [...output.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)]
const button = buttons.find((match) => match[2].replace(/<!--[\s\S]*?-->/g, '').trim() === label)
if (!button) throw new Error(`Missing button: ${label}`)
return button[1]
}
test.each([false, true])(
'saved files keep Preview and Publish visible and available with published version=%s',
(hasPublishedVersion) => {
const output = html({ hasPublishedVersion })
expect(buttonAttributes(output, 'Preview')).not.toMatch(disabledAttribute)
expect(buttonAttributes(output, 'Publish')).not.toMatch(disabledAttribute)
expect(buttonAttributes(output, 'Save')).toMatch(disabledAttribute)
expect(output).toContain('More fixture')
expect(output).toContain('Saved')
expect(output).toContain('aria-live="polite"')
}
)
test('unsaved edits keep primary actions visible with a short status', () => {
const output = html({ isDirty: true })
expect(buttonAttributes(output, 'Save')).not.toMatch(disabledAttribute)
expect(buttonAttributes(output, 'Preview')).toMatch(disabledAttribute)
expect(buttonAttributes(output, 'Publish')).toMatch(disabledAttribute)
expect(output).toContain('Unsaved changes')
})
test.each([false, true])('saving disables primary actions even when dirty=%s', (isDirty) => {
const output = html({ isDirty, isSaving: true })
for (const label of ['Saving…', 'Preview', 'Publish']) {
expect(buttonAttributes(output, label)).toMatch(disabledAttribute)
}
expect(output).not.toContain('Unsaved changes')
})
test.each([
{ isDirty: false, isSaving: false },
{ isDirty: true, isSaving: false },
{ isDirty: false, isSaving: true },
{ isDirty: true, isSaving: true }
])('editor commands retain their explicit appearance with %o', (state) => {
const output = html(state)
for (const label of [state.isSaving ? 'Saving…' : 'Save', 'Preview', 'Publish']) {
expect(buttonAttributes(output, label)).toContain('data-variant="editor"')
}
})
test('Save submits the source form instead of navigating or implicitly publishing', () => {
const attributes = buttonAttributes(html({ isDirty: true }), 'Save')
expect(attributes).toContain('type="submit"')
expect(attributes).toContain('form="save-piece"')
for (const label of ['Preview', 'Publish']) {
expect(buttonAttributes(html(), label)).toContain('type="button"')
}
})
test('publication status is an encoded link only when a public version exists', () => {
const href = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published'
expect(html()).toContain(`href="${href}"`)
const unpublished = html({ hasPublishedVersion: false })
expect(unpublished).toContain('Not published')
expect(unpublished).not.toContain(href)
})
test('editor actions do not duplicate the surrounding archive navigation', () => {
const output = html()
expect(output).toContain('aria-label="Editor actions"')
expect(output).not.toContain('href="/admin"')
expect(output).not.toContain('Back to archive')
expect(output).not.toContain('Publish…')
})
// SSR proves rendering and disabled/form semantics, not click handling or navigation guards.
@@ -1,122 +0,0 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui'
import { goto } from '$app/navigation'
import { publishedHref, previewHref } from '$lib/pieces/navigation'
import { getViewAvailability } from './view-state'
type Props = {
file: string
hasPublishedVersion: boolean
isSaving: boolean
}
let { file, hasPublishedVersion, isSaving }: Props = $props()
const id = $props.id()
const availability = $derived(getViewAvailability(hasPublishedVersion, isSaving))
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="button" data-variant="outline">View</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content sideOffset={8} forceMount>
{#snippet child({ open, props, wrapperProps })}
{#if open}
<div {...wrapperProps}>
<div {...props} class="dropdown-content">
<DropdownMenu.Item
textValue="Published"
disabled={!availability.published}
onSelect={() => goto(publishedHref(file))}
>
{#snippet child({ props })}
<div
{...props}
class="dropdown-item"
aria-labelledby={`${id}-published-label`}
aria-describedby={!availability.published
? `${id}-published-description`
: undefined}
>
<span id={`${id}-published-label`}>Published</span>
{#if !availability.published}
<span id={`${id}-published-description`} class="description">
{#if isSaving}Saving…{:else}Not published yet{/if}
</span>
{/if}
</div>
{/snippet}
</DropdownMenu.Item>
<DropdownMenu.Item
textValue="Preview"
disabled={!availability.preview}
onSelect={() => goto(previewHref(file))}
>
{#snippet child({ props })}
<div
{...props}
class="dropdown-item"
aria-labelledby={`${id}-preview-label`}
aria-describedby={!availability.preview
? `${id}-preview-description`
: undefined}
>
<span id={`${id}-preview-label`}>Preview</span>
{#if !availability.preview}
<span id={`${id}-preview-description`} class="description">Saving…</span>
{/if}
</div>
{/snippet}
</DropdownMenu.Item>
</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style>
.dropdown-content {
background-color: var(--color-surface-container-highest);
border: 1px solid var(--color-outline);
border-radius: var(--radius-small);
min-width: 160px;
box-shadow: var(--shadow-raised);
z-index: 1000;
}
.dropdown-item {
display: flex;
align-items: center;
padding: var(--space-3);
cursor: pointer;
color: var(--color-on-surface);
gap: var(--space-2);
border-radius: var(--radius-small);
width: 100%;
text-align: left;
text-decoration: none;
background: none;
border: none;
text-transform: uppercase;
}
.dropdown-item:hover,
.dropdown-item[data-highlighted] {
background-color: var(--color-surface-container-low);
outline: none;
}
.dropdown-item:focus-visible {
outline: auto;
}
.dropdown-item[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.description {
font-size: var(--font-size-small);
}
</style>
@@ -1,108 +0,0 @@
import { readFileSync } from 'node:fs'
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import PieceView from './PieceView.svelte'
vi.mock('$app/navigation', () => ({ goto: vi.fn() }))
const source = readFileSync(new URL('./PieceView.svelte', import.meta.url), 'utf8')
// Bits portals do not render menu contents in SSR. These tests establish the
// trigger and source contracts, not popup visibility, clicks, warnings or focus.
test.each([false, true])('SSR keeps View available while saving=%s', (isSaving) => {
const { body } = render(PieceView, {
props: { file: 'books/a #?.md', hasPublishedVersion: false, isSaving }
})
expect(body.replace(/<!--.*?-->/g, '')).toMatch(
/<button[^>]*aria-haspopup="menu"[^>]*>View<\/button>/
)
expect(body).not.toMatch(/<button[^>]*disabled/)
})
test('both literal items use native disabled state and current-prop navigation callbacks', () => {
expect(source).toContain('$derived(getViewAvailability(hasPublishedVersion, isSaving))')
expect(source).toMatch(/class="dropdown-content">\s*<DropdownMenu.Item/)
expect(source).toMatch(/<\/DropdownMenu.Item>\s*<DropdownMenu.Item/)
expect(source.match(/<DropdownMenu.Item\s/g)).toHaveLength(2)
for (const [view, helper] of [
['published', 'publishedHref'],
['preview', 'previewHref']
]) {
expect(source).toContain(`disabled={!availability.${view}}`)
expect(source).toContain(`onSelect={() => goto(${helper}(file))}`)
}
expect(source).not.toMatch(/<a\b|\bhref=|preload|autoSave|bypassWarning|setTimeout|async\b/)
expect(source).not.toMatch(/isDirty|markdownStatus|content_hash|previewUrl|publishedUrl/)
})
test('source editor wires availability and encoded actions without replacing its unsaved guard', () => {
const editor = readFileSync(
new URL('../../../routes/(auth)/admin/piece/[...path]/source/+page.svelte', import.meta.url),
'utf8'
)
expect(editor).toContain(
'<PieceView file={data.file} hasPublishedVersion={data.hasPublishedVersion} {isSaving} />'
)
expect(editor).toContain(
'$derived(shouldOfferPublish(data.hasPublishedVersion, data.savedMarkdown))'
)
expect(editor).toContain('$derived(editorHref(data.file))')
for (const action of ['save', 'delete'])
expect(editor).toContain(`action="{sourceUrl}?/${action}"`)
expect(editor).toContain('fetch(`${sourceUrl}?/attach`, {')
expect(editor).toContain(
"if (isDirty && !bypassWarning && navigation.type !== 'form' && navigation.to)"
)
expect(editor).toContain('navigation.cancel()')
expect(editor).toContain('onclick={confirmDiscard}')
})
test('View menu source matches the original actions menu presentation', () => {
const original = readFileSync(new URL('./PieceActions.svelte', import.meta.url), 'utf8')
for (const pattern of [
/\.dropdown-content\s*\{([^}]+)\}/,
/\.dropdown-item\s*\{([^}]+)\}/,
/\.dropdown-item\[data-disabled\]\s*\{([^}]+)\}/
]) {
const expected = original.match(pattern)?.[1]
const actual = source.match(pattern)?.[1]
expect(expected).toBeDefined()
expect(actual).toBeDefined()
expect(actual?.trim()).toBe(expected?.trim())
}
const hover = original.match(/\.dropdown-item:hover\s*\{([^}]+)\}/)?.[1]
const highlight = source.match(/\.dropdown-item\[data-highlighted\]\s*\{([^}]+)\}/)?.[1]
expect(hover).toBeDefined()
expect(highlight).toBeDefined()
expect(highlight?.trim()).toBe(hover?.trim())
expect(source).toMatch(/\.dropdown-item:focus-visible\s*\{\s*outline: auto;/)
})
test('menuitem source keeps labels and short reasons only while disabled', () => {
const markup = source.replace(/\s+/g, ' ')
expect(source).toContain('const id = $props.id()')
for (const [view, label] of [
['published', 'Published'],
['preview', 'Preview']
]) {
expect(source).toContain(`<span id={\`\${id}-${view}-label\`}>${label}</span>`)
expect(source).toContain(`aria-labelledby={\`\${id}-${view}-label\`}`)
expect(markup).toContain(
`aria-describedby={!availability.${view} ? \`\${id}-${view}-description\` : undefined}`
)
expect(markup).toContain(
`{#if !availability.${view}} <span id={\`\${id}-${view}-description\`} class="description">`
)
}
expect(source.match(/\{#snippet child\(\{ props \}\)\}\s*<div\s*\{\.\.\.props\}/g)).toHaveLength(
2
)
expect(source.match(/Saving…/g)).toHaveLength(2)
expect(source).toContain('Not published yet')
for (const verbose of [
'Wait for saving to finish.',
'No published version yet.',
'View the published version.',
'Uses saved Markdown.'
])
expect(source).not.toContain(verbose)
})
@@ -0,0 +1,99 @@
import { expect, test } from 'vitest'
import { reconcileSave, reconcileSourceRefresh } from './save-state'
const file = 'a.note.md'
const submitted = { file, content: 'A' }
const saved = { type: 'success' as const, content: 'A\n' }
test('an unchanged submitted buffer adopts normalized source and is saved', () => {
const content = reconcileSave(submitted, submitted, saved)
expect(content).toBe('A\n')
expect(content === saved.content).toBe(true)
})
test('typing B during Save A preserves B against the refreshed A baseline', () => {
const content = reconcileSave({ file, content: 'B' }, submitted, saved)
expect(content).toBe('B')
expect(content !== saved.content).toBe(true)
// The retained draft can be submitted next, then adopt its own normalization.
const nextSubmission = { file, content }
const nextSaved = { type: 'success' as const, content: 'B\n' }
const nextContent = reconcileSave(nextSubmission, nextSubmission, nextSaved)
expect(nextContent).toBe('B\n')
expect(nextContent === nextSaved.content).toBe(true)
})
test('a failed Save echoing A does not replace newer B or mark it saved', () => {
const baseline = 'original source'
const content = reconcileSave({ file, content: 'B' }, submitted, {
type: 'failure',
content: 'A'
})
expect(content).toBe('B')
expect(content !== baseline).toBe(true)
})
test('failure preserves an unchanged submitted draft too', () => {
expect(reconcileSave(submitted, submitted, { type: 'failure', content: 'error A' })).toBe('A')
expect(reconcileSave(submitted, submitted, { type: 'failure' })).toBe('A')
})
test('clearing the buffer while Save is pending remains an unsaved edit', () => {
const content = reconcileSave({ file, content: '' }, submitted, saved)
expect(content).toBe('')
expect(content !== saved.content).toBe(true)
})
test('an old file response cannot replace the newly loaded file, even with identical text', () => {
const current = { file: 'other.note.md', content: 'A' }
expect(reconcileSave(current, submitted, saved)).toBe(current.content)
expect(reconcileSave(current, submitted, { type: 'failure', content: 'error A' })).toBe(
current.content
)
})
test('pristine same-file refreshes adopt each new saved baseline', () => {
const refreshed = { file, content: 'B' }
const content = reconcileSourceRefresh('A', submitted, refreshed, false)
expect(content).toBe('B')
expect(content).toBe(refreshed.content)
const nextRefresh = { file, content: 'C' }
expect(reconcileSourceRefresh(content, refreshed, nextRefresh, false)).toBe('C')
})
test('same-file refreshes preserve dirty drafts against the latest loaded baseline', () => {
const refreshed = { file, content: 'B' }
const content = reconcileSourceRefresh('draft', submitted, refreshed, false)
expect(content).toBe('draft')
expect(content).not.toBe(refreshed.content)
// Returning to historical A is still dirty against the now-loaded B baseline.
const nextRefresh = { file, content: 'C' }
expect(reconcileSourceRefresh('A', refreshed, nextRefresh, false)).toBe('A')
})
test('a pending Save defers even a pristine buffer refresh to save reconciliation', () => {
const refreshed = { file, content: saved.content }
const content = reconcileSourceRefresh('A', submitted, refreshed, true)
expect(content).toBe('A')
expect(reconcileSave({ file, content }, submitted, saved)).toBe(refreshed.content)
})
test('typing B survives refresh during Save A and either save outcome', () => {
const refreshed = { file, content: saved.content }
const content = reconcileSourceRefresh('B', submitted, refreshed, true)
expect(content).toBe('B')
expect(reconcileSave({ file, content }, submitted, saved)).toBe('B')
expect(reconcileSave({ file, content }, submitted, { type: 'failure', content: 'A' })).toBe('B')
expect(content).not.toBe(refreshed.content)
})
test('navigation always loads the new file despite a dirty buffer or pending Save', () => {
const refreshed = { file: 'other.note.md', content: 'other source' }
expect(reconcileSourceRefresh('draft', submitted, refreshed, false)).toBe('other source')
const content = reconcileSourceRefresh('draft', submitted, refreshed, true)
expect(content).toBe('other source')
expect(reconcileSave({ file: refreshed.file, content }, submitted, saved)).toBe(content)
})
@@ -0,0 +1,30 @@
export type SaveSnapshot = { file: string; content: string }
export function reconcileSourceRefresh(
content: string,
previous: SaveSnapshot,
refreshed: SaveSnapshot,
savePending: boolean
): string {
if (refreshed.file !== previous.file || (!savePending && content === previous.content)) {
return refreshed.content
}
return content
}
type SaveResult = { type: 'success'; content: string } | { type: 'failure'; content?: string }
export function reconcileSave(
current: SaveSnapshot,
submitted: SaveSnapshot,
result: SaveResult
): string {
if (
result.type === 'failure' ||
current.file !== submitted.file ||
current.content !== submitted.content
) {
return current.content
}
return result.content
}
@@ -1,23 +0,0 @@
import { expect, test } from 'vitest'
import { MarkdownPublicationStatus } from '$lib/pieces/types'
import { getViewAvailability, shouldOfferPublish } from './view-state'
test.each([
[false, false, { published: false, preview: true }],
[true, false, { published: true, preview: true }],
[false, true, { published: false, preview: false }],
[true, true, { published: false, preview: false }]
])('availability: published row=%s, saving=%s', (hasPublishedVersion, isSaving, expected) => {
expect(getViewAvailability(hasPublishedVersion, isSaving)).toEqual(expected)
})
test.each([
[false, MarkdownPublicationStatus.MATCHING, true],
[false, MarkdownPublicationStatus.CHANGED, true],
[false, MarkdownPublicationStatus.UNKNOWN, true],
[true, MarkdownPublicationStatus.MATCHING, false],
[true, MarkdownPublicationStatus.CHANGED, true],
[true, MarkdownPublicationStatus.UNKNOWN, true]
])('publish offer: published row=%s, status=%s', (hasPublishedVersion, status, expected) => {
expect(shouldOfferPublish(hasPublishedVersion, status)).toBe(expected)
})
@@ -1,15 +0,0 @@
import { MarkdownPublicationStatus } from '$lib/pieces/types'
export function getViewAvailability(hasPublishedVersion: boolean, isSaving: boolean) {
return {
published: hasPublishedVersion && !isSaving,
preview: !isSaving
}
}
export function shouldOfferPublish(
hasPublishedVersion: boolean,
status: MarkdownPublicationStatus
): boolean {
return !hasPublishedVersion || status !== MarkdownPublicationStatus.MATCHING
}
+1 -1
View File
@@ -3,7 +3,7 @@
import type { Snippet } from 'svelte'
type Props = {
variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'outline' | 'link'
variant?: 'primary' | 'secondary' | 'tertiary' | 'error' | 'outline' | 'link' | 'editor'
type?: 'button' | 'submit' | 'reset'
form?: string
onclick?: (e: MouseEvent) => void
+24 -1
View File
@@ -1,6 +1,6 @@
import { existsSync } from 'node:fs'
import { expect, test, vi } from 'vitest'
import { editorHref, getPieceLinks, previewHref, publishedHref } from './navigation'
import { editorHref, getPieceLinks, previewHref, publishedHref, publishHref } from './navigation'
const { resolve } = vi.hoisted(() => ({
resolve: vi.fn(() => {
@@ -52,6 +52,29 @@ test.each([
}
)
test.each([
'books/源 #?%&"+=.books.md',
'folder with spaces/a book.books.md',
'nested/literal%2F.books.md',
'books/📚.books.md',
'nested/name?check=0&file=other#fragment.books.md'
])('publish navigation preselects the complete raw filename %s', (file) => {
const url = new URL(publishHref(file), 'https://app.test')
expect(url.pathname).toBe('/admin/publish')
expect(url.hash).toBe('')
expect([...url.searchParams.entries()]).toEqual([
['check', '1'],
['file', file]
])
expect(resolve).not.toHaveBeenCalled()
})
test('publish navigation encodes a query value rather than a pathname', () => {
expect(publishHref('folder/a b+c.books.md')).toBe(
'/admin/publish?check=1&file=folder%2Fa+b%2Bc.books.md'
)
})
test.each(
['', '/garden'].flatMap((mount) =>
['', '/page', '/icon', '/share'].map((suffix) => ({ mount, suffix }))
+5
View File
@@ -10,6 +10,11 @@ export function publishedHref(file: string): string {
return `/admin/piece/${piecePath(file)}/published`
}
export function publishHref(file: string): string {
const query = new URLSearchParams({ check: '1', file })
return `/admin/publish?${query}`
}
export function previewHref(file: string, job?: string): string {
const href = `/admin/piece/${piecePath(file)}/preview`
return job === undefined ? href : `${href}/${encodeURIComponent(job)}`
@@ -19,7 +19,6 @@ import {
} from '@luzzle/core'
import { Readable } from 'stream'
import { normalizeLineEndings, normalizeMarkdown } from '$lib/server/markdown'
import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server'
async function resolveAssetUrls(
piece: Piece<PieceFrontmatter>,
@@ -103,8 +102,7 @@ export const load: PageServerLoad = async ({ params }) => {
schema: piece.schema,
canGenerate: config.ai !== undefined,
assetFields: assetPaths,
hasPublishedVersion: Boolean(webPiece),
savedMarkdown: await getMarkdownPublicationStatus(file, webPiece?.content_hash)
hasPublishedVersion: Boolean(webPiece)
}
}
@@ -1,10 +1,15 @@
<script lang="ts">
import { deserialize, enhance } from '$app/forms'
import type { SubmitFunction } from '@sveltejs/kit'
import MarkdownEditor from '$lib/components/editor/MarkdownEditor.svelte'
import {
reconcileSave,
reconcileSourceRefresh,
type SaveSnapshot
} from '$lib/components/editor/save-state'
import Button from '$lib/components/ui/Button.svelte'
import PieceActions from '$lib/components/editor/PieceActions.svelte'
import PieceView from '$lib/components/editor/PieceView.svelte'
import { shouldOfferPublish } from '$lib/components/editor/view-state'
import PieceToolbar from '$lib/components/editor/PieceToolbar.svelte'
import { editorHref } from '$lib/pieces/navigation'
import GenerationDialog from '$lib/components/editor/GenerationDialog.svelte'
import type { PageProps } from './$types'
@@ -13,16 +18,17 @@
import { page } from '$app/state'
import { beforeNavigate, goto, replaceState } from '$app/navigation'
import { onMount, onDestroy } from 'svelte'
import { onMount, onDestroy, tick, untrack } from 'svelte'
let { data, form }: PageProps = $props()
const saveFormId = $props.id()
let targetUrl = $state<string | null>(null)
let showWarningDialog = $state(false)
let bypassWarning = $state(false)
let isSaving = $state(false)
let saveSuccess = $state(false)
let editorError = $state('')
let editorErrorReason = $state<'sign-in' | 'save' | 'refresh' | null>(null)
let editorErrorDetail = $state('')
let disposed = false
onDestroy(() => {
disposed = true
@@ -57,31 +63,87 @@
let dialog: HTMLDialogElement
let editorRef = $state<MarkdownEditor>()
let rawContent = $state<string>(
(form && 'rawContent' in form ? (form.rawContent as string) : undefined) ||
data.rawContent ||
''
)
function initialRawContent(): string {
if (form && 'rawContent' in form) {
if (typeof form.rawContent === 'string') return form.rawContent
}
if (data.rawContent != null) return data.rawContent
return ''
}
let rawContent = $state(initialRawContent())
let loadedSource = untrack(() => ({ file: data.file, content: data.rawContent ?? '' }))
let activeSave: SaveSnapshot | undefined
const enhanceSave: SubmitFunction = ({ formData, cancel }) => {
if (isSaving) {
cancel()
return
}
const submitted = { file: data.file, content: String(formData.get('content') ?? '') }
activeSave = submitted
isSaving = true
editorErrorReason = null
editorErrorDetail = ''
const isCurrentSave = () =>
!disposed && activeSave === submitted && data.file === submitted.file
return async ({ result, update }) => {
if (!isCurrentSave()) return
try {
if (result.type === 'redirect') {
editorErrorReason = 'sign-in'
return
}
if (result.type === 'error') {
editorErrorReason = 'save'
return
}
await update({ reset: false })
await tick()
if (!isCurrentSave()) return
const saveResult =
result.type === 'success'
? { type: result.type, content: data.rawContent ?? '' }
: { type: result.type }
rawContent = reconcileSave({ file: data.file, content: rawContent }, submitted, saveResult)
} catch {
if (isCurrentSave()) {
editorErrorReason = 'refresh'
}
} finally {
if (isCurrentSave()) {
activeSave = undefined
isSaving = false
bypassWarning = false
}
}
}
}
const hasChanges = $derived(rawContent !== (data.rawContent ?? ''))
const isDirty = $derived(!!form?.error || (hasChanges && !isSaving))
const isDirty = $derived(!!form?.error || hasChanges)
const sourceUrl = $derived(editorHref(data.file))
const canPublish = $derived(shouldOfferPublish(data.hasPublishedVersion, data.savedMarkdown))
$effect(() => {
if (form && 'rawContent' in form && form.rawContent) {
rawContent = form.rawContent as string
} else {
rawContent = data.rawContent || ''
}
const refreshed = { file: data.file, content: data.rawContent ?? '' }
untrack(() => {
const changedFile = refreshed.file !== loadedSource.file
rawContent = reconcileSourceRefresh(rawContent, loadedSource, refreshed, !!activeSave)
loadedSource = refreshed
if (!changedFile) return
activeSave = undefined
isSaving = false
bypassWarning = false
})
})
let generateDialogOpen = $state(false)
$effect(() => {
if (data.file) {
generateDialogOpen = false
editorError = ''
editorErrorReason = null
editorErrorDetail = ''
}
})
$effect(() => {
@@ -100,7 +162,10 @@
let customName = $state('')
let isNameManuallyEdited = $state(false)
let isUploading = $state(false)
let uploadError = $state<string | null>(null)
let uploadErrorReason = $state<
'file-required' | 'url-required' | 'filename-required' | 'failed' | null
>(null)
let uploadErrorDetail = $state<string | null>(null)
let fileInputRef = $state<HTMLInputElement>()
function getBasenameWithoutExtension(filename: string): string {
@@ -131,32 +196,55 @@
}
})
function openAttachDialog() {
const selectedText = editorRef?.getSelectedText()?.trim() || ''
let isUrl = false
if (selectedText.startsWith('http://') || selectedText.startsWith('https://')) {
try {
new URL(selectedText)
isUrl = true
} catch (_) {
// Ignore invalid URL formatting
}
}
if (isUrl) {
activeTab = 'url'
urlValue = selectedText
} else {
activeTab = 'file'
}
attachDialogOpen = true
}
function resetAttachState() {
selectedFile = null
urlValue = ''
customName = ''
isNameManuallyEdited = false
uploadError = null
uploadErrorReason = null
uploadErrorDetail = null
isUploading = false
}
async function handleAttachSubmit(e: Event) {
e.preventDefault()
uploadErrorReason = null
uploadErrorDetail = null
if (activeTab === 'file' && !selectedFile) {
uploadError = 'Please select a file to upload.'
uploadErrorReason = 'file-required'
return
}
if (activeTab === 'url' && !urlValue) {
uploadError = 'Please enter a URL.'
uploadErrorReason = 'url-required'
return
}
if (activeTab === 'create' && !customName.trim()) {
uploadError = 'Please enter a filename.'
uploadErrorReason = 'filename-required'
return
}
isUploading = true
uploadError = null
const formData = new FormData()
formData.append('mode', activeTab)
@@ -183,13 +271,14 @@
attachDialogOpen = false
resetAttachState()
} else {
uploadError = resData?.error?.message || 'Attachment failed'
uploadErrorDetail = resData?.error?.message || null
if (!uploadErrorDetail) uploadErrorReason = 'failed'
}
} else {
uploadError = 'Attachment failed'
uploadErrorReason = 'failed'
}
} catch (err) {
uploadError = err instanceof Error ? err.message : String(err)
uploadErrorDetail = err instanceof Error ? err.message : String(err)
} finally {
isUploading = false
}
@@ -204,14 +293,14 @@
fields={Object.keys(data.schema.properties ?? {}).filter((key) => key !== '__proto__')}
onFocusEditor={() => editorRef?.focus()}
onError={(message) => {
editorError = message
saveSuccess = false
editorErrorReason = null
editorErrorDetail = message
}}
onApply={(source, markdown) => {
if (!editorRef) throw new Error('The editor is not ready. Please try again.')
editorRef.replaceContent(markdown, source)
saveSuccess = false
editorError = ''
editorErrorReason = null
editorErrorDetail = ''
}}
/>
{/key}
@@ -332,9 +421,19 @@
</div>
</div>
{#if uploadError}
{#if uploadErrorReason || uploadErrorDetail}
<div class="dialog-error">
{uploadError}
{#if uploadErrorReason === 'file-required'}
Please select a file to upload.
{:else if uploadErrorReason === 'url-required'}
Please enter a URL.
{:else if uploadErrorReason === 'filename-required'}
Please enter a filename.
{:else if uploadErrorReason === 'failed'}
Attachment failed
{:else}
{uploadErrorDetail}
{/if}
</div>
{/if}
@@ -419,99 +518,56 @@
</Dialog.Root>
<div class="piece-page">
<div class="header">
<div style="display:flex; gap: var(--space-2);">
<form
method="post"
action="{sourceUrl}?/save"
use:enhance={() => {
isSaving = true
bypassWarning = true
saveSuccess = false
editorError = ''
return async ({ result, update }) => {
if (disposed) return
try {
if (result.type === 'redirect') {
editorError =
'Please sign in again in another tab, then retry. Your edits are still here.'
return
}
if (result.type === 'error') {
editorError = 'Unable to save. Your edits are still here. Please try again.'
return
}
if (result.type === 'success') saveSuccess = true
await update({ reset: false })
} finally {
isSaving = false
bypassWarning = false
}
}
}}
>
<input type="hidden" name="content" value={rawContent} />
<Button type="submit" disabled={!isDirty || isSaving}>
{isSaving ? 'saving...' : 'save'}
</Button>
</form>
<a href="/admin">
<Button variant="outline">cancel</Button>
</a>
</div>
<PieceView file={data.file} hasPublishedVersion={data.hasPublishedVersion} {isSaving} />
<PieceActions
file={data.file}
currentMode="source"
canGenerate={data.canGenerate && !isSaving}
{canPublish}
onDelete={() => dialog.showModal()}
onGenerate={() => {
editorError = ''
generateDialogOpen = true
}}
onAttach={() => {
const selectedText = editorRef?.getSelectedText()?.trim() || ''
let isUrl = false
if (selectedText.startsWith('http://') || selectedText.startsWith('https://')) {
try {
new URL(selectedText)
isUrl = true
} catch (_) {
// Ignore invalid URL formatting
}
}
if (isUrl) {
activeTab = 'url'
urlValue = selectedText
} else {
activeTab = 'file'
}
attachDialogOpen = true
}}
/>
</div>
{#if saveSuccess}
<div class="banner success-banner">Piece saved successfully!</div>
{/if}
{#if editorError || form?.error}
<form id={saveFormId} hidden method="post" action="{sourceUrl}?/save" use:enhance={enhanceSave}>
<input type="hidden" name="content" value={rawContent} />
</form>
{#if editorErrorReason || editorErrorDetail || form?.error}
<div class="banner error-banner" role="alert">
<strong>Error:</strong>
{editorError || form?.error?.message}
{#if editorErrorReason === 'sign-in'}
Please sign in again in another tab, then retry. Your edits are still here.
{:else if editorErrorReason === 'save'}
Unable to save. Your edits are still here. Please try again.
{:else if editorErrorReason === 'refresh'}
Unable to refresh after saving. Your edits are still here. Please try again.
{:else}
{editorErrorDetail || form?.error?.message}
{/if}
</div>
{/if}
<div class="editor-container">
<MarkdownEditor
bind:this={editorRef}
bind:value={rawContent}
file={data.file}
returnTo={page.url.pathname}
assetFields={data.assetFields}
/>
{#key data.file}
<MarkdownEditor
bind:this={editorRef}
bind:value={rawContent}
file={data.file}
returnTo={page.url.pathname}
assetFields={data.assetFields}
>
{#snippet toolbar()}
<PieceToolbar
file={data.file}
{saveFormId}
{isDirty}
{isSaving}
hasPublishedVersion={data.hasPublishedVersion}
>
<PieceActions
canGenerate={data.canGenerate}
disabled={isSaving}
onDelete={() => dialog.showModal()}
onGenerate={() => {
editorErrorReason = null
editorErrorDetail = ''
generateDialogOpen = true
}}
onAttach={openAttachDialog}
/>
</PieceToolbar>
{/snippet}
</MarkdownEditor>
{/key}
</div>
</div>
@@ -527,15 +583,6 @@
gap: var(--space-4);
}
.header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.banner {
padding: var(--space-3);
border-radius: var(--radius-small);
@@ -543,12 +590,6 @@
font-size: 0.875rem;
}
.success-banner {
background-color: var(--color-success-container, #d4edda);
color: var(--color-on-success-container, #155724);
border: 1px solid var(--color-success, #c3e6cb);
}
.error-banner {
background-color: var(--color-error-container);
color: var(--color-on-error-container);
@@ -16,12 +16,9 @@ import { actions, load } from './+page.server'
import { config } from '$lib/server/config'
import { db } from '$lib/server/database'
import type { WebPieces } from '@luzzle/web.db'
import { getMarkdownPublicationStatus } from '$lib/pieces/publication.server'
import { MarkdownPublicationStatus } from '$lib/pieces/types'
vi.mock('$lib/server/pieces', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
vi.mock('$lib/server/storage', () => ({ getStorage: vi.fn() }))
vi.mock('$lib/pieces/publication.server', () => ({ getMarkdownPublicationStatus: vi.fn() }))
vi.mock('$lib/server/database', () => ({ db: {} }))
vi.mock('$lib/server/config', async () => {
const { makeConfig } = await import('$lib/server/config.fixture')
@@ -294,13 +291,12 @@ function setupLoad() {
writeFile: vi.fn()
})
vi.mocked(getWebPiece).mockResolvedValue(undefined)
vi.mocked(getMarkdownPublicationStatus).mockResolvedValue(MarkdownPublicationStatus.UNKNOWN)
const event = { params: { path: file } } as Parameters<typeof load>[0]
return { piece, storage, event }
}
test.each([false, true])(
'source loader exposes only generation availability (configured: %s)',
'source loader reports generation availability (configured: %s)',
async (configured) => {
config.ai = configured ? { provider: 'google', api_key: 'private-ai-key' } : undefined
const { event } = setupLoad()
@@ -311,44 +307,35 @@ test.each([false, true])(
schema: { type: 'object', properties: {} },
canGenerate: configured,
assetFields: [],
hasPublishedVersion: false,
savedMarkdown: MarkdownPublicationStatus.UNKNOWN
hasPublishedVersion: false
})
}
)
test.each([
{ name: 'no serving row', row: undefined, status: MarkdownPublicationStatus.UNKNOWN },
{ name: 'no serving row', row: undefined },
{
name: 'serving row without a hash or publication time',
row: { ...publishedPiece, content_hash: null },
status: MarkdownPublicationStatus.UNKNOWN
row: { ...publishedPiece, content_hash: null }
},
{
name: 'matching saved Markdown',
row: publishedPiece,
status: MarkdownPublicationStatus.MATCHING
},
{
name: 'changed saved Markdown',
row: publishedPiece,
status: MarkdownPublicationStatus.CHANGED
},
{ name: 'unknown saved Markdown', row: publishedPiece, status: MarkdownPublicationStatus.UNKNOWN }
{ name: 'serving row with a publication hash', row: publishedPiece }
])(
'source loader reports $name without mutations or a status-only stat',
async ({ row, status }) => {
'source loader returns raw saved source and reports $name without mutations or extra reads',
async ({ row }) => {
const { piece, storage, event } = setupLoad()
vi.mocked(getWebPiece).mockResolvedValue(row)
vi.mocked(getMarkdownPublicationStatus).mockResolvedValue(status)
expect(await load(event)).toMatchObject({
expect(await load(event)).toEqual({
file,
type: 'books',
rawContent: rawSource,
hasPublishedVersion: Boolean(row),
savedMarkdown: status
schema: piece.schema,
canGenerate: config.ai !== undefined,
assetFields: [],
hasPublishedVersion: Boolean(row)
})
expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file)
expect(getMarkdownPublicationStatus).toHaveBeenCalledExactlyOnceWith(file, row?.content_hash)
expect(piece.get).toHaveBeenCalledExactlyOnceWith(file)
expect(storage.readFile).toHaveBeenCalledExactlyOnceWith(file, 'text')
expect(storage.stat).not.toHaveBeenCalled()
expect(storage.writeFile).not.toHaveBeenCalled()
@@ -359,22 +346,15 @@ test.each([
}
)
test.each(['serving database', 'publication database', 'raw source'])(
'source loader propagates %s failures rather than claiming an unpublished or unknown state',
test.each(['serving database', 'raw source'])(
'source loader propagates %s failures',
async (failure) => {
const { piece, storage, event } = setupLoad()
const error = new Error(`${failure} failed`)
if (failure === 'serving database') vi.mocked(getWebPiece).mockRejectedValueOnce(error)
if (failure === 'publication database') {
vi.mocked(getWebPiece).mockResolvedValueOnce(publishedPiece)
vi.mocked(getMarkdownPublicationStatus).mockRejectedValueOnce(error)
}
if (failure === 'raw source') storage.readFile.mockRejectedValueOnce(error)
await expect(load(event)).rejects.toBe(error)
if (failure !== 'publication database') {
expect(getMarkdownPublicationStatus).not.toHaveBeenCalled()
}
expect(storage.stat).not.toHaveBeenCalled()
expect(storage.writeFile).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
+29
View File
@@ -69,6 +69,35 @@
text-decoration: underline;
}
.button[data-variant='editor'] {
display: inline-flex;
align-items: center;
gap: var(--space-1);
height: auto;
min-height: calc(var(--space-4) + var(--space-2));
padding: var(--space-1) var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-small);
background: transparent;
color: inherit;
box-shadow: none;
font: inherit;
text-transform: none;
top: 0;
filter: none;
}
.button[data-variant='editor']:not(:disabled):hover,
.button[data-variant='editor'][data-state='open'] {
background-color: var(--editor-selection, var(--color-surface-container-highest));
border-color: var(--editor-border, var(--color-outline));
}
.button[data-variant='editor']:focus-visible {
outline: 2px solid var(--editor-caret, var(--color-primary));
outline-offset: 2px;
}
.input::file-selector-button {
display: inline-block;
padding: 1px 6px;