[web] simplify editor workflow #202

Merged
eleith merged 1 commits from web/editor-publishing into main 2026-11-10 15:54:41 +00:00
39 changed files with 1793 additions and 1422 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@luzzle/web": patch
---
Clarify saved and published editor states in the bottom status bar, with a grouped More menu on desktop and an accessible bottom sheet on phones. Share Publish/Unpublish progress and non-destructive status refresh, retain explicit Save and Publish, and toggle Vim through the menu instead of a keyboard shortcut. Save preserves the exact source without reloading the editor; an explicit Format action edits the buffer without saving.
+187 -7
View File
@@ -1,22 +1,202 @@
import type { Cookie, Page } from '@playwright/test'
import { expect, test } from './support/test.js'
import { ADMIN, notebook } from './support/fixture.js'
test('sign in, save an edit and reload the saved source', async ({ page, baseURL }) => {
await page.goto(notebook.editorUrl)
let editorSession: Cookie[] | undefined
async function signIn(page: Page, editorUrl: string) {
if (editorSession) {
await page.context().addCookies(editorSession)
await page.goto(editorUrl)
return
}
await page.goto(editorUrl)
const destination = new URL(editorUrl, page.url()).href
await page.locator('#svelte-announcer').waitFor({ state: 'attached' })
await page.getByLabel('Username', { exact: true }).fill(ADMIN.username)
await page.getByLabel('Password', { exact: true }).fill(ADMIN.password)
await page.getByRole('button', { name: 'Sign in', exact: true }).click()
await expect(page).toHaveURL(`${baseURL}${notebook.editorUrl}`)
await expect(page).toHaveURL(destination)
editorSession = await page.context().cookies()
}
test.beforeEach(async ({ page }) => {
// Allow real read-only metadata, but never start live publication or Preview jobs.
await page.route(/\/api\/admin\/(?:publish|unpublish)(?:\/[^?]*)?(?:\?.*)?$/, (route) => {
const request = route.request()
if (request.method() === 'GET' && new URL(request.url()).pathname === '/api/admin/publish') {
return route.fallback()
}
return route.abort()
})
await page.route(/\/admin\/piece\/.*\/preview(?:\/[^?]*)?(?:\?.*)?$/, (route) => route.abort())
})
test('Save persists source; Vim menu changes preserve history and :w keeps focus and cursor', async ({
page,
baseURL
}) => {
await signIn(page, notebook.editorUrl)
const editor = page.getByRole('textbox')
const save = page.getByRole('button', { name: 'Save', exact: true })
const saved = page.getByRole('button', { name: 'Saved', exact: true })
const more = page.getByRole('button', { name: 'More', exact: true })
const vim = page.getByRole('menuitemcheckbox', { name: 'Vim mode', exact: true })
const mode = page.locator('.cm-editor .editor-status-mode')
const observation = 'A saved observation from the browser.'
await expect(editor).toContainText('Original observation.')
await editor.press('ControlOrMeta+End')
await page.keyboard.insertText(`\n\n${observation}`)
await save.click()
await expect(save).toBeDisabled()
await page.getByRole('button', { name: 'Save', exact: true }).click()
await expect(saved).toBeDisabled()
await page.reload()
await expect(editor).toContainText(observation)
const savedSource = await editor.innerText()
const draftLine = 'A draft kept while switching keybindings.'
const quotedTitleSource = savedSource.replace(/^title:.*$/m, `title: "${notebook.title}"`)
const draftSource = `${quotedTitleSource.trimEnd()}\n\n${draftLine}\n`
await editor.press('ControlOrMeta+a')
await page.keyboard.insertText(draftSource)
await more.click()
await expect(vim).not.toBeChecked()
await vim.click()
await expect(editor).toBeFocused()
await expect(mode).toHaveText('Normal')
await more.click()
await vim.click()
await expect(editor).toBeFocused()
await editor.press('ControlOrMeta+z')
await expect(editor).toHaveText(savedSource, { useInnerText: true })
await editor.press('ControlOrMeta+Shift+z')
await expect(editor).toHaveText(draftSource, { useInnerText: true })
await more.click()
await vim.click()
await expect(editor).toBeFocused()
const draftLineNumber = draftSource.slice(0, draftSource.indexOf(draftLine)).split('\n').length
await page.keyboard.type(`${draftLineNumber}G$`)
const cursorInLine = () =>
editor.evaluate((content) => {
const selection = window.getSelection()
const node = selection?.focusNode
const line = node?.parentElement?.closest('.cm-line')
if (!selection || !node || !line || !content.contains(line)) return null
const range = document.createRange()
range.selectNodeContents(line)
range.setEnd(node, selection.focusOffset)
return { line: line.textContent, offset: range.toString().length }
})
const expectedCursor = { line: draftLine, offset: draftLine.length - 1 }
await expect.poll(cursorInLine).toEqual(expectedCursor)
await editor.press(':')
const command = page.locator('.cm-editor .editor-vim-status input')
await command.fill('w')
await command.press('Enter')
await expect(saved).toBeDisabled()
await expect(editor).toContainText(`title: "${notebook.title}"`)
await expect(editor).toBeFocused()
await expect.poll(cursorInLine).toEqual(expectedCursor)
await expect(page).toHaveURL(`${baseURL}${notebook.editorUrl}`)
await page.reload()
await expect(editor).toContainText(draftLine)
await expect(editor).toContainText(`title: "${notebook.title}"`)
await expect(mode).toHaveText('Normal')
await more.click()
await expect(vim).toBeChecked()
const previewUrl = `${baseURL}${notebook.editorUrl.replace('/source', '/preview')}`
await page.route(previewUrl, (route) =>
route.fulfill({ status: 400, json: { type: 'failure', status: 400, data: null } })
)
const previewRequest = page.waitForRequest(previewUrl)
await page.getByRole('menuitem', { name: 'Preview', exact: true }).click()
expect((await previewRequest).method()).toBe('POST')
const beforeFormat = await editor.innerText()
let formatSaveRequests = 0
page.on('request', (request) => {
const url = new URL(request.url())
if (request.method() === 'POST' && url.searchParams.has('/save')) formatSaveRequests++
})
await more.click()
await page.getByRole('menuitem', { name: 'Format', exact: true }).click()
await expect.poll(() => editor.innerText()).not.toBe(beforeFormat)
await expect(page.getByRole('button', { name: 'Save', exact: true })).toBeEnabled()
await editor.press('u')
await expect(editor).toHaveText(beforeFormat, { useInnerText: true })
await expect(saved).toBeDisabled()
expect(formatSaveRequests).toBe(0)
})
test('pending Publish allows saving and typing; failed metadata refresh retains the draft', async ({
page,
baseURL
}) => {
const file = 'forest.notes.md'
const editorUrl = '/admin/piece/forest.notes.md/source'
const jobId = 'e2e-forest-publication'
const streamUrl = `**/api/admin/publish/${jobId}/stream`
let finishPublication!: () => void
const publicationFinished = new Promise<void>((resolve) => {
finishPublication = resolve
})
await page.route('**/api/admin/publish', (route) => {
if (route.request().method() !== 'POST') return route.fallback()
return route.fulfill({ status: 202, json: { jobId } })
})
await page.route(streamUrl, async (route) => {
await publicationFinished
await route.fulfill({
contentType: 'text/event-stream',
body: `event: done\ndata: ${JSON.stringify({
workflowName: 'Publish',
state: 'completed',
result: 'ok',
errors: null
})}\n\n`
})
})
try {
await signIn(page, editorUrl)
const editor = page.getByRole('textbox')
const save = page.getByRole('button', { name: 'Save', exact: true })
const saved = page.getByRole('button', { name: 'Saved', exact: true })
const publishing = page.getByRole('button', { name: 'Publishing…', exact: true })
const publication = page.locator('.cm-editor .editor-status .editor-publication-status')
await expect(publication).toHaveText('Up to date')
const acceptedRequest = page.waitForRequest(
(request) =>
request.method() === 'POST' && new URL(request.url()).pathname === '/api/admin/publish'
)
const pendingStream = page.waitForRequest(streamUrl)
await page.getByRole('button', { name: 'More', exact: true }).click()
await page.getByRole('menuitem', { name: 'Publish', exact: true }).click()
expect((await acceptedRequest).postDataJSON()).toEqual({ filePath: file })
await pendingStream
await expect(publishing).toBeDisabled()
await editor.press('ControlOrMeta+End')
await page.keyboard.insertText('\n\nSaved while Publish is still running.')
await expect(save).toBeEnabled()
await save.click()
await expect(saved).toBeDisabled()
await expect(publishing).toBeDisabled()
const savedSource = await editor.innerText()
await editor.press('ControlOrMeta+End')
await page.keyboard.insertText('\n\nUnsaved work must survive the metadata failure.')
const draftSource = await editor.innerText()
expect(draftSource).not.toBe(savedSource)
await page.route(
(url) => url.pathname === '/api/admin/publish' && url.searchParams.get('filePath') === file,
(route) => route.fulfill({ status: 500, json: { message: 'Status temporarily unavailable' } })
)
finishPublication()
await expect(page.getByRole('alert')).toContainText('Unable to refresh publication status')
await expect(publication).toHaveAttribute('data-state', 'unknown')
await expect(editor).toHaveText(draftSource, { useInnerText: true })
await expect(page).toHaveURL(`${baseURL}${editorUrl}`)
await expect(save).toBeEnabled()
} finally {
finishPublication()
}
})
+13
View File
@@ -91,6 +91,17 @@ export async function createFixture(directory: string, origin: string) {
for await (const result of await schemas.sync(asCoreDatabase(db))) {
if (result.error) throw new Error(result.message)
}
const notes = await schemas.getPiece('notes')
for await (const result of await notes.sync(
asCoreDatabase(db),
pieces.map((piece) => piece.file)
)) {
if (result.error) throw new Error(result.message)
}
const cachedPieces = await db
.selectFrom('pieces_cache')
.select(['file_path', 'content_hash'])
.execute()
// Seed the published read model directly; no Publish job is being simulated.
await db
.insertInto('web_pieces')
@@ -101,6 +112,8 @@ export async function createFixture(directory: string, origin: string) {
slug: piece.slug,
type: piece.type,
file_path: piece.file,
content_hash: cachedPieces.find((cached) => cached.file_path === piece.file)!
.content_hash,
title: piece.title,
note,
json_metadata: JSON.stringify({ title: piece.title, date: dateConsumed }),
@@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte'
let { children }: { children: Snippet } = $props()
</script>
{@render children()}
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy, type Snippet } from 'svelte'
import { onMount, onDestroy, mount, unmount, type Snippet } from 'svelte'
import { EditorView } from 'codemirror'
import { EditorState, Compartment } from '@codemirror/state'
import { configuredTheme } from './themes/configured'
@@ -8,7 +8,9 @@
import { luzzleFieldEditor } from './extensions/luzzleFieldEditor'
import { luzzleAssetEditor } from './extensions/luzzleAssetEditor'
import { replaceDocument } from './replace-document'
import { editorKeybindings, finishVimInput } from './keybindings'
import { createEditorKeybindings, finishVimInput } from './keybindings'
import type { StatusContentRenderer } from './editor-status'
import EditorStatusContent from './EditorStatusContent.svelte'
function getAppliedTheme(): 'dark' | 'light' {
const preference = window.localStorage.getItem('theme') || 'system'
@@ -25,16 +27,30 @@
type Props = {
value: string
onchange?: (value: string) => void
onSave?: () => void
file?: string
returnTo?: string
assetFields?: string[]
toolbar?: Snippet
footer?: Snippet
vimEnabled?: boolean
}
let { value = $bindable(), onchange, file, returnTo, assetFields = [], toolbar }: Props = $props()
let {
value = $bindable(),
onchange,
onSave,
file,
returnTo,
assetFields = [],
toolbar,
footer,
vimEnabled = $bindable(false)
}: Props = $props()
let editorContainer: HTMLDivElement
let view: EditorView
let keybindings: ReturnType<typeof createEditorKeybindings>
const themeConfig = new Compartment()
const lspConfig = new Compartment()
const fieldEditorConfig = new Compartment()
@@ -65,7 +81,20 @@
returnTo
})
extensions.unshift(editorKeybindings())
let renderStatus: StatusContentRenderer | undefined
if (footer) {
const content = footer
renderStatus = (target) => {
const component = mount(EditorStatusContent, { target, props: { children: content } })
return () => {
void unmount(component)
}
}
}
keybindings = createEditorKeybindings(renderStatus, onSave)
// Restore before reactive menu synchronization can apply its initial false value.
vimEnabled = keybindings.enabled
extensions.unshift(keybindings.extension)
extensions.push(lspConfig.of([]))
if (file && returnTo) {
@@ -119,16 +148,15 @@
$effect(() => {
if (view && value !== view.state.doc.toString()) {
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: value
}
})
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
}
})
$effect(() => {
const enabled = vimEnabled
if (view && keybindings) keybindings.setEnabled(view, enabled)
})
onDestroy(() => {
if (view) {
view.destroy()
@@ -1,26 +0,0 @@
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,14 +1,29 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui'
import { Dialog, DropdownMenu } from 'bits-ui'
import { tick, untrack } from 'svelte'
import { fade } from 'svelte/transition'
import { responsiveFly } from '#lib/components/ui/dialog-motion.js'
import { MediaQuery } from 'svelte/reactivity'
import { enhance } from '$app/forms'
import { resolve } from '$app/paths'
import { encodeArchivePath } from '#lib/pieces/navigation.js'
import CaretDown from 'virtual:icons/ph/caret-down'
import X from 'virtual:icons/ph/x'
type Props = {
file: string
canGenerate: boolean
disabled?: boolean
hasPublishedVersion?: boolean
publishDisabled?: boolean
unpublishDisabled?: boolean
onPublish: () => void
publishDisabled: boolean
previewDisabled?: boolean
unpublishDisabled?: boolean
vimEnabled: boolean
onVimChange: (enabled: boolean) => void
onEditorFocus: () => void
onFormat: () => void
formatDisabled?: boolean
onUnpublish?: () => void
onDelete: () => void
onAttach?: () => void
@@ -16,90 +31,323 @@
}
let {
file,
canGenerate,
disabled = false,
hasPublishedVersion = false,
publishDisabled = false,
unpublishDisabled = false,
onPublish,
publishDisabled,
previewDisabled = false,
unpublishDisabled = false,
vimEnabled,
onVimChange,
onEditorFocus,
onFormat,
formatDisabled = false,
onUnpublish,
onDelete,
onAttach,
onGenerate
}: Props = $props()
const id = $props.id()
const path = $derived(encodeArchivePath(file))
const phone = new MediaQuery('(max-width: 767px)', false)
let menuOpen = $state(false)
let sheetOpen = $state(false)
let trigger: HTMLButtonElement | null = $state(null)
let previewForm: HTMLFormElement
let focusEditorOnClose = false
let handingOffFocus = false
type ActionId =
| 'publish'
| 'preview'
| 'published'
| 'vim'
| 'format'
| 'generate'
| 'attach'
| 'unpublish'
| 'delete'
type Action = {
id: ActionId
run?: () => void
href?: string
disabled?: boolean
opensDialog?: boolean
}
const groups = $derived.by(() => {
const view: Action[] = [
{
id: 'preview',
run: () => previewForm.requestSubmit(),
disabled: previewDisabled
}
]
if (hasPublishedVersion)
view.push({
id: 'published',
href: resolve('/(auth)/admin/piece/[...path]/published', { path })
})
const editor: Action[] = [
{ id: 'format', run: onFormat, disabled: formatDisabled },
{ id: 'vim' }
]
if (canGenerate && onGenerate)
editor.push({ id: 'generate', run: onGenerate, opensDialog: true })
if (onAttach) editor.push({ id: 'attach', run: onAttach, opensDialog: true })
const destructive: Action[] = []
if (hasPublishedVersion && onUnpublish)
destructive.push({ id: 'unpublish', run: onUnpublish, disabled: unpublishDisabled })
destructive.push({ id: 'delete', run: onDelete, opensDialog: true })
return [
{
id: 'publication',
actions: [{ id: 'publish', run: onPublish, disabled: publishDisabled } as Action]
},
{ id: 'view', actions: view },
{ id: 'editor', actions: editor },
{ id: 'destructive', actions: destructive }
]
})
$effect(() => {
const phoneLayout = phone.current
untrack(() => {
const previousPopupOpen = phoneLayout ? menuOpen : sheetOpen
if (!previousPopupOpen) return
menuOpen = false
sheetOpen = false
void tick().then(() => trigger?.focus())
})
})
async function selectAction(action: Action) {
focusEditorOnClose = action.id === 'vim' || action.id === 'format'
handingOffFocus = !!action.opensDialog
if (action.id === 'vim') onVimChange(!vimEnabled)
menuOpen = false
sheetOpen = false
// Let the current popup release its focus before opening another dialog.
await tick()
if (action.opensDialog) trigger?.focus()
action.run?.()
}
function restoreFocus(event: Event, fromPhone: boolean) {
if (fromPhone !== phone.current || handingOffFocus) {
event.preventDefault()
} else if (focusEditorOnClose) {
event.preventDefault()
onEditorFocus()
}
focusEditorOnClose = false
handingOffFocus = false
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="button" data-variant="editor" {disabled}>
More <CaretDown aria-hidden="true" />
</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 onSelect={onPublish} disabled={publishDisabled}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>Publish</div>
{/snippet}
</DropdownMenu.Item>
<form
hidden
method="post"
action={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
bind:this={previewForm}
use:enhance
></form>
{#if canGenerate && onGenerate}
<DropdownMenu.Item onSelect={onGenerate}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>Generate</div>
{/snippet}
</DropdownMenu.Item>
{#snippet actionLabel(action: Action)}
<span class="action-copy">
{#if action.id === 'publish'}Publish
{:else if action.id === 'preview'}Preview
{:else if action.id === 'published'}View published
{:else if action.id === 'format'}Format
{:else if action.id === 'vim'}Vim mode
{:else if action.id === 'generate'}Generate
{:else if action.id === 'attach'}Attach
{:else if action.id === 'unpublish'}Unpublish
{:else if action.id === 'delete'}Delete{/if}
</span>
{#if action.id === 'vim'}
<span aria-hidden="true"
>{#if vimEnabled}On{:else}Off{/if}</span
>
{/if}
{/snippet}
{#snippet groupedActions(mobile: boolean)}
{#each groups as group (group.id)}
<div
class="action-group"
class:destructive={group.id === 'destructive'}
role="group"
aria-labelledby={`${id}-${mobile ? 'sheet' : 'menu'}-${group.id}`}
>
<p class="group-label" id={`${id}-${mobile ? 'sheet' : 'menu'}-${group.id}`}>
{#if group.id === 'publication'}Publication
{:else if group.id === 'view'}View
{:else if group.id === 'editor'}Editor
{:else if group.id === 'destructive'}Destructive actions{/if}
</p>
{#each group.actions as action (action.id)}
{#if mobile}
{#if action.href}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- The shared action list resolves this URL before rendering. -->
<a class="action-item" href={action.href} onclick={() => (sheetOpen = false)}>
{@render actionLabel(action)}
</a>
{:else}
<button
type="button"
class="action-item"
disabled={action.disabled}
aria-pressed={action.id === 'vim' ? vimEnabled : undefined}
onclick={() => selectAction(action)}>{@render actionLabel(action)}</button
>
{/if}
{:else if action.id === 'vim'}
<DropdownMenu.CheckboxItem
checked={vimEnabled}
onCheckedChange={() => selectAction(action)}
>
{#snippet child({ props })}
<div {...props} class="action-item">
{@render actionLabel(action)}
</div>
{/snippet}
</DropdownMenu.CheckboxItem>
{:else}
<DropdownMenu.Item
disabled={action.disabled}
onSelect={action.href ? undefined : () => selectAction(action)}
>
{#snippet child({ props })}
{#if action.href}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- The shared action list resolves this URL before rendering. -->
<a {...props} class="action-item" href={action.href}
>{@render actionLabel(action)}</a
>
{:else}
<div {...props} class="action-item">{@render actionLabel(action)}</div>
{/if}
{#if onAttach}
<DropdownMenu.Item onSelect={onAttach}>
{#snippet child({ props })}
<div class="dropdown-item" {...props}>Attach</div>
{/snippet}
</DropdownMenu.Item>
{/if}
{#if hasPublishedVersion && onUnpublish}
<DropdownMenu.Item onSelect={onUnpublish} disabled={unpublishDisabled}>
{#snippet child({ props })}
<div class="dropdown-item destructive" {...props}>Unpublish</div>
{/snippet}
</DropdownMenu.Item>
{/if}
<DropdownMenu.Item onSelect={onDelete}>
{#snippet child({ props })}
<div class="dropdown-item destructive" {...props}>Delete</div>
{/snippet}
</DropdownMenu.Item>
</div>
</div>
{/snippet}
</DropdownMenu.Item>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/each}
</div>
{/each}
{/snippet}
{#if phone.current}
<Dialog.Root bind:open={sheetOpen}>
<Dialog.Trigger
type="button"
class="button"
data-variant="editor"
aria-label="More"
{disabled}
bind:ref={trigger}
>
More
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay forceMount>
{#snippet child({ props, open })}
{#if open}
<div {...props} class="sheet-overlay" transition:fade={{ duration: 150 }}></div>
{/if}
{/snippet}
</Dialog.Overlay>
<Dialog.Content forceMount onCloseAutoFocus={(event) => restoreFocus(event, true)}>
{#snippet child({ props, open })}
{#if open}
<div {...props} class="action-sheet" transition:responsiveFly={{ duration: 300 }}>
<div class="sheet-header">
<Dialog.Title>
{#snippet child({ props })}
<h2 {...props} class="sheet-title">Piece actions</h2>
{/snippet}
</Dialog.Title>
<Dialog.Close>
{#snippet child({ props })}
<button
{...props}
type="button"
class="close-button"
aria-label="Close actions"
>
<X aria-hidden="true" />
</button>
{/snippet}
</Dialog.Close>
</div>
<Dialog.Description class="visually-hidden">
View, edit or manage this piece.
</Dialog.Description>
<div class="sheet-body">
{@render groupedActions(true)}
</div>
</div>
{/if}
{/snippet}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{:else}
<DropdownMenu.Root bind:open={menuOpen}>
<DropdownMenu.Trigger class="button" data-variant="editor" {disabled} bind:ref={trigger}>
More <CaretDown aria-hidden="true" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
sideOffset={8}
forceMount
onCloseAutoFocus={(event) => restoreFocus(event, false)}
>
{#snippet child({ open, props, wrapperProps })}
{#if open}
<div {...wrapperProps}>
<div {...props} class="dropdown-content">{@render groupedActions(false)}</div>
</div>
{/if}
{/snippet}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{/if}
<style>
.dropdown-content {
background-color: var(--editor-panelBackground, var(--color-surface-container));
background: 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);
box-sizing: border-box;
width: 10rem;
max-width: calc(100vw - 1rem);
padding: var(--space-1);
font-family: var(--font-mono-name), monospace;
box-shadow: var(--shadow-raised);
z-index: 1001;
border-radius: var(--radius-small);
width: 15rem;
max-width: calc(100vw - 1rem);
max-height: var(--bits-dropdown-menu-content-available-height);
overflow-y: auto;
padding: var(--space-1);
font-size: var(--font-size-xxs);
line-height: 1.4;
box-shadow: var(--shadow-raised);
z-index: 1000;
}
.dropdown-item {
.action-group + .action-group {
border-top: 1px solid var(--editor-border, var(--color-outline));
margin-top: var(--space-1);
padding-top: var(--space-1);
}
.group-label {
margin: 0;
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-xxs);
color: var(--editor-descriptionForeground, var(--color-on-surface-variant));
}
.action-item {
display: flex;
align-items: center;
min-height: calc(var(--space-4) + var(--space-2));
@@ -116,25 +364,106 @@
border: none;
font: inherit;
text-transform: none;
letter-spacing: normal;
box-shadow: none;
}
.dropdown-item:hover,
.dropdown-item[data-highlighted] {
background-color: var(--editor-selection, var(--color-surface-container-highest));
.action-copy {
flex: 1;
}
.action-item:hover,
.action-item[data-highlighted] {
background: var(--editor-selection, var(--color-surface-container-highest));
outline: none;
}
.dropdown-item:focus-visible {
.action-item:focus-visible,
.close-button:focus-visible {
outline: 2px solid var(--editor-caret, var(--color-primary));
outline-offset: -2px;
}
.dropdown-item[data-disabled] {
.action-item[data-disabled],
.action-item:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.destructive {
.destructive .action-item {
color: var(--color-error);
}
.sheet-overlay {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--color-surface-dim) 60%, transparent);
backdrop-filter: blur(4px);
z-index: 1000;
}
.action-sheet {
position: fixed;
inset: auto 0 0;
display: flex;
flex-direction: column;
max-height: 85dvh;
box-sizing: border-box;
overflow: hidden;
outline: none;
border: none;
border-top: 2px solid var(--editor-border, var(--color-outline));
border-radius: 0;
background: var(--editor-panelBackground, var(--color-surface-container));
color: var(--editor-panelForeground, var(--color-on-surface));
box-shadow: var(--shadow-raised);
font-family: var(--font-mono-name), monospace;
font-size: var(--font-size-xs);
z-index: 1001;
--fly-x: 0px;
--fly-y-offset: 400px;
}
.sheet-body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-5);
padding-bottom: max(var(--space-5), env(safe-area-inset-bottom));
}
.sheet-header {
display: flex;
flex-shrink: 0;
padding: var(--space-2) var(--space-5);
border-bottom: 2px solid var(--editor-border, var(--color-outline));
background: var(--editor-panelBackground, var(--color-surface-container));
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.sheet-title {
font: inherit;
font-weight: 600;
margin: 0;
}
.close-button {
display: grid;
place-items: center;
min-width: 44px;
min-height: 44px;
padding: 0;
background: none;
border: none;
box-shadow: none;
color: inherit;
cursor: pointer;
}
.action-sheet .action-item {
min-height: 44px;
padding: var(--space-2);
}
</style>
@@ -1,29 +0,0 @@
import { expect, test, vi } from 'vitest'
import { render } from 'svelte/server'
import PieceActions from './PieceActions.svelte'
// 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: {
canGenerate: true,
disabled,
hasPublishedVersion: true,
publishDisabled: disabled,
unpublishDisabled: disabled,
onPublish: vi.fn(),
onUnpublish: vi.fn(),
onDelete: vi.fn(),
onAttach: vi.fn(),
onGenerate: vi.fn()
}
})
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|=|>)/)
})
@@ -1,112 +1,77 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { resolve } from '$app/paths'
import { enhance } from '$app/forms'
import type { EditorPublicationStatus } from '#lib/pieces/types.js'
import Button from '#lib/components/ui/Button.svelte'
import { encodeArchivePath } from '#lib/pieces/navigation.js'
type Props = {
file: string
saveFormId: string
isDirty: boolean
isSaving: boolean
isUnpublishing?: boolean
hasPublishedVersion: boolean
publishDisabled: boolean
publicationStatus: EditorPublicationStatus
onPublish: () => void
children: Snippet
}
let {
file,
saveFormId,
isDirty,
isSaving,
isUnpublishing = false,
hasPublishedVersion,
publishDisabled,
publicationStatus,
onPublish,
children
}: Props = $props()
const path = $derived(encodeArchivePath(file))
const isPublishing = $derived(publicationStatus === 'publishing')
</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>
{@render children()}
</div>
<div class="status" aria-live="polite">
<div>
{#if isSaving}
Saving…
{:else if isDirty}
Unsaved
{:else}
<form
method="post"
action={resolve('/(auth)/admin/piece/[...path]/preview', { path })}
use:enhance
>
<button type="submit" aria-label="Preview saved piece" title="Preview saved piece">
Saved
</button>
</form>
{/if}
</div>
<span class="publication-status">
{#if isUnpublishing}
Unpublishing…
{:else if hasPublishedVersion}
<a href={resolve('/(auth)/admin/piece/[...path]/published', { path })}>Published</a>
{:else}
Unpublished
{/if}
<span class="save-action" class:clean={!isDirty && !isSaving}>
<Button variant="editor" type="submit" form={saveFormId} disabled={!isDirty || isSaving}>
{#if isSaving}Saving…{:else if isDirty}Save{:else}Saved{/if}
</Button>
</span>
{#if publicationStatus !== 'published' && publicationStatus !== 'unpublishing'}
<span class="publish-action" class:deferred={(isDirty || isSaving) && !isPublishing}>
<Button variant="editor" type="button" onclick={onPublish} disabled={publishDisabled}>
{#if isPublishing}Publishing…{:else}Publish{/if}
</Button>
</span>
{/if}
{@render children()}
</div>
</div>
<style>
.toolbar {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-2) var(--space-3);
justify-content: flex-end;
gap: 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: nowrap;
align-items: center;
flex-shrink: 0;
gap: var(--space-1);
}
.status {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-3);
margin-left: auto;
white-space: nowrap;
.save-action,
.publish-action {
display: contents;
}
.publication-status {
border-inline-start: 1px solid var(--editor-border, var(--color-outline));
padding-inline-start: var(--space-3);
}
.status a,
.status button {
color: inherit;
text-decoration: underline;
}
.status button {
border: 0;
padding: 0;
background: transparent;
font: inherit;
cursor: pointer;
@media (max-width: 767px) {
.toolbar {
gap: var(--space-2);
padding-inline: var(--space-2);
}
.save-action.clean,
.publish-action.deferred {
display: none;
}
}
</style>
@@ -1,143 +0,0 @@
import { createRawSnippet, type ComponentProps } from 'svelte'
import { render } from 'svelte/server'
import { beforeEach, expect, test, vi } from 'vitest'
import { resolve } from '$app/paths'
import {
encodeArchivePath,
previewHref as piecePreviewHref,
publishedHref as piecePublishedHref
} from '#lib/pieces/navigation.js'
import PieceToolbar from './PieceToolbar.svelte'
vi.mock('$app/paths', () => ({
resolve: vi.fn((route: string, params: { path: string }) =>
route.replace('/(auth)', '').replace('[...path]', () => params.path)
)
}))
vi.mock('$app/forms', () => ({ enhance: vi.fn() }))
beforeEach(() => vi.clearAllMocks())
const file = 'books/源 #?%.books.md'
const previewHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/preview'
const publishedHref = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published'
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 buttons(output: string) {
return [...output.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)].map((match) => ({
attributes: match[1],
label: match[2].replace(/<!--[\s\S]*?-->/g, '').trim()
}))
}
function buttonAttributes(output: string, label: string): string {
const button = buttons(output).find((button) => button.label === label)
if (!button) throw new Error(`Missing button: ${label}`)
return button.attributes
}
test.each([false, true])(
'saved files submit a preview action when published=%s',
(hasPublishedVersion) => {
const output = html({ hasPublishedVersion })
expect(buttons(output).map((button) => button.label)).toEqual(['Save', 'Saved'])
expect(buttonAttributes(output, 'Save')).toMatch(disabledAttribute)
expect(buttonAttributes(output, 'Saved')).toContain('type="submit"')
expect(output).toContain('method="post"')
expect(output).toContain(`action="${previewHref}"`)
expect(output).not.toContain(`href="${previewHref}"`)
expect(output).toContain('aria-label="Preview saved piece"')
expect(output).toContain('More fixture')
expect(output).toContain('aria-live="polite"')
}
)
test('unsaved documents have a short status instead of a Saved preview action', () => {
const output = html({ isDirty: true })
expect(buttonAttributes(output, 'Save')).not.toMatch(disabledAttribute)
expect(output).toContain('Unsaved')
expect(output).not.toContain('Unsaved changes')
expect(output).not.toContain(previewHref)
expect(output).toContain(publishedHref)
})
test.each([false, true])('saving disables Save when dirty=%s', (isDirty) => {
const output = html({ isDirty, isSaving: true })
expect(buttonAttributes(output, 'Saving…')).toMatch(disabledAttribute)
expect(output).not.toContain(previewHref)
expect(output).not.toContain('Unsaved')
})
test.each([
{ isDirty: false, isSaving: false, saveLabel: 'Save' },
{ isDirty: true, isSaving: false, saveLabel: 'Save' },
{ isDirty: false, isSaving: true, saveLabel: 'Saving…' },
{ isDirty: true, isSaving: true, saveLabel: 'Saving…' }
])('editor commands retain their shared appearance with %o', ({ saveLabel, ...state }) => {
const output = html(state)
expect(buttonAttributes(output, saveLabel)).toContain('data-variant="editor"')
})
test('Save retains its source form submission', () => {
const output = html({ isDirty: true })
const save = buttonAttributes(output, 'Save')
expect(save).toContain('type="submit"')
expect(save).toContain('form="save-piece"')
})
test('publication status links only to an existing public version', () => {
expect(html()).toContain(`href="${publishedHref}"`)
const unpublished = html({ hasPublishedVersion: false })
expect(unpublished).toContain('Unpublished')
expect(unpublished).not.toContain(publishedHref)
expect(unpublished).toContain(previewHref)
})
test.each([false, true])(
'pending Unpublish replaces only the public link when published=%s',
(hasPublishedVersion) => {
const output = html({ hasPublishedVersion, isUnpublishing: true })
expect(output).toContain('Unpublishing…')
expect(output).not.toContain(publishedHref)
expect(output).toContain(previewHref)
}
)
test('editor actions do not duplicate 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')
})
test('encoded file paths enter the app resolver unchanged', () => {
const file = 'books/literal%2F 源 #?%.books.md'
const preview = piecePreviewHref(file)
const published = piecePublishedHref(file)
const output = html({ file })
expect(preview).toContain('literal%252F%20%E6%BA%90%20%23%3F%25.books.md')
expect(vi.mocked(resolve).mock.calls).toEqual([
['/(auth)/admin/piece/[...path]/preview', { path: encodeArchivePath(file) }],
['/(auth)/admin/piece/[...path]/published', { path: encodeArchivePath(file) }]
])
expect(output).toContain(`action="${preview}"`)
expect(output).toContain(`href="${published}"`)
})
// Mocked SSR verifies resolver inputs, labels, links and disabled/form semantics,
// not browser/native resolution, clicks or responsive layout.
@@ -0,0 +1,33 @@
<script lang="ts">
import type { EditorPublicationStatus } from '#lib/pieces/types.js'
type Props = {
status: EditorPublicationStatus
hasPublishedVersion: boolean
isDirty: boolean
isSaving: boolean
}
let { status, hasPublishedVersion, isDirty, isSaving }: Props = $props()
</script>
<span class="editor-publication-status" data-state={status} role="status">
{#if status === 'unknown'}Status unavailable
{:else if status === 'publishing'}Publishing…
{:else if status === 'unpublishing'}Unpublishing…
{:else if isSaving}Saving…
{:else if isDirty}Unsaved
{:else if status === 'changed'}Saved changes
{:else if hasPublishedVersion}Up to date
{:else}Unpublished{/if}
</span>
<style>
.editor-publication-status {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: inherit;
}
</style>
@@ -0,0 +1,52 @@
import { afterEach, expect, test, vi } from 'vitest'
import type { EditorView, ViewUpdate } from '@codemirror/view'
import { getCM } from '@replit/codemirror-vim'
import { editorStatusPanel } from './editor-status'
vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn() }))
afterEach(() => vi.resetAllMocks())
test('mode events update the panel without document edits and detach on disable or destroy', () => {
const dom = { hidden: false, setAttribute: vi.fn(), append: vi.fn() }
const modeLabel = { textContent: '', setAttribute: vi.fn() }
const prompt = { hidden: true, append: vi.fn() }
const createElement = vi
.fn()
.mockReturnValueOnce(dom)
.mockReturnValueOnce(modeLabel)
.mockReturnValueOnce(prompt)
const view = { dom: { ownerDocument: { createElement } } } as unknown as EditorView
const events = new EventTarget()
const state = { vim: { insertMode: false } }
const off = vi.fn((event: string, handler: () => void) =>
events.removeEventListener(event, handler)
)
const adapter = {
state,
on: (event: string, handler: () => void) => events.addEventListener(event, handler),
off
} as unknown as NonNullable<ReturnType<typeof getCM>>
vi.mocked(getCM).mockReturnValue(adapter)
const panel = editorStatusPanel(view)
expect(modeLabel.textContent).toBe('Normal')
state.vim.insertMode = true
events.dispatchEvent(new Event('vim-mode-change'))
expect(modeLabel.textContent).toBe('Insert')
vi.mocked(getCM).mockReturnValue(null)
panel.update?.({} as ViewUpdate)
expect(off).toHaveBeenCalledWith('vim-mode-change', expect.any(Function))
state.vim.insertMode = false
events.dispatchEvent(new Event('vim-mode-change'))
expect(modeLabel.textContent).toBe('')
vi.mocked(getCM).mockReturnValue(adapter)
panel.update?.({} as ViewUpdate)
expect(modeLabel.textContent).toBe('Normal')
panel.destroy?.()
state.vim.insertMode = true
events.dispatchEvent(new Event('vim-mode-change'))
expect(modeLabel.textContent).toBe('Normal')
})
@@ -0,0 +1,91 @@
import type { EditorView, Panel } from '@codemirror/view'
import { getCM } from '@replit/codemirror-vim'
export type StatusContentRenderer = (target: HTMLElement) => () => void
export function editorStatusPanel(view: EditorView, renderContent?: StatusContentRenderer): Panel {
const document = view.dom.ownerDocument
const dom = document.createElement('div')
dom.className = 'editor-status'
dom.setAttribute('role', 'group')
dom.setAttribute('aria-label', 'Editor status')
const modeLabel = document.createElement('span')
modeLabel.className = 'editor-status-mode'
modeLabel.setAttribute('role', 'status')
dom.append(modeLabel)
const prompt = document.createElement('span')
prompt.className = 'editor-vim-status cm-vim-panel'
prompt.hidden = true
dom.append(prompt)
let content: HTMLElement | undefined
let destroyContent: (() => void) | undefined
if (renderContent) {
content = document.createElement('span')
content.className = 'editor-status-content'
dom.append(content)
destroyContent = renderContent(content)
}
let editor: ReturnType<typeof getCM> = null
let previousStatusbar: Element | null | undefined
function render() {
const state = editor?.state.vim
let mode = ''
if (state) {
mode = 'Normal'
if (state.visualMode) {
mode = 'Visual'
if (state.visualBlock) mode = 'Visual block'
else if (state.visualLine) mode = 'Visual line'
} else if (state.insertMode) {
mode = editor?.state.overwrite ? 'Replace' : 'Insert'
}
}
// Cursor movements and focus updates must not repeatedly announce unchanged status.
if (modeLabel.textContent !== mode) modeLabel.textContent = mode
const dialog = editor?.state.dialog
const showingPrompt = !!dialog
if (dialog && dialog.parentElement !== prompt) prompt.append(dialog)
prompt.hidden = !showingPrompt
modeLabel.hidden = showingPrompt || !mode
if (content) content.hidden = showingPrompt
dom.setAttribute('data-vim-dialog', String(showingPrompt))
dom.hidden = !showingPrompt && !mode && !renderContent
}
function detachEditor() {
editor?.off('vim-mode-change', render)
editor?.off('dialog', render)
if (editor && editor.state.statusbar === prompt) {
editor.state.statusbar = previousStatusbar
}
}
function update() {
const current = getCM(view)
if (current !== editor) {
detachEditor()
editor = current
previousStatusbar = editor?.state.statusbar
if (editor) {
// Give Vim a host in our panel so prompts do not create a second bottom row.
editor.state.statusbar = prompt
editor.on('vim-mode-change', render)
editor.on('dialog', render)
}
}
render()
}
update()
return {
dom,
top: false,
update,
destroy() {
detachEditor()
destroyContent?.()
}
}
}
@@ -1,173 +0,0 @@
import { afterEach, expect, test, vi } from 'vitest'
import type { EditorView, ViewUpdate } from '@codemirror/view'
import { getCM } from '@replit/codemirror-vim'
import { keybindingsStatus } from './keybindings-status'
vi.mock('@replit/codemirror-vim', () => ({ getCM: vi.fn() }))
afterEach(() => vi.resetAllMocks())
function element() {
let text = ''
const writes = vi.fn()
return {
className: '',
setAttribute: vi.fn(),
append: vi.fn(),
writes,
get textContent() {
return text
},
set textContent(value: string) {
text = value
writes(value)
}
}
}
function setup() {
const dom = element()
const label = element()
const modeLabel = element()
const createElement = vi
.fn()
.mockReturnValueOnce(dom)
.mockReturnValueOnce(label)
.mockReturnValueOnce(modeLabel)
const focus = vi.fn()
const view = {
dom: { ownerDocument: { createElement } },
hasFocus: false,
focus
} as unknown as EditorView
const panel = keybindingsStatus(view)
return { panel, dom, label, modeLabel, createElement, focus }
}
function vimEditor() {
const events = new EventTarget()
const state = {
vim: { insertMode: false, visualMode: false, visualLine: false, visualBlock: false },
overwrite: false
}
const adapter = {
state,
on: vi.fn((event: string, handler: () => void) => events.addEventListener(event, handler)),
off: vi.fn((event: string, handler: () => void) => events.removeEventListener(event, handler))
}
return {
adapter: adapter as unknown as NonNullable<ReturnType<typeof getCM>>,
state,
on: adapter.on,
off: adapter.off,
modeChanged: () => events.dispatchEvent(new Event('vim-mode-change'))
}
}
test('renders a bottom informational footer in Standard mode with no focusable controls', () => {
vi.mocked(getCM).mockReturnValue(null)
const { panel, dom, label, modeLabel, createElement, focus } = setup()
expect(panel.top).toBe(false)
expect(panel.dom).toBe(dom)
expect(dom.className).toBe('editor-status')
expect(dom.setAttribute).toHaveBeenCalledWith('aria-label', 'Editor keybindings')
expect(label.setAttribute).toHaveBeenCalledWith('role', 'status')
expect(label.textContent).toBe('Standard (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe('')
expect(modeLabel.setAttribute).toHaveBeenCalledWith('role', 'status')
expect(createElement.mock.calls).toEqual([['div'], ['span'], ['span']])
expect(dom.append).toHaveBeenCalledWith(label, modeLabel)
panel.update?.({ focusChanged: true } as ViewUpdate)
panel.destroy?.()
expect(focus).not.toHaveBeenCalled()
})
test.each([
['Normal', {}],
['Insert', { insertMode: true }],
['Visual', { visualMode: true }],
['Visual line', { visualMode: true, visualLine: true }],
['Visual block', { visualMode: true, visualBlock: true }]
])('shows the actual initial Vim mode: %s', (mode, flags) => {
const editor = vimEditor()
Object.assign(editor.state.vim, flags)
vi.mocked(getCM).mockReturnValue(editor.adapter)
const { dom, label, modeLabel } = setup()
expect(label.textContent).toBe('Vim (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe(mode)
expect(dom.append).toHaveBeenCalledWith(label, modeLabel)
})
test('updates from mode events even when there is no document transaction', () => {
const editor = vimEditor()
vi.mocked(getCM).mockReturnValue(editor.adapter)
const { panel, modeLabel, focus } = setup()
const listener = editor.on.mock.calls[0][1]
expect(editor.on).toHaveBeenCalledExactlyOnceWith('vim-mode-change', listener)
editor.state.vim.insertMode = true
editor.modeChanged()
expect(modeLabel.textContent).toBe('Insert')
editor.state.overwrite = true
editor.modeChanged()
expect(modeLabel.textContent).toBe('Replace')
editor.state.vim.insertMode = false
editor.state.overwrite = false
editor.modeChanged()
expect(modeLabel.textContent).toBe('Normal')
panel.update?.({ focusChanged: true } as ViewUpdate)
expect(modeLabel.textContent).toBe('Normal')
expect(focus).not.toHaveBeenCalled()
})
test('tracks enable/disable and a replacement Vim instance without leaking listeners', () => {
vi.mocked(getCM).mockReturnValue(null)
const { panel, label, modeLabel } = setup()
const first = vimEditor()
vi.mocked(getCM).mockReturnValue(first.adapter)
panel.update?.({} as ViewUpdate)
expect(label.textContent).toBe('Vim (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe('Normal')
const firstListener = first.on.mock.calls[0][1]
vi.mocked(getCM).mockReturnValue(null)
panel.update?.({} as ViewUpdate)
expect(first.off).toHaveBeenCalledExactlyOnceWith('vim-mode-change', firstListener)
expect(label.textContent).toBe('Standard (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe('')
first.state.vim.insertMode = true
first.modeChanged()
expect(label.textContent).toBe('Standard (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe('')
const second = vimEditor()
vi.mocked(getCM).mockReturnValue(second.adapter)
panel.update?.({} as ViewUpdate)
const secondListener = second.on.mock.calls[0][1]
panel.destroy?.()
expect(second.off).toHaveBeenCalledExactlyOnceWith('vim-mode-change', secondListener)
second.state.vim.insertMode = true
second.modeChanged()
expect(label.textContent).toBe('Vim (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe('Normal')
})
test('does not rewrite the live status for every cursor or focus update', () => {
const editor = vimEditor()
vi.mocked(getCM).mockReturnValue(editor.adapter)
const { panel, label, modeLabel } = setup()
panel.update?.({ selectionSet: true } as ViewUpdate)
panel.update?.({ focusChanged: true } as ViewUpdate)
editor.modeChanged()
expect(label.writes).toHaveBeenCalledOnce()
expect(modeLabel.writes).toHaveBeenCalledOnce()
expect(editor.on).toHaveBeenCalledOnce()
})
test('does not report Vim from a stale adapter without active Vim state', () => {
const editor = vimEditor()
delete (editor.state as { vim?: unknown }).vim
vi.mocked(getCM).mockReturnValue(editor.adapter)
const { label, modeLabel } = setup()
expect(label.textContent).toBe('Standard (Ctrl+Alt+V)')
expect(modeLabel.textContent).toBe('')
})
@@ -1,57 +0,0 @@
import type { EditorView, Panel } from '@codemirror/view'
import { getCM } from '@replit/codemirror-vim'
/** An informational footer; keybinding changes still belong to the shortcut. */
export function keybindingsStatus(view: EditorView): Panel {
const document = view.dom.ownerDocument
const dom = document.createElement('div')
dom.className = 'editor-status'
dom.setAttribute('role', 'group')
dom.setAttribute('aria-label', 'Editor keybindings')
const label = document.createElement('span')
label.setAttribute('role', 'status')
const modeLabel = document.createElement('span')
modeLabel.setAttribute('role', 'status')
dom.append(label, modeLabel)
let editor: ReturnType<typeof getCM> = null
function render() {
const state = editor?.state.vim
const text = `${state ? 'Vim' : 'Standard'} (Ctrl+Alt+V)`
let mode = ''
if (state) {
mode = 'Normal'
if (state.visualMode) {
mode = 'Visual'
if (state.visualBlock) mode = 'Visual block'
else if (state.visualLine) mode = 'Visual line'
} else if (state.insertMode) {
mode = editor?.state.overwrite ? 'Replace' : 'Insert'
}
}
// Cursor movements and focus updates must not repeatedly announce the same mode.
if (label.textContent !== text) label.textContent = text
if (modeLabel.textContent !== mode) modeLabel.textContent = mode
}
function update() {
const current = getCM(view)
if (current !== editor) {
editor?.off('vim-mode-change', render)
editor = current
editor?.on('vim-mode-change', render)
}
render()
}
update()
return {
dom,
top: false,
update,
destroy() {
editor?.off('vim-mode-change', render)
}
}
}
@@ -1,45 +1,35 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { EditorState, StateField, type TransactionSpec } from '@codemirror/state'
import { EditorView, showPanel } from '@codemirror/view'
import { history, undo } from '@codemirror/commands'
import { getCM, Vim, vim } from '@replit/codemirror-vim'
import { editorKeybindings, finishVimInput } from './keybindings'
import { keybindingsStatus } from './keybindings-status'
import type { EditorView } from '@codemirror/view'
import { vim } from '@replit/codemirror-vim'
import { createEditorKeybindings } from './keybindings'
vi.mock('@replit/codemirror-vim', () => ({
vim: vi.fn(),
getCM: vi.fn(),
Vim: { handleKey: vi.fn() }
Vim: { defineEx: vi.fn() }
}))
const activeVim = StateField.define({ create: () => true, update: (value) => value })
const storage = { getItem: vi.fn(), setItem: vi.fn() }
let handlers: Parameters<typeof EditorView.domEventHandlers>[0]
beforeEach(() => {
vi.stubGlobal('window', { localStorage: storage })
storage.getItem.mockReturnValue(null)
vi.mocked(vim).mockReturnValue(activeVim)
vi.mocked(getCM).mockReturnValue(null)
vi.spyOn(EditorView, 'domEventHandlers').mockImplementation((value) => {
handlers = value
return []
})
})
afterEach(() => {
vi.resetAllMocks()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
function setup() {
const bindings = editorKeybindings()
const keydown = handlers.keydown!
const controller = createEditorKeybindings()
let state = EditorState.create({
doc: 'Notes',
selection: { anchor: 1, head: 3 },
extensions: [bindings, history()]
extensions: controller.extension
})
const view = {
get state() {
@@ -49,129 +39,41 @@ function setup() {
state = state.update(spec).state
}
} as EditorView
return { view, keydown: (event: KeyboardEvent) => keydown.call(view, event, view) }
return { controller, view }
}
function shortcut(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
key: 'v',
ctrlKey: true,
altKey: true,
shiftKey: false,
metaKey: false,
isComposing: false,
repeat: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...overrides
} as unknown as KeyboardEvent
}
test('menu changes preserve the buffer and selection, and persist for the next editor', () => {
storage.setItem.mockImplementation((_key, value) => storage.getItem.mockReturnValue(value))
const { controller, view } = setup()
const selection = view.state.selection
expect(controller.enabled).toBe(false)
test.each([null, 'standard', 'invalid'])('defaults to Standard for preference %j', (preference) => {
storage.getItem.mockReturnValue(preference)
const { view } = setup()
expect(storage.getItem).toHaveBeenCalledWith('editor.keybindings')
expect(view.state.facet(showPanel)).toContain(keybindingsStatus)
expect(view.state.field(activeVim, false)).toBeUndefined()
expect(vim).not.toHaveBeenCalled()
})
test('restores Vim without the plugin’s separate status panel', () => {
storage.getItem.mockReturnValue('vim')
const { view } = setup()
expect(view.state.field(activeVim)).toBe(true)
expect(vim).toHaveBeenCalledExactlyOnceWith({ status: false })
})
test('Ctrl+Alt+V toggles and remembers the preference without changing text or selection', () => {
const { view, keydown } = setup()
const before = view.state.selection
const event = shortcut()
expect(keydown(event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalledOnce()
expect(event.stopPropagation).toHaveBeenCalledOnce()
controller.setEnabled(view, true)
expect(controller.enabled).toBe(true)
expect(view.state.field(activeVim)).toBe(true)
expect(vim).toHaveBeenCalledWith({ status: false })
expect(storage.setItem).toHaveBeenLastCalledWith('editor.keybindings', 'vim')
expect(view.state.doc.toString()).toBe('Notes')
expect(view.state.selection.eq(before)).toBe(true)
const restored = setup()
expect(restored.controller.enabled).toBe(true)
expect(restored.view.state.field(activeVim)).toBe(true)
expect(keydown(shortcut())).toBe(true)
controller.setEnabled(view, false)
expect(controller.enabled).toBe(false)
expect(view.state.field(activeVim, false)).toBeUndefined()
expect(storage.setItem).toHaveBeenLastCalledWith('editor.keybindings', 'standard')
expect(view.state.facet(showPanel)).toContain(keybindingsStatus)
expect(view.state.doc.toString()).toBe('Notes')
expect(view.state.selection.eq(before)).toBe(true)
expect(view.state.selection.eq(selection)).toBe(true)
})
test('does not add history entries or discard prior editing history when toggled', () => {
const { view, keydown } = setup()
view.dispatch({ changes: { from: 5, insert: ' edited' } })
keydown(shortcut())
keydown(shortcut())
expect(undo(view)).toBe(true)
expect(view.state.doc.toString()).toBe('Notes')
})
test('restores the shared preference when opening another editor', () => {
storage.setItem.mockImplementation((_key, value) => storage.getItem.mockReturnValue(value))
setup().keydown(shortcut())
expect(setup().view.state.field(activeVim)).toBe(true)
})
test.each([
{ ctrlKey: false },
{ altKey: false },
{ shiftKey: true },
{ metaKey: true },
{ key: 'x' },
{ isComposing: true }
])('leaves other key events alone: %j', (overrides) => {
const { view, keydown } = setup()
const event = shortcut(overrides)
expect(keydown(event)).toBe(false)
expect(event.preventDefault).not.toHaveBeenCalled()
expect(view.state.field(activeVim, false)).toBeUndefined()
expect(storage.setItem).not.toHaveBeenCalled()
})
test('does not toggle repeatedly while the shortcut is held down', () => {
const { view, keydown } = setup()
keydown(shortcut())
expect(keydown(shortcut({ repeat: true }))).toBe(true)
expect(view.state.field(activeVim)).toBe(true)
expect(storage.setItem).toHaveBeenCalledTimes(1)
})
test('accepts Caps Lock without requiring Shift', () => {
const { view, keydown } = setup()
keydown(shortcut({ key: 'V' }))
expect(view.state.field(activeVim)).toBe(true)
})
test('still edits and toggles when browser storage is unavailable', () => {
test('unavailable browser storage does not prevent switching to Vim', () => {
storage.getItem.mockImplementation(() => {
throw new Error('Storage unavailable')
})
storage.setItem.mockImplementation(() => {
throw new Error('Storage unavailable')
})
const { view, keydown } = setup()
expect(view.state.field(activeVim, false)).toBeUndefined()
expect(() => keydown(shortcut())).not.toThrow()
const { controller, view } = setup()
expect(controller.enabled).toBe(false)
controller.setEnabled(view, true)
expect(view.state.field(activeVim)).toBe(true)
})
test('finishes the active Vim input operation before an external document replacement', () => {
const { view } = setup()
const editor = {} as NonNullable<ReturnType<typeof getCM>>
vi.mocked(getCM).mockReturnValue(editor)
finishVimInput(view)
expect(getCM).toHaveBeenCalledExactlyOnceWith(view)
expect(Vim.handleKey).toHaveBeenCalledExactlyOnceWith(editor, '<Esc>', 'api')
})
test('finishing Vim input is a no-op in Standard mode', () => {
const { view } = setup()
finishVimInput(view)
expect(Vim.handleKey).not.toHaveBeenCalled()
})
@@ -1,9 +1,17 @@
import { Compartment, Prec, type Extension } from '@codemirror/state'
import { EditorView, showPanel } from '@codemirror/view'
import { getCM, Vim, vim } from '@replit/codemirror-vim'
import { keybindingsStatus } from './keybindings-status'
import { Compartment, type Extension } from '@codemirror/state'
import { showPanel, ViewPlugin, type EditorView } from '@codemirror/view'
import { getCM, Vim, vim, type CodeMirrorV } from '@replit/codemirror-vim'
import { editorStatusPanel, type StatusContentRenderer } from './editor-status'
const preferenceKey = 'editor.keybindings'
const saveHandlers = new WeakMap<EditorView, () => void>()
function saveFromVim(editor: CodeMirrorV) {
saveHandlers.get(editor.cm6)?.()
}
Vim.defineEx('write', 'w', saveFromVim)
Vim.defineEx('wq', 'wq', saveFromVim)
function readPreference(): boolean {
try {
@@ -21,38 +29,47 @@ function savePreference(enabled: boolean) {
}
}
/** Create once per editor, before its ordinary keymaps. */
export function editorKeybindings(): Extension {
export function createEditorKeybindings(renderStatus?: StatusContentRenderer, onSave?: () => void) {
const keybindings = new Compartment()
let enabled = readPreference()
return [
Prec.highest(
EditorView.domEventHandlers({
keydown(event, view) {
if (
!event.ctrlKey ||
!event.altKey ||
event.shiftKey ||
event.metaKey ||
event.isComposing ||
event.key.toLowerCase() !== 'v'
)
return false
event.preventDefault()
event.stopPropagation()
if (event.repeat) return true
enabled = !enabled
view.dispatch({
effects: keybindings.reconfigure(enabled ? vim({ status: false }) : [])
})
savePreference(enabled)
return true
function setEnabled(view: EditorView, next: boolean) {
if (enabled === next) return
enabled = next
view.dispatch({
effects: keybindings.reconfigure(enabled ? vim({ status: false }) : [])
})
savePreference(enabled)
}
const extension: Extension[] = [
keybindings.of(enabled ? vim({ status: false }) : []),
showPanel.of((view) => editorStatusPanel(view, renderStatus))
]
if (onSave) {
extension.push(
ViewPlugin.define((view) => {
saveHandlers.set(view, onSave)
return {
destroy() {
saveHandlers.delete(view)
}
}
})
),
keybindings.of(enabled ? vim({ status: false }) : []),
showPanel.of(keybindingsStatus)
]
)
}
return {
extension,
get enabled() {
return enabled
},
setEnabled
}
}
export function editorKeybindings(onSave?: () => void): Extension {
return createEditorKeybindings(undefined, onSave).extension
}
/** Finish Vim's insertion before applying a generated document, outside dot-repeat. */
@@ -3,9 +3,7 @@ import { isolateHistory } from '@codemirror/commands'
export function replaceDocument(state: EditorState, content: string, expectedSource: string) {
if (state.doc.toString() !== expectedSource) {
throw new Error(
'The editor changed during generation. Nothing was applied; try again with the current content.'
)
throw new Error('The editor changed. Nothing was applied; try again with the current content.')
}
return state.update({
changes: { from: 0, to: state.doc.length, insert: content },
@@ -1,99 +0,0 @@
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)
})
@@ -1,30 +0,0 @@
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
}
@@ -73,7 +73,6 @@
.cm-editor .editor-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
box-sizing: border-box;
block-size: calc(var(--space-4) + var(--space-2));
@@ -84,3 +83,33 @@
line-height: 1;
white-space: nowrap;
}
.cm-editor .editor-status[data-vim-dialog='true'] {
block-size: auto;
min-block-size: calc(var(--space-4) + var(--space-2));
max-block-size: 30dvh;
overflow: auto;
}
.cm-editor .editor-status .editor-vim-status {
flex: 1;
min-width: 0;
padding: 0;
font: inherit;
white-space: normal;
}
.cm-editor .editor-status-mode {
flex-shrink: 0;
}
.cm-editor .editor-status-content {
margin-inline-start: auto;
min-width: 0;
overflow: hidden;
}
.cm-editor .editor-status[hidden],
.cm-editor .editor-status > [hidden] {
display: none;
}
@@ -2,6 +2,7 @@
import SearchIcon from 'virtual:icons/ph/magnifying-glass'
import CloseIcon from 'virtual:icons/ph/x'
import { fade } from 'svelte/transition'
import { responsiveFly } from '#lib/components/ui/dialog-motion.js'
import { Dialog } from 'bits-ui'
import SearchForm from './SearchForm.svelte'
import { afterNavigate } from '$app/navigation'
@@ -19,18 +20,6 @@
isOpen = false
})
function responsiveFly(node: HTMLElement, { duration = 300 }) {
return {
duration,
css: (t: number) => {
return `
opacity: ${t};
transform: translate(var(--fly-x, 0px), calc((1 - ${t}) * var(--fly-y-offset, 0px)));
`
}
}
}
</script>
<Dialog.Root bind:open={isOpen}>
@@ -0,0 +1,12 @@
export function responsiveFly(node: HTMLElement, { duration = 300 } = {}) {
if (node.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches) {
duration = 0
}
return {
duration,
css: (t: number) => `
opacity: ${t};
transform: translate(var(--fly-x, 0px), calc((1 - ${t}) * var(--fly-y-offset, 0px)));
`
}
}
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { encodeEvent, SSE_HEADERS } from '#lib/server/sse.js'
import { submitUnpublish } from './unpublish-client'
import { getPublicationState, submitPublication } from './publication-client'
import { MarkdownPublicationStatus } from './types.js'
const fetchMock = vi.fn<typeof fetch>()
const encoder = new TextEncoder()
@@ -49,7 +50,7 @@ test('posts the exact file and waits for its own completion, not just admission'
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
const observer = new AbortController()
let settled = false
const pending = submitUnpublish(filePath, observer.signal).then((result) => {
const pending = submitPublication('unpublish', filePath, observer.signal).then((result) => {
settled = true
return result
})
@@ -73,7 +74,7 @@ test('does not adopt a conflicting job or submit again', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ jobId: 'foreign-job' }), { status: 409 })
)
const result = await submitUnpublish(filePath, new AbortController().signal)
const result = await submitPublication('unpublish', filePath, new AbortController().signal)
expect(result).toMatchObject({ state: 'failed', reason: 'busy' })
expect(result?.jobId).toBeUndefined()
expect(fetchMock).toHaveBeenCalledOnce()
@@ -90,7 +91,9 @@ test('preserves serialized cleanup diagnostics without retrying the job', async
})
)
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
await expect(
submitPublication('unpublish', filePath, new AbortController().signal)
).resolves.toMatchObject({
state: 'failed',
reason: 'job',
jobId: 'job-1',
@@ -108,7 +111,9 @@ test('rejects a different workflow rather than claiming Unpublish succeeded', as
})
)
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
await expect(
submitPublication('unpublish', filePath, new AbortController().signal)
).resolves.toMatchObject({
state: 'failed',
reason: 'response',
jobId: 'job-1'
@@ -119,7 +124,9 @@ test('reports an interrupted stream as unknown completion and does not reconnect
const source = stream(encodeEvent('state', { workflowName: 'Unpublish', state: 'running' }))
source.close()
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
await expect(
submitPublication('unpublish', filePath, new AbortController().signal)
).resolves.toMatchObject({
state: 'failed',
reason: 'connection',
jobId: 'job-1'
@@ -129,7 +136,7 @@ test('reports an interrupted stream as unknown completion and does not reconnect
test('reports authentication failure without observing a job', async () => {
fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
const result = await submitUnpublish(filePath, new AbortController().signal)
const result = await submitPublication('unpublish', filePath, new AbortController().signal)
expect(result).toMatchObject({ state: 'failed', reason: 'sign-in' })
expect(result?.jobId).toBeUndefined()
expect(fetchMock).toHaveBeenCalledOnce()
@@ -139,7 +146,9 @@ test('distinguishes authentication loss after admission from a worker failure',
fetchMock
.mockResolvedValueOnce(accepted())
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
await expect(
submitPublication('unpublish', filePath, new AbortController().signal)
).resolves.toMatchObject({
state: 'failed',
reason: 'sign-in',
jobId: 'job-1'
@@ -148,7 +157,9 @@ test('distinguishes authentication loss after admission from a worker failure',
test('reports request diagnostics without treating them as success', async () => {
fetchMock.mockResolvedValue(new Response('queue unavailable', { status: 500 }))
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
await expect(
submitPublication('unpublish', filePath, new AbortController().signal)
).resolves.toMatchObject({
state: 'failed',
reason: 'request',
detail: 'queue unavailable'
@@ -158,7 +169,9 @@ test('reports request diagnostics without treating them as success', async () =>
test('requires a job ID before opening a status stream', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ result: 'ok' })))
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
await expect(
submitPublication('unpublish', filePath, new AbortController().signal)
).resolves.toMatchObject({
state: 'failed',
reason: 'response'
})
@@ -169,10 +182,107 @@ test('detaching stops observation, not the admitted server job', async () => {
const source = stream()
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
const observer = new AbortController()
const pending = submitUnpublish(filePath, observer.signal)
const pending = submitPublication('unpublish', filePath, observer.signal)
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
observer.abort()
await expect(pending).resolves.toBeUndefined()
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(source.cancel).toHaveBeenCalledOnce()
})
test('publishes through the shared transport and waits for Publish completion', async () => {
const source = stream(
encodeEvent('done', {
workflowName: 'Publish',
state: 'completed',
result: 'ok',
errors: null
})
)
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
const observer = new AbortController()
await expect(submitPublication('publish', filePath, observer.signal)).resolves.toEqual({
state: 'completed',
jobId: 'job-1'
})
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/admin/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath }),
signal: observer.signal
})
})
test('reports partial publication as a job failure with the failed piece diagnostics', async () => {
const detail = 'cover upload refused'
const source = stream(
encodeEvent('done', {
workflowName: 'Publish',
state: 'completed',
result: 'partial',
errors: null,
failedPieces: [{ filePath, message: detail }]
})
)
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
await expect(
submitPublication('publish', filePath, new AbortController().signal)
).resolves.toEqual({
state: 'failed',
reason: 'job',
jobId: 'job-1',
detail: `${filePath}: ${detail}`
})
})
test('reads publication state for the exact filename with a cancellable GET', async () => {
const state = {
hasPublishedVersion: true,
markdownPublicationStatus: MarkdownPublicationStatus.MATCHING
}
fetchMock.mockResolvedValue(Response.json(state))
const observer = new AbortController()
await expect(getPublicationState(filePath, observer.signal)).resolves.toEqual(state)
expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
`/api/admin/publish?${new URLSearchParams({ filePath })}`,
{ signal: observer.signal }
)
})
test.each([
{ name: 'HTTP failure', response: () => new Response('unavailable', { status: 500 }) },
{ name: 'sign-in HTML', response: () => new Response('<html>Sign in</html>') },
{ name: 'invalid JSON', response: () => new Response('{') },
{ name: 'missing state', response: () => Response.json({}) },
{
name: 'invalid boolean',
response: () =>
Response.json({ hasPublishedVersion: 'yes', markdownPublicationStatus: 'matching' })
},
{
name: 'unknown status',
response: () =>
Response.json({ hasPublishedVersion: true, markdownPublicationStatus: 'pending' })
}
])(
'a state read throws on $name without retrying or submitting an action',
async ({ response }) => {
fetchMock.mockResolvedValue(response())
await expect(getPublicationState(filePath, new AbortController().signal)).rejects.toThrow()
expect(fetchMock).toHaveBeenCalledOnce()
expect(fetchMock.mock.calls[0][1]?.method).toBeUndefined()
}
)
test('a failed or aborted state fetch propagates without retrying an action', async () => {
const error = new DOMException('Aborted', 'AbortError')
fetchMock.mockRejectedValue(error)
await expect(getPublicationState(filePath, new AbortController().signal)).rejects.toBe(error)
expect(fetchMock).toHaveBeenCalledOnce()
})
@@ -1,15 +1,17 @@
import { readJsonEvents } from '#lib/sse.js'
import { MarkdownPublicationStatus, type PiecePublicationState } from './types.js'
export type UnpublishFailureReason =
export type PublicationAction = 'publish' | 'unpublish'
export type PublicationFailureReason =
| 'busy'
| 'sign-in'
| 'request'
| 'connection'
| 'response'
| 'job'
export type UnpublishOutcome =
export type PublicationOutcome =
| { state: 'completed'; jobId: string }
| { state: 'failed'; reason: UnpublishFailureReason; detail: string; jobId?: string }
| { state: 'failed'; reason: PublicationFailureReason; detail: string; jobId?: string }
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
@@ -30,7 +32,53 @@ function messageDetail(value: unknown): string {
return ''
}
function failure(reason: UnpublishFailureReason, detail = '', jobId?: string): UnpublishOutcome {
function publicationFailureDetail(errors: unknown, failedPieces: unknown[]): string {
const details: string[] = []
if (Array.isArray(errors)) {
for (const error of errors) {
const detail = messageDetail(error)
if (detail) details.push(detail)
}
}
for (const piece of failedPieces) {
const detail = messageDetail(piece)
if (!detail) continue
if (isObject(piece) && typeof piece.filePath === 'string') {
details.push(`${piece.filePath}: ${detail}`)
} else {
details.push(detail)
}
}
return details.join('\n')
}
export async function getPublicationState(
filePath: string,
signal: AbortSignal
): Promise<PiecePublicationState> {
const query = new URLSearchParams({ filePath })
const response = await fetch(`/api/admin/publish?${query}`, { signal })
if (!response.ok || response.redirected) {
throw new Error('Failed to read publication state')
}
const state: unknown = await response.json()
if (!isObject(state) || typeof state.hasPublishedVersion !== 'boolean') {
throw new Error('Invalid publication state response')
}
const markdownPublicationStatus = state.markdownPublicationStatus
const validPublicationStatus =
markdownPublicationStatus === MarkdownPublicationStatus.MATCHING ||
markdownPublicationStatus === MarkdownPublicationStatus.CHANGED ||
markdownPublicationStatus === MarkdownPublicationStatus.UNKNOWN
if (!validPublicationStatus) throw new Error('Invalid publication state response')
return { hasPublishedVersion: state.hasPublishedVersion, markdownPublicationStatus }
}
function failure(
reason: PublicationFailureReason,
detail = '',
jobId?: string
): PublicationOutcome {
return { state: 'failed', reason, detail, jobId }
}
@@ -38,10 +86,11 @@ function requiresSignIn(response: Response): boolean {
return response.redirected || response.status === 401 || response.status === 403
}
async function observeUnpublishJob(
async function observePublicationJob(
action: PublicationAction,
jobId: string,
signal: AbortSignal
): Promise<UnpublishOutcome | undefined> {
): Promise<PublicationOutcome | undefined> {
try {
const stream = await fetch(`/api/admin/publish/${encodeURIComponent(jobId)}/stream`, {
headers: { Accept: 'text/event-stream' },
@@ -64,21 +113,22 @@ async function observeUnpublishJob(
if (signal.aborted) return
if (event.type === 'error') return failure('connection', messageDetail(event.data), jobId)
if (event.type !== 'state' && event.type !== 'done') continue
if (!isObject(event.data) || event.data.workflowName !== 'Unpublish') {
if (
!isObject(event.data) ||
event.data.workflowName !== (action === 'publish' ? 'Publish' : 'Unpublish')
) {
return failure('response', '', jobId)
}
if (event.type !== 'done') continue
if (event.data.state === 'completed' && event.data.result === 'ok') {
const failedPieces = Array.isArray(event.data.failedPieces) ? event.data.failedPieces : []
if (
event.data.state === 'completed' &&
event.data.result === 'ok' &&
failedPieces.length === 0
) {
return { state: 'completed', jobId }
}
const details: string[] = []
if (Array.isArray(event.data.errors)) {
for (const error of event.data.errors) {
const detail = messageDetail(error)
if (detail) details.push(detail)
}
}
return failure('job', details.join('\n'), jobId)
return failure('job', publicationFailureDetail(event.data.errors, failedPieces), jobId)
}
if (signal.aborted) return
return failure('connection', '', jobId)
@@ -87,13 +137,14 @@ async function observeUnpublishJob(
}
}
export async function submitUnpublish(
export async function submitPublication(
action: PublicationAction,
filePath: string,
signal: AbortSignal
): Promise<UnpublishOutcome | undefined> {
): Promise<PublicationOutcome | undefined> {
if (signal.aborted) return
try {
const response = await fetch('/api/admin/unpublish', {
const response = await fetch(`/api/admin/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filePath }),
@@ -118,7 +169,7 @@ export async function submitUnpublish(
return failure('response')
}
if (signal.aborted) return
return observeUnpublishJob(accepted.jobId, signal)
return observePublicationJob(action, accepted.jobId, signal)
} catch (error) {
if (!signal.aborted) return failure('request', messageDetail(error))
}
@@ -12,9 +12,10 @@ import {
import { db } from '#lib/server/database/index.js'
import type { AppDatabase } from '@luzzle/web.db'
import { getPieces } from '#lib/server/pieces.js'
import type * as ServerPieces from '#lib/server/pieces.js'
import { getStorage } from '#lib/server/storage.js'
import { actions } from '../../routes/(auth)/admin/piece/[...path]/source/+page.server'
import { getMarkdownPublicationStatus } from './publication.server.js'
import { getMarkdownPublicationStatus, getPiecePublicationState } from './publication.server.js'
import { MarkdownPublicationStatus } from './types.js'
vi.mock('#lib/server/database/index.js', async () => {
@@ -28,7 +29,10 @@ vi.mock('#lib/server/database/index.js', async () => {
if (web.error) throw new Error(`Test web migration failed: ${web.error}`)
return { db }
})
vi.mock('#lib/server/pieces.js', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
vi.mock('#lib/server/pieces.js', async (importOriginal) => ({
...(await importOriginal<typeof ServerPieces>()),
getPieces: vi.fn()
}))
vi.mock('#lib/server/storage.js', () => ({ getStorage: vi.fn() }))
vi.mock('#lib/server/config.js', async () => {
const { makeConfig } = await import('#lib/server/config.fixture.js')
@@ -163,13 +167,17 @@ test('Save creates the archive item/cache before first publication, not a servin
title: 'Saved title',
cover: '.assets/books/cover.png'
})
expect(item.note_markdown).toBe('A paragraph.\n')
expect(item.note_markdown).toBe('A paragraph.')
expect(JSON.parse(item.assets_json_array!)).toEqual(['.assets/books/cover.png'])
expect(cache.content_hash).toBe(digest(savedBytes))
expect(cache.content_hash).not.toBe(digest(raw))
expect(savedBytes).toEqual(Buffer.from(raw))
expect(storage.writeFile).toHaveBeenCalledOnce()
expect(storage.createReadStream).toHaveBeenCalledExactlyOnceWith(file)
expect(await db.selectFrom('web_pieces').selectAll().execute()).toEqual([])
expect(await getPiecePublicationState(file)).toEqual({
hasPublishedVersion: false,
markdownPublicationStatus: MarkdownPublicationStatus.UNKNOWN
})
})
test('Save updates existing item/cache while retaining serving content, hash and timestamp', async () => {
@@ -180,9 +188,10 @@ test('Save updates existing item/cache while retaining serving content, hash and
.where('file_path', '=', file)
.executeTakeFirstOrThrow()
const serving = await published(originalCache.content_hash)
expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe(
MarkdownPublicationStatus.MATCHING
)
expect(await getPiecePublicationState(file)).toEqual({
hasPublishedVersion: true,
markdownPublicationStatus: MarkdownPublicationStatus.MATCHING
})
expect(
await save('---\ntitle: Updated\ncover: .assets/books/new.png\n---\nUpdated body')
@@ -201,9 +210,10 @@ test('Save updates existing item/cache while retaining serving content, hash and
expect(cache.content_hash).toBe(digest(savedBytes))
expect(JSON.parse(item.frontmatter_json).title).toBe('Updated')
expect(JSON.parse(item.assets_json_array!)).toEqual(['.assets/books/new.png'])
expect(await getMarkdownPublicationStatus(file, serving.content_hash)).toBe(
MarkdownPublicationStatus.CHANGED
)
expect(await getPiecePublicationState(file)).toEqual({
hasPublishedVersion: true,
markdownPublicationStatus: MarkdownPublicationStatus.CHANGED
})
expect(
await db
.selectFrom('web_pieces')
+10 -1
View File
@@ -1,5 +1,14 @@
import { db } from '#lib/server/database/index.js'
import { MarkdownPublicationStatus } from './types.js'
import { getWebPiece } from '#lib/server/pieces.js'
import { MarkdownPublicationStatus, type PiecePublicationState } from './types.js'
export async function getPiecePublicationState(file: string): Promise<PiecePublicationState> {
const webPiece = await getWebPiece(db, file)
return {
hasPublishedVersion: Boolean(webPiece),
markdownPublicationStatus: await getMarkdownPublicationStatus(file, webPiece?.content_hash)
}
}
export async function getMarkdownPublicationStatus(
file: string,
+13
View File
@@ -3,3 +3,16 @@ export enum MarkdownPublicationStatus {
CHANGED = 'changed',
UNKNOWN = 'unknown'
}
export type EditorPublicationStatus =
| 'unpublished'
| 'published'
| 'changed'
| 'unknown'
| 'publishing'
| 'unpublishing'
export interface PiecePublicationState {
hasPublishedVersion: boolean
markdownPublicationStatus: MarkdownPublicationStatus
}
-4
View File
@@ -7,7 +7,3 @@ export async function normalizeMarkdown(content: string): Promise<string> {
printWidth: 80
})
}
export function normalizeLineEndings(content: string): string {
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
}
@@ -29,6 +29,7 @@
// Save refreshes preserve the draft; the asset-path key resets it on navigation.
let editorContent = $state(untrack(() => data.content ?? ''))
let isSaving = $state(false)
let saveForm = $state<HTMLFormElement>()
let saveError = $state<string | null>(null)
let saveErrorReason = $state<
'navigation' | 'delete' | 'delete-refresh' | 'sign-in' | 'save' | 'refresh' | null
@@ -78,6 +79,10 @@
}
}
function saveFromVim() {
if (isDirty && !isSaving) saveForm?.requestSubmit()
}
const enhanceSave: SubmitFunction = ({ cancel }) => {
if (isSaving) {
cancel()
@@ -93,8 +98,12 @@
try {
if (data.path !== file) return
if (result.type === 'success') {
const restoreEditorFocus = view?.hasFocus ?? false
await update({ reset: false, navigate: false })
if (!disposed && data.path === file) saveSuccess = true
if (!disposed && data.path === file) {
if (restoreEditorFocus) view?.focus()
saveSuccess = true
}
} else if (result.type === 'failure') {
const failure = result.data as { error?: { message?: string } } | undefined
saveError = failure?.error?.message || 'Failed to save'
@@ -222,7 +231,7 @@
const initialTheme = configuredTheme(isDark ? 'dark' : 'light')
const extensions = [
editorKeybindings(),
editorKeybindings(saveFromVim),
highlightSpecialChars(),
history(),
drawSelection(),
@@ -376,7 +385,7 @@
<Button variant="error" onclick={() => (showDeleteDialog = true)}>Delete</Button>
{/if}
{#if data.exists && !data.isBinary}
<form method="POST" action="?/save" use:enhance={enhanceSave}>
<form bind:this={saveForm} method="POST" action="?/save" use:enhance={enhanceSave}>
<input type="hidden" name="content" value={editorContent} />
<Button type="submit" disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
@@ -1,8 +1,9 @@
import { error, fail, redirect } from '@sveltejs/kit'
import type { Actions, PageServerLoad } from './$types'
import { getPieces, getWebPiece } from '#lib/server/pieces.js'
import { getPieces } from '#lib/server/pieces.js'
import { getStorage } from '#lib/server/storage.js'
import { db } from '#lib/server/database/index.js'
import { getPiecePublicationState } from '#lib/pieces/publication.server.js'
import path from 'path'
import { config } from '#lib/server/config.js'
import {
@@ -11,65 +12,11 @@ import {
makePieceMarkdown,
type PieceFrontmatter,
type PieceMarkdown,
type Piece,
filterFrontmatterFields,
resolveFieldPaths,
getFrontmatterValue,
savePieceAsset
} from '@luzzle/core'
import { Readable } from 'stream'
import { normalizeLineEndings, normalizeMarkdown } from '#lib/server/markdown.js'
async function resolveAssetUrls(
piece: Piece<PieceFrontmatter>,
markdown: PieceMarkdown<PieceFrontmatter>,
content: string,
assetSchemaPaths: string[]
) {
let updated = markdown
const storage = getStorage()
for (const schemaPath of assetSchemaPaths) {
const instancePaths = resolveFieldPaths(piece.fields, updated.frontmatter, schemaPath)
for (const instancePath of instancePaths) {
const value = getFrontmatterValue(updated.frontmatter, instancePath)
if (typeof value !== 'string' || !/^https?:\/\//i.test(value)) continue
try {
const parts = instancePath.split('.')
let fieldName = 'attachment'
for (let i = parts.length - 1; i >= 0; i--) {
if (isNaN(parseInt(parts[i], 10))) {
fieldName = parts[i]
break
}
}
const relativePath = await savePieceAsset(markdown.filePath, value, storage, {
name: fieldName
})
updated = await piece.setField(updated, instancePath, relativePath)
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
console.error(`Failed to download and rename URL asset for ${instancePath}:`, e)
return {
error: fail(400, {
error: {
message: `Failed to download URL for '${instancePath}' (${message}). Remove the URL and use the form editor to upload the file instead.`
},
rawContent: content,
fields: undefined,
note: undefined
})
}
}
}
}
return { markdown: updated }
}
import { normalizeMarkdown } from '#lib/server/markdown.js'
export const load: PageServerLoad = async ({ params }) => {
const file = params.path
@@ -88,9 +35,9 @@ export const load: PageServerLoad = async ({ params }) => {
}
const storage = getStorage()
const [rawContent, webPiece] = await Promise.all([
const [rawContent, publicationState] = await Promise.all([
storage.readFile(file, 'text') as Promise<string>,
getWebPiece(db, file)
getPiecePublicationState(file)
])
const assetPaths = filterFrontmatterFields(piece.fields, (f) => f.format === 'asset')
@@ -102,23 +49,38 @@ export const load: PageServerLoad = async ({ params }) => {
schema: piece.schema,
canGenerate: config.ai !== undefined,
assetFields: assetPaths,
hasPublishedVersion: Boolean(webPiece)
...publicationState
}
}
export const actions = {
format: async (event) => {
const formData = await event.request.formData()
const content = formData.get('content')
if (typeof content !== 'string') {
return fail(400, { error: { message: 'content is required' } })
}
try {
return { formattedContent: await normalizeMarkdown(content) }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
return fail(400, { error: { message } })
}
},
save: async (event) => {
const file = event.params.path
const pieces = getPieces()
const type = pieces.parseFilename(file).type
const formData = await event.request.formData()
const content = formData.get('content')?.toString()
const content = formData.get('content')
if (!type) {
return error(404, `piece type does not exist`)
}
if (!content) {
if (typeof content !== 'string' || !content) {
return fail(400, { error: { message: 'content is required' } })
}
@@ -126,18 +88,13 @@ export const actions = {
let markdown: PieceMarkdown<PieceFrontmatter>
try {
const normalized = normalizeLineEndings(content)
const data = await extractFullMarkdown(normalized)
const note = await normalizeMarkdown(data.markdown)
markdown = makePieceMarkdown(file, type, note, data.frontmatter as PieceFrontmatter)
const assetPaths = filterFrontmatterFields(piece.fields, (f) => f.format === 'asset')
if (assetPaths.length > 0) {
const result = await resolveAssetUrls(piece, markdown, content, assetPaths)
if ('error' in result) return result.error
markdown = result.markdown
const data = await extractFullMarkdown(content)
markdown = makePieceMarkdown(file, type, data.markdown, data.frontmatter as PieceFrontmatter)
const validated = piece.validate(markdown)
if (!validated.isValid) {
throw new Error(validated.errors.join('\n'))
}
await piece.write(markdown)
await getStorage().writeFile(file, content)
} catch (e: unknown) {
const error = e instanceof Error ? e : new Error(String(e))
console.error('Save raw action error:', error)
@@ -1,48 +1,51 @@
<script lang="ts">
import { deserialize, enhance, type SubmitFunction } from '$app/forms'
import MarkdownEditor from '#lib/components/editor/MarkdownEditor.svelte'
import {
reconcileSave,
reconcileSourceRefresh,
type SaveSnapshot
} from '#lib/components/editor/save-state.js'
import Button from '#lib/components/ui/Button.svelte'
import PieceActions from '#lib/components/editor/PieceActions.svelte'
import PieceToolbar from '#lib/components/editor/PieceToolbar.svelte'
import { submitUnpublish, type UnpublishOutcome } from '#lib/pieces/unpublish-client.js'
import { editorHref, publishQuery } from '#lib/pieces/navigation.js'
import PublicationStatus from '#lib/components/editor/PublicationStatus.svelte'
import {
submitPublication,
getPublicationState,
type PublicationAction,
type PublicationOutcome
} from '#lib/pieces/publication-client.js'
import { editorHref } from '#lib/pieces/navigation.js'
import {
MarkdownPublicationStatus,
type PiecePublicationState,
type EditorPublicationStatus
} from '#lib/pieces/types.js'
import GenerationDialog from '#lib/components/editor/GenerationDialog.svelte'
import type { PageProps } from './$types'
import { Dialog } from 'bits-ui'
import { fade, fly } from 'svelte/transition'
import { navigating, page } from '$app/state'
import { beforeNavigate, goto, refreshAll } from '$app/navigation'
import { resolve } from '$app/paths'
import { onMount, onDestroy, tick, untrack } from 'svelte'
import { beforeNavigate, goto } from '$app/navigation'
import { onMount, onDestroy, untrack } from 'svelte'
let { data, form }: PageProps = $props()
const saveFormId = $props.id()
let saveForm = $state<HTMLFormElement>()
let targetUrl = $state<string | null>(null)
let showWarningDialog = $state(false)
let bypassWarning = $state(false)
let isSaving = $state(false)
let editorErrorReason = $state<
| 'sign-in'
| 'save'
| 'refresh'
| 'navigation'
| 'generation-url'
| 'publish'
| 'delete-refresh'
| null
'sign-in' | 'save' | 'format' | 'navigation' | 'generation-url' | 'delete-refresh' | null
>(null)
let editorErrorDetail = $state('')
let disposed = false
let activeUnpublish: AbortController | undefined
let activePublication: AbortController | undefined
let publicationRead: AbortController | undefined
let activeFormat = $state<AbortController>()
onDestroy(() => {
disposed = true
activeUnpublish?.abort()
activePublication?.abort()
publicationRead?.abort()
activeFormat?.abort()
})
beforeNavigate((navigation) => {
@@ -87,9 +90,21 @@
let dialog: HTMLDialogElement
let editorRef = $state<MarkdownEditor>()
let isUnpublishing = $state(false)
let unpublishOutcome = $state<UnpublishOutcome | null>(null)
let unpublishRefreshFailed = $state(false)
let vimEnabled = $state(false)
let publicationAction = $state<PublicationAction>('publish')
let publicationRunning = $state(false)
let publicationOutcome = $state<PublicationOutcome | null>(null)
let publicationRefreshFailed = $state(false)
let publicationState = $state<PiecePublicationState>(
untrack(() => ({
hasPublishedVersion: data.hasPublishedVersion,
markdownPublicationStatus: data.markdownPublicationStatus
}))
)
const hasPublishedVersion = $derived(publicationState.hasPublishedVersion)
const markdownPublicationStatus = $derived(publicationState.markdownPublicationStatus)
const isPublishing = $derived(publicationRunning && publicationAction === 'publish')
const isUnpublishing = $derived(publicationRunning && publicationAction === 'unpublish')
function initialRawContent(): string {
if (form && 'rawContent' in form) {
@@ -100,49 +115,47 @@
}
let rawContent = $state(initialRawContent())
let loadedSource = untrack(() => ({ file: data.file, content: data.rawContent ?? '' }))
let activeSave: SaveSnapshot | undefined
let savedSource = $state(untrack(() => ({ file: data.file, content: data.rawContent ?? '' })))
let activeSave: { file: string; content: string } | undefined
function saveFromVim() {
if (isDirty && !isSaving) saveForm?.requestSubmit()
}
const enhanceSave: SubmitFunction = ({ formData, cancel }) => {
if (isSaving) {
cancel()
return
}
const submitted = { file: data.file, content: String(formData.get('content') ?? '') }
const submitted = { file: data.file, content: rawContent }
formData.set('content', submitted.content)
activeSave = submitted
isSaving = true
form = null
editorErrorReason = null
editorErrorDetail = ''
const isCurrentSave = () =>
!disposed && activeSave === submitted && data.file === submitted.file
return async ({ result, update }) => {
return async ({ result }) => {
if (!isCurrentSave()) return
try {
if (result.type === 'redirect') {
if (result.type === 'success') {
savedSource = submitted
await refreshPublicationState()
} else if (result.type === 'failure') {
form = result.data as PageProps['form']
} else if (result.type === 'redirect') {
const destination = new URL(result.location, page.url.href)
if (destination.origin === page.url.origin && destination.pathname === '/signin') {
editorErrorReason = 'sign-in'
} else {
await update()
editorErrorReason = 'save'
}
return
}
if (result.type === 'error') {
} else {
editorErrorReason = 'save'
return
}
await update({ reset: false, navigate: 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'
}
if (isCurrentSave()) editorErrorReason = 'save'
} finally {
if (isCurrentSave()) {
activeSave = undefined
@@ -153,59 +166,148 @@
}
}
const hasChanges = $derived(rawContent !== (data.rawContent ?? ''))
const isDirty = $derived(!!form?.error || hasChanges)
const unpublishDisabled = $derived(
isDirty || isSaving || isUnpublishing || !data.hasPublishedVersion
)
const sourceUrl = $derived(editorHref(data.file))
async function handleFormat() {
if (isSaving || activeFormat) return
const file = data.file
const source = rawContent
const controller = new AbortController()
activeFormat = controller
editorErrorReason = null
editorErrorDetail = ''
const isCurrent = () => !disposed && activeFormat === controller && data.file === file
try {
const body = new FormData()
body.set('content', source)
const response = await fetch(`${sourceUrl}?/format`, {
method: 'POST',
headers: { accept: 'application/json', 'x-sveltekit-action': 'true' },
body,
signal: controller.signal
})
if (!isCurrent()) return
if (response.redirected || response.status === 401 || response.status === 403) {
editorErrorReason = 'sign-in'
return
}
const result = deserialize(await response.text())
if (!isCurrent()) return
if (result.type !== 'success' && result.type !== 'failure') {
editorErrorReason = 'format'
return
}
const formatted = result.data as
| {
formattedContent?: string
error?: { message?: string }
}
| undefined
if (result.type !== 'success' || typeof formatted?.formattedContent !== 'string') {
editorErrorReason = 'format'
editorErrorDetail = formatted?.error?.message ?? ''
return
}
if (formatted.formattedContent !== source) {
editorRef?.replaceContent(formatted.formattedContent, source)
}
editorRef?.focus()
} catch (error) {
if (isCurrent()) {
editorErrorReason = 'format'
editorErrorDetail = error instanceof Error ? error.message : String(error)
}
} finally {
if (isCurrent()) activeFormat = undefined
}
}
async function handleUnpublish() {
if (unpublishDisabled) return
const hasChanges = $derived(rawContent !== savedSource.content)
const isDirty = $derived(!!form?.error || hasChanges)
const publishDisabled = $derived(isDirty || isSaving || publicationRunning)
const unpublishDisabled = $derived(publishDisabled || !hasPublishedVersion)
const sourceUrl = $derived(editorHref(data.file))
const publicationStatus = $derived.by((): EditorPublicationStatus => {
if (isPublishing) return 'publishing'
if (isUnpublishing) return 'unpublishing'
if (publicationRefreshFailed) return 'unknown'
if (!hasPublishedVersion) return 'unpublished'
if (isDirty || isSaving || markdownPublicationStatus === MarkdownPublicationStatus.CHANGED)
return 'changed'
if (markdownPublicationStatus === MarkdownPublicationStatus.MATCHING) return 'published'
return 'unknown'
})
async function refreshPublicationState() {
publicationRead?.abort()
const reader = new AbortController()
const file = data.file
publicationRead = reader
const isCurrent = () => !disposed && publicationRead === reader && data.file === file
try {
const state = await getPublicationState(file, reader.signal)
if (isCurrent()) {
publicationState = state
publicationRefreshFailed = false
}
} catch {
if (isCurrent()) publicationRefreshFailed = true
} finally {
if (isCurrent()) publicationRead = undefined
}
}
async function handlePublication(action: PublicationAction) {
if (action === 'publish' && publishDisabled) return
if (action === 'unpublish' && unpublishDisabled) return
const file = data.file
const observer = new AbortController()
activeUnpublish = observer
isUnpublishing = true
unpublishOutcome = null
unpublishRefreshFailed = false
const isCurrent = () => !disposed && activeUnpublish === observer && data.file === file
activePublication = observer
publicationAction = action
publicationRunning = true
publicationOutcome = null
publicationRefreshFailed = false
const isCurrent = () => !disposed && activePublication === observer && data.file === file
try {
const result = await submitUnpublish(file, observer.signal)
const result = await submitPublication(action, file, observer.signal)
if (!isCurrent() || !result) return
unpublishOutcome = result
publicationOutcome = result
if (!result.jobId) return
if (result.state === 'failed' && result.reason === 'sign-in') return
if (navigating.to) return
try {
await refreshAll()
} catch {
if (isCurrent()) unpublishRefreshFailed = true
}
await refreshPublicationState()
} finally {
if (isCurrent()) {
activeUnpublish = undefined
isUnpublishing = false
activePublication = undefined
publicationRunning = false
}
}
}
$effect(() => {
const refreshed = { file: data.file, content: data.rawContent ?? '' }
const file = data.file
untrack(() => {
const changedFile = refreshed.file !== loadedSource.file
rawContent = reconcileSourceRefresh(rawContent, loadedSource, refreshed, !!activeSave)
loadedSource = refreshed
if (!changedFile) return
if (file === savedSource.file) return
savedSource = { file, content: data.rawContent ?? '' }
rawContent = savedSource.content
form = null
activeFormat?.abort()
activeFormat = undefined
attachDialogOpen = false
resetAttachState()
publicationRead?.abort()
publicationRead = undefined
publicationState = {
hasPublishedVersion: data.hasPublishedVersion,
markdownPublicationStatus: data.markdownPublicationStatus
}
activeSave = undefined
isSaving = false
activeUnpublish?.abort()
activeUnpublish = undefined
isUnpublishing = false
unpublishOutcome = null
unpublishRefreshFailed = false
activePublication?.abort()
activePublication = undefined
publicationRunning = false
publicationAction = 'publish'
publicationOutcome = null
publicationRefreshFailed = false
bypassWarning = false
})
})
@@ -273,8 +375,8 @@
if (pathname && pathname !== '/') {
customName = getBasenameWithoutExtension(pathname)
}
} catch (_) {
// Invalid URL or incomplete, ignore
} catch {
return
}
}
})
@@ -286,8 +388,8 @@
try {
new URL(selectedText)
isUrl = true
} catch (_) {
// Ignore invalid URL formatting
} catch {
isUrl = false
}
}
@@ -328,6 +430,9 @@
}
isUploading = true
const file = data.file
const editor = editorRef
const isCurrent = () => !disposed && data.file === file && editorRef === editor
const formData = new FormData()
formData.append('mode', activeTab)
@@ -343,6 +448,7 @@
try {
const response = await fetch(`${sourceUrl}?/attach`, { method: 'POST', body: formData })
const result = deserialize(await response.text())
if (!isCurrent()) return
if (result.type === 'success' || result.type === 'failure') {
const resData = result.data as { error?: { message?: string }; path?: string } | undefined
@@ -358,9 +464,9 @@
uploadErrorReason = 'failed'
}
} catch (err) {
uploadErrorDetail = err instanceof Error ? err.message : String(err)
if (isCurrent()) uploadErrorDetail = err instanceof Error ? err.message : String(err)
} finally {
isUploading = false
if (isCurrent()) isUploading = false
}
}
</script>
@@ -610,7 +716,14 @@
</Dialog.Root>
<div class="piece-page">
<form id={saveFormId} hidden method="post" action="{sourceUrl}?/save" use:enhance={enhanceSave}>
<form
id={saveFormId}
bind:this={saveForm}
hidden
method="post"
action="{sourceUrl}?/save"
use:enhance={enhanceSave}
>
<input type="hidden" name="content" value={rawContent} />
</form>
{#if editorErrorReason || editorErrorDetail || form?.error}
@@ -620,14 +733,13 @@
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 if editorErrorReason === 'format'}
Unable to format. Your edits are still here.
{#if editorErrorDetail}<pre class="error-details">{editorErrorDetail}</pre>{/if}
{:else if editorErrorReason === 'navigation'}
Unable to leave this page. Your edits are still here.
{:else if editorErrorReason === 'generation-url'}
Unable to clear the generation link from the URL.
{:else if editorErrorReason === 'publish'}
Unable to open publishing. Your edits are still here.
{:else if editorErrorReason === 'delete-refresh'}
Unable to finish deleting this piece. Please reload to confirm its status.
{:else}
@@ -636,26 +748,38 @@
</div>
{/if}
{#if unpublishOutcome?.state === 'failed' || unpublishRefreshFailed}
{#if publicationOutcome?.state === 'failed' || publicationRefreshFailed}
<div class="banner error-banner" role="alert">
{#if unpublishOutcome?.state === 'failed'}
{#if unpublishOutcome.reason === 'busy'}
{#if publicationOutcome?.state === 'failed'}
{#if publicationOutcome.reason === 'busy'}
Another publication operation is running.
{:else if unpublishOutcome.reason === 'sign-in'}
{:else if publicationOutcome.reason === 'sign-in'}
Please sign in again in another tab. Your edits are still here.
{:else if unpublishOutcome.reason === 'job'}
{#if data.hasPublishedVersion}Unpublish did not complete.
{:else}Unpublished, but cleanup did not complete.{/if}
{:else if unpublishOutcome.reason === 'connection'}
{:else if publicationOutcome.reason === 'job'}
{#if publicationAction === 'unpublish' && !hasPublishedVersion}
Unpublished, but cleanup did not complete.
{:else if publicationAction === 'publish'}
Publish did not complete.
{:else}
Unpublish did not complete.
{/if}
{:else if publicationOutcome.reason === 'connection'}
Unable to confirm completion. The job may still be running.
{:else if unpublishOutcome.reason === 'response'}
Could not read the Unpublish response. The job may have been accepted.
{:else if publicationOutcome.reason === 'response'}
{#if publicationAction === 'publish'}
Could not read the Publish response. The job may have been accepted.
{:else}
Could not read the Unpublish response. The job may have been accepted.
{/if}
{:else if publicationAction === 'publish'}
Unable to confirm the Publish request.
{:else}
Unable to confirm the Unpublish request.
{/if}
{#if unpublishOutcome.detail}<pre class="error-details">{unpublishOutcome.detail}</pre>{/if}
{#if publicationOutcome.detail}<pre
class="error-details">{publicationOutcome.detail}</pre>{/if}
{/if}
{#if unpublishRefreshFailed}
{#if publicationRefreshFailed}
<p>Unable to refresh publication status. Your edits are still here.</p>
{/if}
</div>
@@ -666,36 +790,38 @@
<MarkdownEditor
bind:this={editorRef}
bind:value={rawContent}
onSave={saveFromVim}
file={data.file}
returnTo={page.url.pathname}
assetFields={data.assetFields}
bind:vimEnabled
>
{#snippet toolbar()}
<PieceToolbar
file={data.file}
{saveFormId}
{isDirty}
{isSaving}
{isUnpublishing}
hasPublishedVersion={data.hasPublishedVersion}
{publishDisabled}
{publicationStatus}
onPublish={() => handlePublication('publish')}
>
<PieceActions
file={data.file}
canGenerate={data.canGenerate}
disabled={isSaving}
hasPublishedVersion={data.hasPublishedVersion}
publishDisabled={isDirty || isSaving}
{hasPublishedVersion}
previewDisabled={isDirty || isSaving}
{publishDisabled}
onPublish={() => handlePublication('publish')}
{unpublishDisabled}
onPublish={() => {
const file = data.file
const query = publishQuery(file)
void goto(resolve(`admin/publish?${query}`)).catch(() => {
if (!disposed && data.file === file) {
editorErrorReason = 'publish'
editorErrorDetail = ''
}
})
{vimEnabled}
onVimChange={(enabled) => {
vimEnabled = enabled
}}
onUnpublish={handleUnpublish}
onEditorFocus={() => editorRef?.focus()}
onFormat={handleFormat}
formatDisabled={isSaving || !!activeFormat}
onUnpublish={() => handlePublication('unpublish')}
onDelete={() => dialog.showModal()}
onGenerate={() => {
editorErrorReason = null
@@ -706,6 +832,14 @@
/>
</PieceToolbar>
{/snippet}
{#snippet footer()}
<PublicationStatus
status={publicationStatus}
{hasPublishedVersion}
{isDirty}
{isSaving}
/>
{/snippet}
</MarkdownEditor>
{/key}
</div>
@@ -4,22 +4,22 @@ import {
Piece,
type LuzzleStorage,
type PieceFrontmatterSchema,
savePieceAsset,
setFrontmatterValue,
type PieceFrontmatterSchemaField,
type PieceMarkdown,
type PieceFrontmatter
type PieceFrontmatter,
savePieceAsset
} from '@luzzle/core'
import { getPieces, getWebPiece } from '#lib/server/pieces.js'
import { getPieces } from '#lib/server/pieces.js'
import { getStorage } from '#lib/server/storage.js'
import { actions, load } from './+page.server'
import { config } from '#lib/server/config.js'
import { db } from '#lib/server/database/index.js'
import type { WebPieces } from '@luzzle/web.db'
import * as markdownFormatting from '#lib/server/markdown.js'
import { getPiecePublicationState } from '#lib/pieces/publication.server.js'
import { MarkdownPublicationStatus } from '#lib/pieces/types.js'
vi.mock('#lib/server/pieces.js', () => ({ getPieces: vi.fn(), getWebPiece: vi.fn() }))
vi.mock('#lib/server/pieces.js', () => ({ getPieces: vi.fn() }))
vi.mock('#lib/server/storage.js', () => ({ getStorage: vi.fn() }))
vi.mock('#lib/server/database/index.js', () => ({ db: {} }))
vi.mock('#lib/pieces/publication.server.js', () => ({ getPiecePublicationState: vi.fn() }))
vi.mock('#lib/server/config.js', async () => {
const { makeConfig } = await import('#lib/server/config.fixture.js')
return { config: makeConfig() }
@@ -35,22 +35,27 @@ afterEach(() => {
vi.restoreAllMocks()
})
function setup(content: string, fields: PieceFrontmatterSchemaField[] = []) {
const storage = { delete: vi.fn() }
vi.mocked(getStorage).mockReturnValue(storage as unknown as ReturnType<typeof getStorage>)
const piece = {
type: 'books',
fields,
write: vi.fn().mockResolvedValue(undefined),
syncMarkdown: vi.fn().mockResolvedValue(undefined),
setField: vi.fn(
async (markdown: PieceMarkdown<PieceFrontmatter>, field: string, value: string) => {
const updated = structuredClone(markdown)
setFrontmatterValue(updated.frontmatter, field, value)
return updated
}
)
const file = 'books/example.books.md'
const schema = {
title: 'books',
type: 'object',
properties: { title: { type: 'string' }, cover: { type: 'string' } },
required: ['title'],
additionalProperties: false
} as PieceFrontmatterSchema<PieceFrontmatter>
function setup(content: string) {
const storage = {
writeFile: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
stat: vi.fn(),
delete: vi.fn()
}
vi.mocked(getStorage).mockReturnValue(storage as unknown as ReturnType<typeof getStorage>)
const piece = new Piece('books', storage as unknown as LuzzleStorage, schema)
const validate = vi.spyOn(piece, 'validate')
const write = vi.spyOn(piece, 'write')
const sync = vi.spyOn(piece, 'syncMarkdown').mockResolvedValue(undefined)
vi.mocked(getPieces).mockReturnValue({
parseFilename: vi.fn().mockReturnValue({ type: 'books' }),
getPiece: vi.fn().mockResolvedValue(piece)
@@ -58,308 +63,172 @@ function setup(content: string, fields: PieceFrontmatterSchemaField[] = []) {
const formData = new FormData()
formData.set('content', content)
const event = {
params: { path: 'books/example.books.md' },
params: { path: file },
request: { formData: async () => formData }
} as unknown as Parameters<typeof actions.save>[0]
return { piece, storage, event }
return { piece, validate, write, sync, storage, formData, event }
}
const assets: PieceFrontmatterSchemaField[] = [
{ name: 'cover', type: 'string', format: 'asset' },
{ name: 'images', type: 'array', items: { type: 'string', format: 'asset' } },
{
name: 'editions',
type: 'array',
items: { type: 'object', properties: { cover: { type: 'string', format: 'asset' } } }
}
]
test('Save validates a model but writes the exact submitted source before syncing', async () => {
const content =
'---\r\n# keep this comment\r\ntitle: "Reviewed"\r\ncover: \'https://example.test/cover.png\'\r\n---\r\n# Heading\r\n\r\nA paragraph. \r\n'
const { validate, write, sync, storage, event } = setup(content)
const format = vi.spyOn(markdownFormatting, 'normalizeMarkdown')
test('Save normalizes line endings and markdown and overwrites the existing piece', async () => {
const { piece, event } = setup(
'---\r\ntitle: Reviewed\r\n---\r\n# Heading\r\n\r\nA paragraph.\r\n'
)
expect(await actions.save(event)).toEqual({ success: true })
expect(piece.write).toHaveBeenCalledExactlyOnceWith({
filePath: 'books/example.books.md',
piece: 'books',
frontmatter: { title: 'Reviewed' },
note: '# Heading\n\nA paragraph.\n'
})
expect(piece.syncMarkdown).toHaveBeenCalledExactlyOnceWith(db, piece.write.mock.calls[0][0])
expect(piece.write.mock.invocationCallOrder[0]).toBeLessThan(
piece.syncMarkdown.mock.invocationCallOrder[0]
)
expect(savePieceAsset).not.toHaveBeenCalled()
})
test('Save resolves scalar, array and nested asset URLs while preserving local references', async () => {
const { piece, storage, event } = setup(
`---
title: Reviewed
cover: https://example.test/cover.png
images:
- .assets/local.png
- https://example.test/image.png
editions:
- cover: https://example.test/edition.png
---
Body`,
assets
)
vi.mocked(savePieceAsset)
.mockResolvedValueOnce('.assets/cover.png')
.mockResolvedValueOnce('.assets/images.png')
.mockResolvedValueOnce('.assets/edition.png')
expect(await actions.save(event)).toEqual({ success: true })
expect(vi.mocked(savePieceAsset).mock.calls).toEqual([
['books/example.books.md', 'https://example.test/cover.png', storage, { name: 'cover' }],
['books/example.books.md', 'https://example.test/image.png', storage, { name: 'images' }],
['books/example.books.md', 'https://example.test/edition.png', storage, { name: 'cover' }]
])
expect(piece.write).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
frontmatter: {
title: 'Reviewed',
cover: '.assets/cover.png',
images: ['.assets/local.png', '.assets/images.png'],
editions: [{ cover: '.assets/edition.png' }]
}
})
)
expect(piece.syncMarkdown).toHaveBeenCalledExactlyOnceWith(db, piece.write.mock.calls[0][0])
})
test('Save returns the exact raw draft on malformed frontmatter without writing', async () => {
const rawContent = '---\r\ntitle: [broken\r\n---\r\n untouched '
const { piece, event } = setup(rawContent)
expect(await actions.save(event)).toMatchObject({
status: 400,
data: { rawContent, error: { message: expect.stringContaining('failed to save raw piece:') } }
})
expect(piece.write).not.toHaveBeenCalled()
expect(piece.syncMarkdown).not.toHaveBeenCalled()
})
test('Save preserves the exact draft and stops on a failed URL download', async () => {
const rawContent =
'---\r\ncover: https://example.test/cover.png\r\nimages: [https://example.test/image.png]\r\n---\r\n untouched '
const { piece, storage, event } = setup(rawContent, assets)
vi.mocked(savePieceAsset)
.mockResolvedValueOnce('.assets/cover.png')
.mockRejectedValueOnce(new Error('download failed'))
expect(await actions.save(event)).toMatchObject({
status: 400,
data: {
rawContent,
fields: undefined,
note: undefined,
error: {
message:
"Failed to download URL for 'images.0' (download failed). Remove the URL and use the form editor to upload the file instead."
}
}
})
expect(piece.write).not.toHaveBeenCalled()
expect(piece.syncMarkdown).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
})
test('Save returns the exact draft on write failure and does not delete downloaded assets', async () => {
const rawContent = '---\r\ncover: https://example.test/cover.png\r\n---\r\n untouched '
const { piece, storage, event } = setup(rawContent, assets)
vi.mocked(savePieceAsset).mockResolvedValueOnce('.assets/cover.png')
piece.write.mockRejectedValueOnce(new Error('invalid metadata'))
expect(await actions.save(event)).toMatchObject({
status: 400,
data: {
rawContent,
fields: undefined,
note: undefined,
error: { message: 'failed to save raw piece: invalid metadata' }
}
})
expect(piece.write).toHaveBeenCalledTimes(1)
expect(piece.syncMarkdown).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
})
test('Save waits for sync before reporting success', async () => {
const { piece, event } = setup('---\ntitle: Reviewed\n---\nBody')
let finishSync!: () => void
const started = new Promise<void>((resolve) => {
piece.syncMarkdown.mockImplementationOnce(() => {
resolve()
return new Promise<void>((finish) => {
finishSync = finish
})
})
let startSync!: () => void
const syncStarted = new Promise<void>((resolve) => {
startSync = resolve
})
const syncFinished = new Promise<void>((resolve) => {
finishSync = resolve
})
sync.mockImplementationOnce(() => {
startSync()
return syncFinished
})
let settled = false
const saving = actions.save(event).then((result) => {
settled = true
return result
})
await started
await syncStarted
expect(settled).toBe(false)
expect(storage.writeFile).toHaveBeenCalledExactlyOnceWith(file, content)
finishSync()
expect(await saving).toEqual({ success: true })
expect(validate).toHaveBeenCalledExactlyOnceWith({
filePath: file,
piece: 'books',
frontmatter: { title: 'Reviewed', cover: 'https://example.test/cover.png' },
note: '# Heading\r\n\r\nA paragraph.'
})
expect(storage.writeFile).toHaveBeenCalledExactlyOnceWith(file, content)
expect(sync).toHaveBeenCalledExactlyOnceWith(db, validate.mock.calls[0][0])
expect(write).not.toHaveBeenCalled()
expect(format).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
})
test('Save reports sync failure truthfully without retrying or rolling back the file', async () => {
test.each([
['malformed frontmatter', '---\r\ntitle: [broken\r\n---\r\n untouched '],
['invalid schema', '---\r\ntitle: 42\r\n---\r\n untouched ']
])('Save rejects %s before writing', async (_failure, rawContent) => {
const { storage, sync, event } = setup(rawContent)
expect(await actions.save(event)).toMatchObject({
status: 400,
data: { rawContent, error: { message: expect.stringContaining('failed to save raw piece:') } }
})
expect(storage.writeFile).not.toHaveBeenCalled()
expect(sync).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
})
test('Save preserves the draft on write failure and does not sync or retry', async () => {
const rawContent = '---\r\ntitle: Reviewed\r\n---\r\n untouched '
const { storage, sync, event } = setup(rawContent)
storage.writeFile.mockRejectedValueOnce(new Error('disk full'))
expect(await actions.save(event)).toMatchObject({
status: 400,
data: { rawContent, error: { message: 'failed to save raw piece: disk full' } }
})
expect(storage.writeFile).toHaveBeenCalledExactlyOnceWith(file, rawContent)
expect(sync).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
})
test('Save reports sync failure truthfully without retrying or rolling back the saved file', async () => {
const rawContent = '---\r\ntitle: Reviewed\r\n---\r\nBody'
const { piece, storage, event } = setup(rawContent)
const error = new Error('private database error')
piece.syncMarkdown.mockRejectedValueOnce(error)
const { validate, storage, sync, event } = setup(rawContent)
sync.mockRejectedValueOnce(new Error('private database error'))
expect(await actions.save(event)).toMatchObject({
status: 500,
data: {
rawContent,
fields: undefined,
note: undefined,
error: { message: 'Markdown was saved, but its database index could not be updated.' }
}
})
expect(piece.write).toHaveBeenCalledOnce()
expect(piece.syncMarkdown).toHaveBeenCalledExactlyOnceWith(db, piece.write.mock.calls[0][0])
expect(storage.writeFile).toHaveBeenCalledExactlyOnceWith(file, rawContent)
expect(sync).toHaveBeenCalledExactlyOnceWith(db, validate.mock.calls[0][0])
expect(storage.delete).not.toHaveBeenCalled()
expect(console.error).toHaveBeenCalledWith('Sync after Save failed:', error)
})
test.each(['https://example.test/cover.png', 'HTTPS://example.test/cover.png'])(
'Save converts %s to a local asset before strict validation and writing',
async (url) => {
const { storage, event } = setup(`---\ntitle: Reviewed\ncover: ${url}\n---\n`)
const targetStorage = {
...storage,
makeDirectory: vi.fn(),
writeFile: vi.fn()
}
const schema = {
title: 'books',
type: 'object',
properties: { title: { type: 'string' }, cover: { type: 'string', format: 'asset' } },
required: ['title', 'cover'],
additionalProperties: false
} as PieceFrontmatterSchema<PieceFrontmatter>
const piece = new Piece('books', targetStorage as unknown as LuzzleStorage, schema)
const sync = vi.spyOn(piece, 'syncMarkdown').mockResolvedValue(undefined)
vi.spyOn(getPieces(), 'getPiece').mockResolvedValue(piece)
vi.mocked(savePieceAsset).mockResolvedValue('.assets/books/example.books/cover.png')
test('Format formats the whole buffer including frontmatter without saving, syncing or downloading', async () => {
const content = '---\r\ntitle: "Reviewed"\r\n---\r\nA paragraph.\r\n'
const { storage, write, sync, event } = setup(content)
const formattedContent = '---\ntitle: "Reviewed"\n---\n\nA paragraph.\n'
const format = vi
.spyOn(markdownFormatting, 'normalizeMarkdown')
.mockResolvedValueOnce(formattedContent)
expect(await actions.save(event)).toEqual({ success: true })
expect(savePieceAsset).toHaveBeenCalledExactlyOnceWith('books/example.books.md', url, storage, {
name: 'cover'
})
expect(targetStorage.writeFile).toHaveBeenCalledExactlyOnceWith(
'books/example.books.md',
expect.stringContaining('cover: .assets/books/example.books/cover.png')
)
expect(sync).toHaveBeenCalledExactlyOnceWith(
db,
expect.objectContaining({
frontmatter: { title: 'Reviewed', cover: '.assets/books/example.books/cover.png' }
})
)
}
)
expect(await actions.format(event)).toEqual({ formattedContent })
expect(format).toHaveBeenCalledExactlyOnceWith(content)
expect(getPieces).not.toHaveBeenCalled()
expect(getStorage).not.toHaveBeenCalled()
expect(storage.writeFile).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
expect(sync).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
})
test('Format returns formatter errors without touching the saved file', async () => {
const { storage, sync, event } = setup('---\ntitle: [broken\n---\nBody')
vi.spyOn(markdownFormatting, 'normalizeMarkdown').mockRejectedValueOnce(new Error('invalid YAML'))
expect(await actions.format(event)).toMatchObject({
status: 400,
data: { error: { message: 'invalid YAML' } }
})
expect(storage.writeFile).not.toHaveBeenCalled()
expect(sync).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
})
const file = 'books/example.books.md'
const rawSource = '---\r\ntitle: Saved\r\n---\r\n untouched \r\n'
const publishedPiece: WebPieces = {
id: 'published-id',
key: 'published-key',
title: 'Published title',
slug: 'published-book',
file_path: file,
type: 'books',
json_metadata: '{"title":"Published title"}',
date_added: 0,
content_hash: 'completed-publication-hash',
last_published_at: null
}
function setupLoad() {
const saved = setup('')
const piece = Object.assign(saved.piece, {
get: vi.fn().mockResolvedValue({ filePath: file }),
schema: { type: 'object', properties: {} }
vi.spyOn(saved.piece, 'get').mockResolvedValue({
filePath: file,
piece: 'books',
frontmatter: { title: 'Saved' }
})
const storage = Object.assign(saved.storage, {
readFile: vi.fn().mockResolvedValue(rawSource),
stat: vi.fn(),
writeFile: vi.fn()
saved.storage.readFile.mockResolvedValue(rawSource)
vi.mocked(getPiecePublicationState).mockResolvedValue({
hasPublishedVersion: false,
markdownPublicationStatus: MarkdownPublicationStatus.UNKNOWN
})
vi.mocked(getWebPiece).mockResolvedValue(undefined)
const event = { params: { path: file } } as Parameters<typeof load>[0]
return { piece, storage, event }
return { ...saved, event }
}
test.each([false, true])(
'source loader reports generation availability (configured: %s)',
async (configured) => {
config.ai = configured ? { provider: 'google', api_key: 'private-ai-key' } : undefined
const { event } = setupLoad()
expect(await load(event)).toEqual({
file,
type: 'books',
rawContent: rawSource,
schema: { type: 'object', properties: {} },
canGenerate: configured,
assetFields: [],
hasPublishedVersion: false
})
test('source loader returns raw saved source, schema, generation and publication state without mutations', async () => {
const { piece, storage, write, sync, event } = setupLoad()
const publicationState = {
hasPublishedVersion: true,
markdownPublicationStatus: MarkdownPublicationStatus.CHANGED
}
)
vi.mocked(getPiecePublicationState).mockResolvedValue(publicationState)
test.each([
{ name: 'no serving row', row: undefined },
{
name: 'serving row without a hash or publication time',
row: { ...publishedPiece, content_hash: null }
},
{ name: 'serving row with a publication hash', row: publishedPiece }
])(
'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)
expect(await load(event)).toEqual({
file,
type: 'books',
rawContent: rawSource,
schema: piece.schema,
canGenerate: config.ai !== undefined,
assetFields: [],
hasPublishedVersion: Boolean(row)
})
expect(getWebPiece).toHaveBeenCalledExactlyOnceWith(db, file)
expect(piece.get).toHaveBeenCalledExactlyOnceWith(file)
expect(storage.readFile).toHaveBeenCalledExactlyOnceWith(file, 'text')
expect(storage.stat).not.toHaveBeenCalled()
expect(storage.writeFile).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
expect(piece.write).not.toHaveBeenCalled()
expect(piece.syncMarkdown).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
}
)
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 === 'raw source') storage.readFile.mockRejectedValueOnce(error)
await expect(load(event)).rejects.toBe(error)
expect(storage.stat).not.toHaveBeenCalled()
expect(storage.writeFile).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
expect(piece.write).not.toHaveBeenCalled()
expect(piece.syncMarkdown).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
}
)
expect(await load(event)).toEqual({
file,
type: 'books',
rawContent: rawSource,
schema: piece.schema,
canGenerate: config.ai !== undefined,
assetFields: [],
...publicationState
})
expect(getPiecePublicationState).toHaveBeenCalledExactlyOnceWith(file)
expect(piece.get).toHaveBeenCalledExactlyOnceWith(file)
expect(storage.readFile).toHaveBeenCalledExactlyOnceWith(file, 'text')
expect(storage.stat).not.toHaveBeenCalled()
expect(storage.writeFile).not.toHaveBeenCalled()
expect(storage.delete).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
expect(sync).not.toHaveBeenCalled()
expect(savePieceAsset).not.toHaveBeenCalled()
})
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types'
import { config } from '#lib/server/config.js'
import { getPieces } from '#lib/server/pieces.js'
import { getPiecePublicationState } from '#lib/pieces/publication.server.js'
import { getOpenWorkflow, getOpenWorkflowBackend } from '#lib/server/workflow/index.js'
import {
findInFlightPublishRun,
@@ -67,6 +68,19 @@ async function prepareArchivePublish(
return input
}
export const GET: RequestHandler = async ({ url }) => {
try {
const filePath = validatePiecePath(url.searchParams.get('filePath'))
if (filePath instanceof Response) return filePath
return Response.json(await getPiecePublicationState(filePath), {
headers: { 'Cache-Control': 'no-store' }
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return new Response(`Internal server error: ${message}`, { status: 500 })
}
}
export const POST: RequestHandler = async ({ request }) => {
try {
const body: PublishRequestBody | null = await request.json().catch(() => ({}))
@@ -82,8 +96,12 @@ export const POST: RequestHandler = async ({ request }) => {
return Response.json({ jobId: inFlight.id }, { status: 409 })
}
const input: PublishPayload | PublishPiecePayload | Response =
filePath !== undefined ? { filePath } : await prepareArchivePublish(body)
let input: PublishPayload | PublishPiecePayload | Response
if (filePath !== undefined) {
input = { filePath }
} else {
input = await prepareArchivePublish(body)
}
if (input instanceof Response) return input
const openWorkflow = getOpenWorkflow()
@@ -14,6 +14,7 @@ import { POST as audit } from './audit/+server.js'
import { POST as unpublish } from '../unpublish/+server.js'
const { runWorkflow } = vi.hoisted(() => ({ runWorkflow: vi.fn() }))
vi.mock('#lib/pieces/publication.server.js', () => ({ getPiecePublicationState: vi.fn() }))
vi.mock('#lib/server/workflow/index.js', () => ({
getOpenWorkflow: () => ({ runWorkflow }),
getOpenWorkflowBackend: vi.fn()
@@ -11,6 +11,7 @@ import {
import { POST } from './+server.js'
const { runWorkflow } = vi.hoisted(() => ({ runWorkflow: vi.fn() }))
vi.mock('#lib/pieces/publication.server.js', () => ({ getPiecePublicationState: vi.fn() }))
vi.mock('#lib/server/config.js', () => ({ config: { pieces: [{ type: 'books' }] } }))
vi.mock('#lib/server/pieces.js', () => ({
getPieces: () => ({ parseFilename: Pieces.prototype.parseFilename })
@@ -0,0 +1,72 @@
import { beforeEach, expect, test, vi } from 'vitest'
import { Pieces } from '@luzzle/core'
import { getPiecePublicationState } from '#lib/pieces/publication.server.js'
import { MarkdownPublicationStatus } from '#lib/pieces/types.js'
import { getStorage } from '#lib/server/storage.js'
import { getOpenWorkflow, getOpenWorkflowBackend } from '#lib/server/workflow/index.js'
import { withPublishAdmission } from '#lib/server/workflow/publish.js'
import { GET } from './+server.js'
vi.mock('#lib/server/config.js', () => ({ config: { pieces: [{ type: 'books' }] } }))
vi.mock('#lib/server/pieces.js', () => ({
getPieces: () => ({ parseFilename: Pieces.prototype.parseFilename })
}))
vi.mock('#lib/pieces/publication.server.js', () => ({ getPiecePublicationState: vi.fn() }))
vi.mock('#lib/server/storage.js', () => ({ getStorage: vi.fn() }))
vi.mock('#lib/server/workflow/index.js', () => ({
getOpenWorkflow: vi.fn(),
getOpenWorkflowBackend: vi.fn()
}))
vi.mock('#lib/server/workflow/publish.js', () => ({
findInFlightPublishRun: vi.fn(),
validateAuditForPublish: vi.fn(),
withPublishAdmission: vi.fn()
}))
const filePath = '書 shelves/été?draft=1&tag=#%.books.md'
function event(path: string | null = filePath) {
const url = new URL('http://localhost/api/admin/publish')
if (path !== null) url.search = new URLSearchParams({ filePath: path }).toString()
return { url } as Parameters<typeof GET>[0]
}
function expectReadOnly() {
expect(withPublishAdmission).not.toHaveBeenCalled()
expect(getOpenWorkflow).not.toHaveBeenCalled()
expect(getOpenWorkflowBackend).not.toHaveBeenCalled()
expect(getStorage).not.toHaveBeenCalled()
}
beforeEach(() => vi.resetAllMocks())
test('GET returns the exact shared publication state for the canonical filename without queueing', async () => {
const state = {
hasPublishedVersion: true,
markdownPublicationStatus: MarkdownPublicationStatus.CHANGED
}
vi.mocked(getPiecePublicationState).mockResolvedValue(state)
const response = await GET(event())
expect(response.status).toBe(200)
expect(await response.json()).toEqual(state)
expect(getPiecePublicationState).toHaveBeenCalledExactlyOnceWith(filePath)
expectReadOnly()
})
test.each([null, `../${filePath}`, '/absolute.books.md', 'one.unsupported.md'])(
'GET rejects invalid or unsupported filePath %j without reading or queueing',
async (path) => {
const response = await GET(event(path))
expect(response.status).toBe(400)
expect(getPiecePublicationState).not.toHaveBeenCalled()
expectReadOnly()
}
)
test('GET reports a read failure as HTTP failure without admitting work', async () => {
vi.mocked(getPiecePublicationState).mockRejectedValue(new Error('database unavailable'))
const response = await GET(event())
expect(response.status).toBe(500)
expectReadOnly()
})
+34 -6
View File
@@ -14,10 +14,28 @@ New to the files underneath? Start with [archives](../archive.md) or the
## Using the editor
With [authentication configured](config.md#sign-in), open `/admin` to manage
pieces. Save edits, then click **Saved** to start a preview of that saved file.
Use `/admin/publish` to check changes and publish. Saving is not publishing.
pieces. **Save** validates and writes exactly your editor text, without formatting,
rewriting asset URLs, or reloading the editor. **Saved** means the file is saved, not
published. **Publish** requires a saved editor and publishes only that piece. You can keep
typing and saving while it runs.
Check lists all pending changes. Choose which added or updated pieces to publish.
The editor's bottom status bar shows one short description on the right, such as
**Up to date**, **Unsaved**, **Saved changes**, or **Unpublished**.
This uses the existing saved-Markdown comparison; it does not track changes to
attachment bytes, templates, or the renderer.
**More** groups publication, viewing, editor settings, and destructive actions.
On phones the same **More** button opens a text-only bottom sheet with editor colors and the search dialog's spacing and motion.
**Format** formats the current buffer, including frontmatter. It is undoable and does
not save; use **Save** when ready. Use **Attach** to download or upload assets explicitly.
**Preview** always generates a fresh preview of the saved file; save your edits first.
**View published** opens the existing public version's Overview.
When saved Markdown matches the publication, the toolbar is quieter. **Publish**
remains available in **More** to regenerate unchanged Markdown after attachment,
template, or renderer changes.
Use `/admin/publish` to check and publish archive changes. Check lists all pending changes. Choose which added or updated pieces to publish.
Publishing reads saved files when the worker runs, so edits saved after Check or
while the job is waiting may be included. Unselected edits stay pending.
@@ -27,8 +45,18 @@ Schema changes also apply globally. A renamed file looks like a removal and an
addition: the old public version can disappear before you select its replacement.
Check alone does not change the public site.
Source and text-asset editors start with Standard keybindings. Press
**Ctrl+Alt+V** while focused to toggle Vim mode; the browser remembers your choice.
Use the Save button: Vim save/quit commands do not trigger application actions.
Source and text-asset editors default to Standard keybindings. Toggle **Vim mode**
in the piece editor's **More** menu; the browser remembers your choice.
The piece editor's bottom bar shows the active Vim mode on the left and publication
status on the right.
Vim's `:w` and `:wq` use the same Save action as the button, including its busy and
error handling. Both save and stay in the editor; neither publishes or navigates away.
Vim command prompts, searches and messages temporarily replace the normal bottom-bar
contents instead of adding another row.
Useful Vim commands include `/text` and `?text` for search, `n`/`N` for matches,
`:42` to jump to a line, `:%s/old/new/g` for replacement, `:sort`, `:noh` to clear
search highlighting, and `:undo`/`:redo`. `:marks` and `:reg` show marks and registers.
This is a browser Vim emulation, not a shell or a full file/window manager.
If Vimium steals Escape, exclude the editor URLs in Vimium with an empty
excluded-keys field.
+6 -6
View File
@@ -53,12 +53,12 @@ pnpm --filter @luzzle/web build
pnpm --filter @luzzle/web test:e2e
```
The Playwright suite covers two journeys: sign in to a protected editor,
save an edit and reload it; and search for a piece and open its public page. It
uses one Chromium worker against the production build, with a temporary
archive/config and a real SQLite index, not your demo or live data. It runs no
worker, LSP or external identity/storage services; it does not prove Preview/Publish
execution.
The Playwright suite covers saved editing and Vim controls, retaining drafts when
publication status fails, search, and a few reading regressions. It uses one
Chromium worker against the production build, with a temporary archive/config and
a real SQLite index, not your demo or live data. Preview/Publish job requests are
intercepted. It runs no worker, LSP or external identity/storage services and does
not prove live publication.
GitHub and Woodpecker reuse the app build from `test:templates` in their existing
test jobs. Both use the prebuilt Playwright image used by When/Directory: GHCR on