[web] simplify editor workflow #202
No files matched your search
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)));
|
||||
`
|
||||
}
|
||||
}
|
||||
+121
-11
@@ -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()
|
||||
})
|
||||
+71
-20
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user