@michaelthielemann/kestrel 1.7.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/layers/access/server/utils/grant-registry.ts +1 -1
- package/layers/admin/app/components/BlocksBody.vue +2 -1
- package/layers/admin/app/components/CollectionEditor.vue +124 -22
- package/layers/admin/app/components/CollectionList.vue +8 -4
- package/layers/admin/app/components/EditorStatus.vue +11 -0
- package/layers/admin/app/components/SeoFields.vue +1 -0
- package/layers/admin/app/components/SingletonEditor.vue +6 -6
- package/layers/admin/app/composables/useCollectionOps.ts +15 -3
- package/layers/admin/app/composables/useEditForm.ts +6 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +9 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
- package/layers/admin/app/utils/editor-expose.ts +8 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
- package/layers/core/modules/kestrel/index.ts +1 -0
- package/layers/core/server/schema/introspect.ts +1 -1
- package/layers/core/server/schema/sync.ts +1 -1
- package/layers/core/server/utils/crud.ts +5 -4
- package/layers/core/server/utils/kestrel-config.ts +7 -0
- package/layers/fields/server/field-registry/index.ts +2 -1
- package/layers/fields/server/field-registry/sanitize.ts +6 -3
- package/layers/media/app/components/MediaLibrary.vue +4 -1
- package/layers/media/app/components/MediaToolbar.vue +1 -1
- package/layers/media/app/components/field/Media.vue +2 -0
- package/layers/media/app/composables/useMediaLibrary.ts +2 -1
- package/layers/public/app/pages/[...slug].vue +27 -6
- package/layers/public/app/pages/__kestrel/preview.vue +15 -3
- package/layers/public/app/utils/preview-protocol.ts +36 -0
- package/layers/public/server/api/preview.get.ts +28 -0
- package/layers/public/server/api/preview.post.ts +93 -0
- package/layers/public/server/api/publish-status.get.ts +23 -9
- package/layers/public/server/api/publish.post.ts +84 -0
- package/layers/public/server/plugins/zz.publish.ts +12 -3
- package/layers/public/server/tasks/publish/run.ts +2 -1
- package/layers/public/server/utils/preview-token.ts +109 -0
- package/layers/public/server/utils/publish/invalidation.ts +24 -0
- package/layers/public/server/utils/publish/pending.ts +74 -0
- package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
- package/layers/public/server/utils/publish/publish-status.ts +18 -0
- package/layers/public/server/utils/publish/publisher.ts +74 -9
- package/layers/ui/app/components/field/Datetime.vue +2 -0
- package/layers/ui/app/components/field/Repeater.vue +2 -0
- package/layers/ui/app/components/ui/Checkbox.vue +1 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
- package/layers/ui/app/components/ui/Combobox.vue +2 -0
- package/layers/ui/app/components/ui/Field.vue +1 -0
- package/layers/ui/app/components/ui/Fieldset.vue +2 -1
- package/layers/ui/app/components/ui/Icon.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +2 -0
- package/layers/ui/app/components/ui/Richtext.vue +25 -3
- package/layers/ui/app/components/ui/Select.vue +1 -0
- package/layers/ui/app/components/ui/TextInput.vue +1 -0
- package/layers/ui/app/components/ui/Textarea.vue +1 -0
- package/layers/ui/app/components/ui/TimeInput.vue +1 -0
- package/layers/ui/app/i18n/de.ts +10 -0
- package/layers/ui/app/i18n/en.ts +10 -0
- package/package.json +6 -1
- package/scripts/kestrel.mjs +7 -3
- package/scripts/lib/scaffold.mjs +23 -1
- package/templates/starter/app/blocks/Prose.vue +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { inArray, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import type { AnySQLiteTable } from 'drizzle-orm/sqlite-core'
|
|
3
|
+
import { MAX_BULK_IDS } from '../../../core/app/utils/list-limits'
|
|
4
|
+
import { classifyWrite, planInvalidation } from '../utils/publish/invalidation'
|
|
5
|
+
import { staleRoutes } from '../utils/publish/deps'
|
|
6
|
+
import { usePublishRuntime } from '../utils/publish/publish-runtime'
|
|
7
|
+
import { allPublishedRoutes } from '../utils/publish/publisher'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Publish records: write their static files (and everything whose baked output embeds them) to the
|
|
11
|
+
* configured output — the deliberate second half of the split ADR-0008 introduced. Saving persists to the
|
|
12
|
+
* DB and leaves the live site alone; THIS is what changes what visitors see.
|
|
13
|
+
*
|
|
14
|
+
* body: { collection: string, ids?: number[], id?: number }
|
|
15
|
+
* 200: { queued, generates, routes, pruned, drafts }
|
|
16
|
+
*
|
|
17
|
+
* The record's publish INTENT (`status`) is not touched here — that is a field the editor saves like any
|
|
18
|
+
* other, so a page goes live by being published while published. A draft is therefore reported back
|
|
19
|
+
* (`drafts`) rather than silently promoted: it has no public output to write.
|
|
20
|
+
*
|
|
21
|
+
* All-or-nothing on lookup (an unknown id 404s before anything is enqueued), like the bulk write actions.
|
|
22
|
+
*/
|
|
23
|
+
export default defineEventHandler(async (event) => {
|
|
24
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
25
|
+
const body = await readBody(event)
|
|
26
|
+
const name = typeof (body as { collection?: unknown })?.collection === 'string' ? (body as { collection: string }).collection : ''
|
|
27
|
+
const c = getCollection(name)
|
|
28
|
+
if (!c) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${name}` })
|
|
29
|
+
|
|
30
|
+
const raw = (body as { ids?: unknown; id?: unknown })?.ids ?? [(body as { id?: unknown })?.id]
|
|
31
|
+
const ids = parseIdList(raw, MAX_BULK_IDS)
|
|
32
|
+
|
|
33
|
+
const db = useDb()
|
|
34
|
+
const table = c.table as AnySQLiteTable
|
|
35
|
+
const cols = getTableColumns(table) as Record<string, never>
|
|
36
|
+
const rows = db.select().from(table).where(inArray(cols.id, ids)).all() as Record<string, unknown>[]
|
|
37
|
+
const found = new Set(rows.map((r) => r.id as number))
|
|
38
|
+
const missing = ids.filter((id) => !found.has(id))
|
|
39
|
+
if (missing.length) throw createError({ statusCode: 404, statusMessage: `${c.def.name} not found: ${missing.join(', ')}` })
|
|
40
|
+
|
|
41
|
+
// Where the next publish would go, and whether one happens here at all: in dev (or with `output.auto`
|
|
42
|
+
// off) there is no runtime publisher, and saying so is more useful than a queued run that never runs.
|
|
43
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean } }).output
|
|
44
|
+
const runtime = usePublishRuntime()
|
|
45
|
+
const generates = !import.meta.dev && !!output?.auto && !!runtime
|
|
46
|
+
|
|
47
|
+
// The live route set answers "is this tracked route still somebody's page?" — the question a rename
|
|
48
|
+
// leaves open. `failed` means the enumeration was incomplete, and an incomplete read must never drive a
|
|
49
|
+
// delete (the standing rule from the 2026-07-25 audit), so the prune is skipped wholesale.
|
|
50
|
+
const liveNow = runtime ? allPublishedRoutes() : { routes: [], failed: ['*'] }
|
|
51
|
+
const prunable = liveNow.failed.length === 0
|
|
52
|
+
|
|
53
|
+
const primary = primaryLocale()
|
|
54
|
+
const prefixPrimary = prefixPrimaryLocale()
|
|
55
|
+
const routes: string[] = []
|
|
56
|
+
const pruned: string[] = []
|
|
57
|
+
const drafts: number[] = []
|
|
58
|
+
let queued = false
|
|
59
|
+
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
// before === after: nothing about the record is changing, this is a re-render of its current state.
|
|
62
|
+
// A draft classifies as not-published, so `planInvalidation` returns a noop for it — reported below.
|
|
63
|
+
const ev = classifyWrite(c.def, row, row, primary, prefixPrimary)
|
|
64
|
+
const inv = planInvalidation(ev)
|
|
65
|
+
if (inv.type !== 'tags') {
|
|
66
|
+
drafts.push(row.id as number)
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
// Abandoned URLs: routes the publisher baked FROM this record (tagged with its id) that no live page
|
|
70
|
+
// claims any more — in practice the old file a published rename left behind. Same rule a full publish
|
|
71
|
+
// applies globally, scoped to this record's tag; a referrer or listing carrying the tag is a live route
|
|
72
|
+
// and therefore never in this set.
|
|
73
|
+
const tagged = ev.id != null ? (runtime?.deps.routesForTags([`${c.def.name}:${ev.id}`]) ?? []) : []
|
|
74
|
+
const stale = prunable ? staleRoutes(tagged, liveNow.routes) : []
|
|
75
|
+
routes.push(...inv.render)
|
|
76
|
+
pruned.push(...stale)
|
|
77
|
+
if (runtime) {
|
|
78
|
+
runtime.queue.enqueue({ ...inv, prune: [...inv.prune, ...stale] })
|
|
79
|
+
queued = true
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { queued, generates, routes, pruned, drafts }
|
|
84
|
+
})
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createPublishQueue } from '../utils/publish/queue'
|
|
2
|
+
import { setPublishRuntime } from '../utils/publish/publish-runtime'
|
|
2
3
|
import { DepsStore } from '../utils/publish/deps'
|
|
3
4
|
import { createSqlitePersistence } from '../utils/publish/deps-persistence'
|
|
4
5
|
import { outputDriver, publishInvalidation } from '../utils/publish/publisher'
|
|
5
|
-
import { classifyWrite,
|
|
6
|
+
import { classifyWrite, planWrite } from '../utils/publish/invalidation'
|
|
6
7
|
import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -19,7 +20,7 @@ import { registerWriteListener } from '../../../core/server/utils/write-events'
|
|
|
19
20
|
*/
|
|
20
21
|
export default defineNitroPlugin(() => {
|
|
21
22
|
if (import.meta.dev) return
|
|
22
|
-
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
|
+
const output = (useRuntimeConfig().kestrel as { output?: { auto?: boolean; publishOnSave?: boolean; reconcileMinutes?: number; verbose?: boolean } }).output
|
|
23
24
|
if (!output?.auto) return
|
|
24
25
|
|
|
25
26
|
// `output.verbose`: on top of the one-line summary, itemise each incremental republish with a
|
|
@@ -48,8 +49,16 @@ export default defineNitroPlugin(() => {
|
|
|
48
49
|
onError: (error) => console.error('[kestrel] publish run failed:', error),
|
|
49
50
|
})
|
|
50
51
|
|
|
52
|
+
// The explicit publish action (`POST /api/publish`) enqueues through this same queue, so a publish and
|
|
53
|
+
// a write-driven prune are serialized by one single-flight run rather than racing each other.
|
|
54
|
+
setPublishRuntime({ queue, deps })
|
|
55
|
+
|
|
56
|
+
// A save writes the DB, not the site: by default only what a save must still REMOVE from the output (an
|
|
57
|
+
// unpublished or deleted record's page) passes through, and everything renderable waits for an explicit
|
|
58
|
+
// publish — see ADR-0008. `output.publishOnSave` restores the pre-2.0 model, where every write republished.
|
|
59
|
+
const publishOnSave = output.publishOnSave ?? false
|
|
51
60
|
registerWriteListener(({ def, before, after }) => {
|
|
52
|
-
queue.enqueue(
|
|
61
|
+
queue.enqueue(planWrite(classifyWrite(def, before, after, primaryLocale(), prefixPrimaryLocale()), publishOnSave))
|
|
53
62
|
})
|
|
54
63
|
|
|
55
64
|
// Boot publish goes THROUGH the queue (not a direct publishFull) so it shares the single-flight guard:
|
|
@@ -5,7 +5,8 @@ import { createSqlitePersistence } from '../../utils/publish/deps-persistence'
|
|
|
5
5
|
/**
|
|
6
6
|
* The shared publish engine, exposed as a Nitro task. Renders every published page (+ sitemap/robots)
|
|
7
7
|
* via the live server, mirrors `_nuxt`/assets, and always prunes the routes that left the published set
|
|
8
|
-
* (output ≡ DB).
|
|
8
|
+
* (output ≡ DB). Like every full publish it HOLDS BACK routes with unpublished changes (ADR-0008) — a
|
|
9
|
+
* resync must not push work in progress live; those pages go out when they are published.
|
|
9
10
|
*
|
|
10
11
|
* Triggering (Nuxt 4.4 / Nitro 2.13 — there is NO `nuxi task run`):
|
|
11
12
|
* - dev: GET http://localhost:3000/_nitro/tasks/publish:run (the dev-only task route; plumbing smoke test)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import type { H3Event } from 'h3'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Short-lived tickets carrying the editor's UNSAVED form state to a real page render. The editor's own
|
|
6
|
+
* iframe gets unsaved content over postMessage, but an external tab has no parent window to talk to — so
|
|
7
|
+
* instead of saving (which would publish intent the user never expressed) it mints a ticket and opens
|
|
8
|
+
* `<url>?kestrel-preview-token=…`. Nothing is written to the DB; the ticket lives in this process only.
|
|
9
|
+
*
|
|
10
|
+
* The admin session is the actual gate (both endpoints are admin-only under the default-deny API guard).
|
|
11
|
+
* The owner binding on top is NOT per-session isolation today: Kestrel has one shared admin credential and
|
|
12
|
+
* `derivePrincipal` (access layer) never mints more than one admin identity, so every caller that reaches
|
|
13
|
+
* this store has already been narrowed by `requireAdmin` to the same principal, and `previewOwner()`
|
|
14
|
+
* resolves to the literal string `'admin'` every time — `t.owner === owner` cannot currently be false for
|
|
15
|
+
* an admin caller. The binding is kept because it is the seam that makes the check meaningful the moment
|
|
16
|
+
* (if ever) a per-user identity is added upstream; until then it costs nothing and documents the intent.
|
|
17
|
+
* Tickets stay readable until they expire — a preview tab may be reloaded — and the store bounds itself in
|
|
18
|
+
* both directions: a sweep on every mint, and a hard cap that evicts the oldest ticket.
|
|
19
|
+
*
|
|
20
|
+
* In-memory by design: previewing is a per-editor, per-minute affair, and a second server instance would
|
|
21
|
+
* simply re-mint. Nothing durable depends on it.
|
|
22
|
+
*/
|
|
23
|
+
export interface PreviewPayload {
|
|
24
|
+
collection: string
|
|
25
|
+
/** The record being previewed, or null for one that has never been saved. */
|
|
26
|
+
id: number | null
|
|
27
|
+
locale?: string
|
|
28
|
+
/** The editor's populated values — the same tree the live-preview bridge posts into the iframe. */
|
|
29
|
+
values: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PreviewTicket {
|
|
33
|
+
token: string
|
|
34
|
+
expiresAt: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PreviewStore {
|
|
38
|
+
mint: (owner: string, payload: PreviewPayload) => PreviewTicket
|
|
39
|
+
read: (token: string, owner: string) => PreviewPayload | null
|
|
40
|
+
size: () => number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PreviewStoreOptions {
|
|
44
|
+
ttlMs?: number
|
|
45
|
+
max?: number
|
|
46
|
+
now?: () => number
|
|
47
|
+
randomToken?: () => string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const TTL_MS = 10 * 60 * 1000
|
|
51
|
+
const MAX_TICKETS = 32
|
|
52
|
+
|
|
53
|
+
export function createPreviewStore(opts: PreviewStoreOptions = {}): PreviewStore {
|
|
54
|
+
const ttlMs = opts.ttlMs ?? TTL_MS
|
|
55
|
+
const max = opts.max ?? MAX_TICKETS
|
|
56
|
+
const now = opts.now ?? Date.now
|
|
57
|
+
const randomToken = opts.randomToken ?? (() => randomBytes(24).toString('base64url'))
|
|
58
|
+
// Insertion-ordered, which is what makes "evict the oldest" a single `keys().next()`.
|
|
59
|
+
const tickets = new Map<string, { owner: string; payload: PreviewPayload; expiresAt: number }>()
|
|
60
|
+
|
|
61
|
+
function sweep(at: number): void {
|
|
62
|
+
for (const [token, t] of tickets) if (t.expiresAt <= at) tickets.delete(token)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
mint(owner, payload) {
|
|
67
|
+
const at = now()
|
|
68
|
+
sweep(at)
|
|
69
|
+
while (tickets.size >= max) tickets.delete(tickets.keys().next().value as string)
|
|
70
|
+
const token = randomToken()
|
|
71
|
+
const expiresAt = at + ttlMs
|
|
72
|
+
tickets.set(token, { owner, payload, expiresAt })
|
|
73
|
+
return { token, expiresAt }
|
|
74
|
+
},
|
|
75
|
+
read(token, owner) {
|
|
76
|
+
const t = tickets.get(token)
|
|
77
|
+
if (!t) return null
|
|
78
|
+
if (t.expiresAt <= now()) {
|
|
79
|
+
tickets.delete(token)
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
// Bound to the minting owner rather than trusted on token possession alone — inert while every admin
|
|
83
|
+
// caller resolves to the same owner (see the module docstring), but the check a future per-user
|
|
84
|
+
// identity would need is already the one being run, not one that would need to be added later.
|
|
85
|
+
return t.owner === owner ? t.payload : null
|
|
86
|
+
},
|
|
87
|
+
size: () => tickets.size,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Who a ticket belongs to. In production this only ever runs after `requireAdmin(event)` has already
|
|
93
|
+
* thrown for anyone but the admin principal, and `derivePrincipal` (access layer) always gives that
|
|
94
|
+
* principal a fixed `userId: 'admin'` — so the first branch always wins and this always returns the
|
|
95
|
+
* literal `'admin'`. The `role` / `'anonymous'` fallbacks are unreached by any principal shape
|
|
96
|
+
* `derivePrincipal` produces today; kept as a defensive default rather than a non-null assertion, since
|
|
97
|
+
* this function has no way to enforce that invariant itself.
|
|
98
|
+
*/
|
|
99
|
+
export function previewOwner(event: H3Event): string {
|
|
100
|
+
const principal = event.context.principal as { userId?: string | null; role?: string } | undefined
|
|
101
|
+
return principal?.userId ?? principal?.role ?? 'anonymous'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The process-wide store the two `/api/preview` handlers share. */
|
|
105
|
+
let shared: PreviewStore | null = null
|
|
106
|
+
export function usePreviewStore(): PreviewStore {
|
|
107
|
+
shared ??= createPreviewStore()
|
|
108
|
+
return shared
|
|
109
|
+
}
|
|
@@ -84,6 +84,30 @@ export function classifyWrite(def: WriteCollection, before: Row, after: Row, pri
|
|
|
84
84
|
return { collection: def.name, pageLike, status, id, pathChanged, statusChanged, isPublished, wasPublished, selfRoute, oldRoute, groupTag }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* What the write listener enqueues for a content write. `publishOnSave` (`output.publishOnSave`) is the
|
|
89
|
+
* documented way back to the pre-2.0 model where a save WAS a publish: with it on, a write plans exactly
|
|
90
|
+
* what it always did. Off (the default), only removals pass — see `planSaveInvalidation`.
|
|
91
|
+
*/
|
|
92
|
+
export function planWrite(ev: WriteClassification, publishOnSave: boolean): Invalidation {
|
|
93
|
+
return publishOnSave ? planInvalidation(ev) : planSaveInvalidation(ev)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What a plain SAVE may do to the static output. Saving persists to the DB; writing a page's file is the
|
|
98
|
+
* explicit publish action's job (`planInvalidation`, driven by `POST /api/publish`), so a save renders
|
|
99
|
+
* nothing — the live site keeps serving the last published version while the editor works on the next one.
|
|
100
|
+
*
|
|
101
|
+
* REMOVAL is the asymmetry, and it is deliberate: an unpublished or deleted record must not keep a live
|
|
102
|
+
* page, so those two branches act immediately. Their referrer/listing re-renders come along, because a
|
|
103
|
+
* baked link to a page that just went offline is stale the moment it goes — the same "availability" rule
|
|
104
|
+
* `planInvalidation` documents, minus everything that would put NEW content on the live site.
|
|
105
|
+
*/
|
|
106
|
+
export function planSaveInvalidation(ev: WriteClassification): Invalidation {
|
|
107
|
+
const removal = ev.status === 'deleted' || (ev.statusChanged && !ev.isPublished)
|
|
108
|
+
return removal ? planInvalidation(ev) : { type: 'noop' }
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
/**
|
|
88
112
|
* Decide what a write invalidates, per the maintainer-agreed model. Three notions of "dependent":
|
|
89
113
|
* - LISTINGS — pages that QUERY the collection (overviews) → captured as the `<coll>` tag.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Saved but not published": the state a deferred-publish model needs to name. Saving writes the DB,
|
|
3
|
+
* publishing writes the static file, so the two stamps drift apart on purpose — a record edited after its
|
|
4
|
+
* page was last published keeps serving the published version until someone publishes again.
|
|
5
|
+
*
|
|
6
|
+
* The tolerance is not cosmetic: `publish_status.updated_at` is stored in whole seconds while a record's
|
|
7
|
+
* `updatedAt` is milliseconds, so the publish that directly followed a save can carry a stamp up to a
|
|
8
|
+
* second BEHIND it. Without the slack every freshly published page would report unpublished changes.
|
|
9
|
+
*/
|
|
10
|
+
const TOLERANCE_MS = 1000
|
|
11
|
+
|
|
12
|
+
export function hasPendingChanges(savedAtMs: number | null | undefined, publishedAtMs: number | null | undefined, toleranceMs = TOLERANCE_MS): boolean {
|
|
13
|
+
// Never published (no status row) → nothing to protect: there is no older artifact this edit could
|
|
14
|
+
// overtake, so the route is a normal render candidate, not a pending change.
|
|
15
|
+
if (savedAtMs == null || publishedAtMs == null) return false
|
|
16
|
+
return savedAtMs > publishedAtMs + toleranceMs
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The subset of `savedAt` routes whose record moved on after the route's last publish. Pure. */
|
|
20
|
+
export function pendingRoutes(savedAt: Map<string, number>, publishedAt: Map<string, number>): string[] {
|
|
21
|
+
const out: string[] = []
|
|
22
|
+
for (const [route, saved] of savedAt) {
|
|
23
|
+
if (hasPendingChanges(saved, publishedAt.get(route) ?? null)) out.push(route)
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HeldRoutes {
|
|
29
|
+
/** Routes to leave un-rendered: their record is serving an older published version somewhere. */
|
|
30
|
+
hold: Set<string>
|
|
31
|
+
/** Previously-published routes that are still the live artifact of a held record — never prune these. */
|
|
32
|
+
keep: Set<string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Withholding by RECORD rather than by route string, which is what a rename needs. `pendingRoutes` compares
|
|
37
|
+
* a route against its own publish stamp, so a renamed record — whose new route has no stamp at all — falls
|
|
38
|
+
* through the "never published, nothing to protect" carve-out: the unpublished rename gets rendered and the
|
|
39
|
+
* old route, still the live one, is left looking abandoned to the prune. The carve-out is about a FIRST
|
|
40
|
+
* deploy having no older version; a rename has one, at the previous route.
|
|
41
|
+
*
|
|
42
|
+
* So a record's prior published routes are consulted too: if the record has moved on since the newest of
|
|
43
|
+
* them, the new route is held back and those prior routes are protected from the prune. A record with no
|
|
44
|
+
* prior published route keeps the carve-out — otherwise a first deploy would produce an empty site.
|
|
45
|
+
*
|
|
46
|
+
* Pure: `routesForTag` is the deps index's lookup, passed in.
|
|
47
|
+
*/
|
|
48
|
+
export function heldRoutes(
|
|
49
|
+
savedAt: Map<string, number>,
|
|
50
|
+
publishedAt: Map<string, number>,
|
|
51
|
+
recordTag: Map<string, string>,
|
|
52
|
+
routesForTag: (tag: string) => Iterable<string>,
|
|
53
|
+
toleranceMs = TOLERANCE_MS,
|
|
54
|
+
): HeldRoutes {
|
|
55
|
+
const hold = new Set<string>()
|
|
56
|
+
const keep = new Set<string>()
|
|
57
|
+
for (const [route, saved] of savedAt) {
|
|
58
|
+
const own = publishedAt.get(route) ?? null
|
|
59
|
+
if (own != null) {
|
|
60
|
+
if (hasPendingChanges(saved, own, toleranceMs)) hold.add(route)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
// No stamp of its own: either genuinely never published, or published under a previous route.
|
|
64
|
+
const tag = recordTag.get(route)
|
|
65
|
+
if (!tag) continue
|
|
66
|
+
const priors = [...routesForTag(tag)].filter((r) => r !== route && publishedAt.has(r))
|
|
67
|
+
if (!priors.length) continue // first publish of this record — nothing to protect
|
|
68
|
+
const newest = Math.max(...priors.map((r) => publishedAt.get(r)!))
|
|
69
|
+
if (!hasPendingChanges(saved, newest, toleranceMs)) continue
|
|
70
|
+
hold.add(route)
|
|
71
|
+
for (const prior of priors) keep.add(prior)
|
|
72
|
+
}
|
|
73
|
+
return { hold, keep }
|
|
74
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PublishQueue } from './queue'
|
|
2
|
+
import type { DepsStore } from './deps'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The process-wide handle on the running publish machinery. The `zz.publish` Nitro plugin owns the queue
|
|
6
|
+
* and the deps index (it builds the output driver and does the logging), but the explicit publish action —
|
|
7
|
+
* `POST /api/publish` — has to reach both from inside a request: the queue to enqueue the run, the deps
|
|
8
|
+
* index to find the routes this record was baked into. Mirrors core's `write-events` registry: module
|
|
9
|
+
* state, set once at boot.
|
|
10
|
+
*
|
|
11
|
+
* `null` wherever the runtime publisher does not run at all (dev, or `output.auto` off). That is not an
|
|
12
|
+
* error — it is what the endpoint reports back so the editor can say "nothing is generated here" instead
|
|
13
|
+
* of pretending a publish happened.
|
|
14
|
+
*/
|
|
15
|
+
export interface PublishRuntime {
|
|
16
|
+
queue: PublishQueue
|
|
17
|
+
deps: DepsStore
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let runtime: PublishRuntime | null = null
|
|
21
|
+
|
|
22
|
+
export function setPublishRuntime(next: PublishRuntime | null): void {
|
|
23
|
+
runtime = next
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function usePublishRuntime(): PublishRuntime | null {
|
|
27
|
+
return runtime
|
|
28
|
+
}
|
|
@@ -43,6 +43,24 @@ export function renderOutcome(status: number, hasBody: boolean): 'success' | 'er
|
|
|
43
43
|
return 'skip'
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Every route's last successful-or-failed publish time, in ms — the "last published" half of the
|
|
48
|
+
* saved-vs-published comparison a deferred publish needs. Same missing-table resilience as the writers:
|
|
49
|
+
* an unmigrated deploy yields an empty map, which reads as "nothing was ever published here" and so
|
|
50
|
+
* holds nothing back.
|
|
51
|
+
*/
|
|
52
|
+
export function lastPublishedAt(db: BetterSQLite3Database): Map<string, number> {
|
|
53
|
+
const out = new Map<string, number>()
|
|
54
|
+
try {
|
|
55
|
+
for (const row of db.select({ route: publishStatus.route, updatedAt: publishStatus.updatedAt }).from(publishStatus).all()) {
|
|
56
|
+
if (row.updatedAt instanceof Date) out.set(row.route, row.updatedAt.getTime())
|
|
57
|
+
}
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn('[kestrel] could not read publish status:', (error as Error).message)
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
/** Clear a route's status row — its static file was pruned (unpublish / delete / slug change), so it is no
|
|
47
65
|
* longer live. Idempotent; same missing-table resilience as `recordPublishStatus`. */
|
|
48
66
|
export function clearPublishStatus(db: BetterSQLite3Database, route: string): void {
|
|
@@ -12,8 +12,9 @@ import { withResolveScope } from '../../../../core/server/utils/resolve-scope'
|
|
|
12
12
|
import { runAsRenderer } from '../../../../access/server/utils/render-context'
|
|
13
13
|
import { htmlKeyForRoute } from './route-keys'
|
|
14
14
|
import { staleRoutes, type DepsStore } from './deps'
|
|
15
|
-
import { recordPublishStatus, clearPublishStatus, renderOutcome } from './publish-status'
|
|
15
|
+
import { recordPublishStatus, clearPublishStatus, renderOutcome, lastPublishedAt } from './publish-status'
|
|
16
16
|
import { routesToPrune, type Invalidation } from './invalidation'
|
|
17
|
+
import { pendingRoutes, heldRoutes } from './pending'
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The runtime static publisher: renders public routes from the LIVE server (`localFetch`, the same
|
|
@@ -28,6 +29,7 @@ interface OutputRc {
|
|
|
28
29
|
dir: string
|
|
29
30
|
publicDir: string
|
|
30
31
|
auto: boolean
|
|
32
|
+
publishOnSave: boolean
|
|
31
33
|
reconcileMinutes: number
|
|
32
34
|
verbose: boolean
|
|
33
35
|
s3: { bucket: string; region: string; endpoint: string; prefix: string; accessKeyId: string; secretAccessKey: string; sessionToken: string }
|
|
@@ -69,6 +71,11 @@ export async function renderRoute(route: string): Promise<{ body: Buffer | null;
|
|
|
69
71
|
* route set is INCOMPLETE, so it must never be used as the authority for what to delete. */
|
|
70
72
|
export interface PublishedRoutes {
|
|
71
73
|
routes: string[]
|
|
74
|
+
/** Each route's record `updatedAt` in ms — the "last saved" half of the saved-vs-published comparison. */
|
|
75
|
+
savedAt: Map<string, number>
|
|
76
|
+
/** Each route's owning record as its deps tag (`<coll>:<id>`) — route strings move on a rename, records
|
|
77
|
+
* do not, so withholding a renamed page needs the identity behind the route. */
|
|
78
|
+
recordTag: Map<string, string>
|
|
72
79
|
/** Names of collections whose route query threw (drifted schema, locked DB) — routes are missing. */
|
|
73
80
|
failed: string[]
|
|
74
81
|
}
|
|
@@ -82,6 +89,8 @@ export function allPublishedRoutes(): PublishedRoutes {
|
|
|
82
89
|
const prefixPrimary = prefixPrimaryLocale()
|
|
83
90
|
const pub = publicReadableResources()
|
|
84
91
|
const routes = new Set<string>([localePath('/', primary, primary, prefixPrimary)]) // `/` or `/<primary>`
|
|
92
|
+
const savedAt = new Map<string, number>()
|
|
93
|
+
const recordTag = new Map<string, string>()
|
|
85
94
|
const failed: string[] = []
|
|
86
95
|
for (const c of allCollections()) {
|
|
87
96
|
if (!c.def.pageLike || !isPubliclyReadable(c.def.name, pub)) continue
|
|
@@ -94,6 +103,10 @@ export function allPublishedRoutes(): PublishedRoutes {
|
|
|
94
103
|
const proj: Record<string, unknown> = { path: cols.path }
|
|
95
104
|
if (c.def.translatable) proj.locale = cols.locale
|
|
96
105
|
if (c.def.status) proj.status = cols.status
|
|
106
|
+
// `updatedAt` is a system column on every built collection, but this projection also runs against
|
|
107
|
+
// hand-rolled tables in tests — guard it exactly like `locale`, or its absence throws the select.
|
|
108
|
+
if (Object.hasOwn(cols, 'updatedAt')) proj.updatedAt = cols.updatedAt
|
|
109
|
+
if (Object.hasOwn(cols, 'id')) proj.id = cols.id
|
|
97
110
|
let rows: Record<string, unknown>[]
|
|
98
111
|
try { rows = db.select(proj as never).from(c.table).all() as Record<string, unknown>[] }
|
|
99
112
|
catch (error) {
|
|
@@ -106,10 +119,17 @@ export function allPublishedRoutes(): PublishedRoutes {
|
|
|
106
119
|
for (const row of rows) {
|
|
107
120
|
if (c.def.status && row.status !== 'published') continue
|
|
108
121
|
const route = pageRowHref(row, primary, prefixPrimary) // the shared (path, locale) → route rule
|
|
109
|
-
if (route)
|
|
122
|
+
if (!route) continue
|
|
123
|
+
routes.add(route)
|
|
124
|
+
const saved = row.updatedAt
|
|
125
|
+
if (saved instanceof Date) savedAt.set(route, saved.getTime())
|
|
126
|
+
else if (typeof saved === 'number') savedAt.set(route, saved)
|
|
127
|
+
// The same tag the publisher records against a rendered route, so a route can be traced back to its
|
|
128
|
+
// record even after a rename moved the route string.
|
|
129
|
+
if (typeof row.id === 'number') recordTag.set(route, `${c.def.name}:${row.id}`)
|
|
110
130
|
}
|
|
111
131
|
}
|
|
112
|
-
return { routes: [...routes], failed }
|
|
132
|
+
return { routes: [...routes], savedAt, recordTag, failed }
|
|
113
133
|
}
|
|
114
134
|
|
|
115
135
|
/** Render + write the given routes (skips non-200). When `deps` is given, each render is wrapped in a
|
|
@@ -226,7 +246,7 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
|
|
|
226
246
|
// runs late (layer-then-filename order) — finish populating the registry first. Moving the read before
|
|
227
247
|
// an await would silently render an empty registry. See docs/architecture.md → "Server plugins".
|
|
228
248
|
await syncStaticAssets(driver, cfg.publicDir)
|
|
229
|
-
const { routes, failed } = allPublishedRoutes()
|
|
249
|
+
const { routes, savedAt, recordTag, failed } = allPublishedRoutes()
|
|
230
250
|
if (failed.length) {
|
|
231
251
|
// Keep on doubt: with a collection missing from the enumeration, every one of its live pages looks
|
|
232
252
|
// stale, so a prune would wipe it from the output. Rendering still proceeds — a stale extra file is
|
|
@@ -234,12 +254,27 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
|
|
|
234
254
|
console.error(`[kestrel] publish: prune skipped — routes of ${failed.join(', ')} could not be enumerated; existing files kept`)
|
|
235
255
|
}
|
|
236
256
|
|
|
257
|
+
// A full run resynchronizes the output with the DB, so without this it would push every saved-but-
|
|
258
|
+
// unpublished edit live — exactly what deferring the publish exists to prevent. Those routes keep the
|
|
259
|
+
// file their last publish wrote.
|
|
260
|
+
// …unless the consumer opted out of the split (`output.publishOnSave`): there, a save IS a publish, so
|
|
261
|
+
// "saved after the last publish" means a republish is merely in flight, not deliberately withheld.
|
|
262
|
+
// Computed BEFORE the prune, because a held record's live file may sit at a route the DB no longer names
|
|
263
|
+
// (an unpublished rename), and that file is what the site is still serving. Without `deps` there is no
|
|
264
|
+
// way to find those prior routes, so only same-route withholding applies — the pre-rename behaviour.
|
|
265
|
+
const { hold, keep } = cfg.publishOnSave
|
|
266
|
+
? { hold: new Set<string>(), keep: new Set<string>() }
|
|
267
|
+
: heldRoutes(savedAt, lastPublishedAt(useDb()), recordTag, (tag) => deps?.routesForTags([tag]) ?? [])
|
|
268
|
+
if (hold.size) {
|
|
269
|
+
console.info(`[kestrel] publish: ${hold.size} route(s) held at their published version (unpublished changes): ${[...hold].join(', ')}`)
|
|
270
|
+
}
|
|
271
|
+
|
|
237
272
|
// Targeted prune: a route we previously published that is no longer in the published set — a page
|
|
238
273
|
// unpublished, deleted, or whose slug changed — must lose its static file. Safe because it only deletes
|
|
239
274
|
// files this publisher wrote (tracked in deps, durable across restarts). Output ≡ DB; no opt-in toggle.
|
|
240
275
|
let pruned = 0
|
|
241
276
|
if (deps && !failed.length) {
|
|
242
|
-
const stale = staleRoutes(deps.routes(), routes)
|
|
277
|
+
const stale = staleRoutes(deps.routes(), routes).filter((route) => !keep.has(route))
|
|
243
278
|
if (stale.length) {
|
|
244
279
|
await prunePages(stale, driver)
|
|
245
280
|
for (const route of stale) deps.forget(route)
|
|
@@ -247,11 +282,13 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
|
|
|
247
282
|
}
|
|
248
283
|
}
|
|
249
284
|
|
|
285
|
+
const renderRoutes = routes.filter((route) => !hold.has(route))
|
|
286
|
+
|
|
250
287
|
// Reset the discovery accumulator so this full run reconciles ONLY what it actually renders — an earlier
|
|
251
288
|
// incremental (tag) publish also feeds the accumulator, and a variant it recorded whose usage was later
|
|
252
289
|
// removed would otherwise survive and be re-registered here (defeating usage-driven narrowing).
|
|
253
290
|
clearVariants()
|
|
254
|
-
const written = await publishRoutes(
|
|
291
|
+
const written = await publishRoutes(renderRoutes, driver, deps)
|
|
255
292
|
const rendered = written.length
|
|
256
293
|
await publishMeta(driver)
|
|
257
294
|
// Auto-discovery: a FULL render just visited every published route, so the capture accumulator now holds
|
|
@@ -260,8 +297,10 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
|
|
|
260
297
|
// by the un-rendered routes. ONLY narrow when EVERY route rendered: a partial failure leaves the accumulator
|
|
261
298
|
// incomplete, so reconciling would deregister variants still referenced by the stale (kept) published HTML,
|
|
262
299
|
// which a later backfill would then delete out from under the live page. An un-enumerated collection is
|
|
263
|
-
// the same partial-coverage case: its pages were never visited, so their variants are missing too
|
|
264
|
-
|
|
300
|
+
// the same partial-coverage case: its pages were never visited, so their variants are missing too — and
|
|
301
|
+
// so is a route held back at its published version: its live file still references the variants this run
|
|
302
|
+
// never saw.
|
|
303
|
+
if (!failed.length && !hold.size && rendered === renderRoutes.length) saveDiscoveredVariants(useDb())
|
|
265
304
|
return { rendered, pruned }
|
|
266
305
|
}
|
|
267
306
|
|
|
@@ -270,13 +309,39 @@ export async function publishFull(driver: StorageDriver = outputDriver(), deps?:
|
|
|
270
309
|
* (their `<lastmod>` may have changed). */
|
|
271
310
|
export interface PublishResult { rendered: string[]; pruned: string[]; counts: { rendered: number; pruned: number } }
|
|
272
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Drop the routes a tag match dragged in that are holding their published version back. Withholding is a
|
|
314
|
+
* property of the ROUTE, not of the full publish: a route whose record was saved after its last publish
|
|
315
|
+
* serves that published file until someone publishes it. Without this, publishing one record re-renders
|
|
316
|
+
* every route tagged with the collection — and every route reads the `site` singleton — from the live DB,
|
|
317
|
+
* so a routine Publish writes an unrelated record's withheld body to the live site.
|
|
318
|
+
*
|
|
319
|
+
* A route named in `render` is exempt: it IS what the publish was for, and pressing Publish is what clears
|
|
320
|
+
* the withholding. The prune set is untouched — removal has no publish intent left to protect, so an
|
|
321
|
+
* unpublished or deleted record's page still goes at once (ADR-0008).
|
|
322
|
+
*
|
|
323
|
+
* The cost, deliberately accepted: a withheld route keeps the baked links and hreflang of its last
|
|
324
|
+
* publish, so a link to a record that has since been unpublished stays stale until the referrer itself is
|
|
325
|
+
* published. That is the same staleness its body already carries — a frozen route is one publish
|
|
326
|
+
* generation throughout, rather than a mix of two. Rendering a referrer from its published state while
|
|
327
|
+
* resolving fresh links needs a published snapshot per record, which is ADR-0008's "Future".
|
|
328
|
+
*/
|
|
329
|
+
function withheldRemoved(inv: Extract<Invalidation, { type: 'tags' }>, routes: string[]): string[] {
|
|
330
|
+
if (outputConfig().publishOnSave) return routes // that mode never defers a publish in the first place
|
|
331
|
+
// An un-enumerable collection contributes no `savedAt` entry, so its routes are simply not withheld —
|
|
332
|
+
// the same direction publishFull takes, and the non-destructive one (a stale re-render, never a delete).
|
|
333
|
+
const held = new Set(pendingRoutes(allPublishedRoutes().savedAt, lastPublishedAt(useDb())))
|
|
334
|
+
const explicit = new Set(inv.render)
|
|
335
|
+
return routes.filter((route) => explicit.has(route) || !held.has(route))
|
|
336
|
+
}
|
|
337
|
+
|
|
273
338
|
export async function publishInvalidation(inv: Invalidation, driver: StorageDriver = outputDriver(), deps?: DepsStore): Promise<PublishResult> {
|
|
274
339
|
if (inv.type === 'noop') return { rendered: [], pruned: [], counts: { rendered: 0, pruned: 0 } }
|
|
275
340
|
if (inv.type === 'full') {
|
|
276
341
|
const r = await publishFull(driver, deps) // full: counts only (don't list every route)
|
|
277
342
|
return { rendered: [], pruned: [], counts: { rendered: r.rendered, pruned: r.pruned } }
|
|
278
343
|
}
|
|
279
|
-
const routes = [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])]
|
|
344
|
+
const routes = withheldRemoved(inv, [...new Set([...(deps?.routesForTags(inv.tags) ?? []), ...inv.render])])
|
|
280
345
|
const rendered = await publishRoutes(routes, driver, deps)
|
|
281
346
|
let pruned: string[] = []
|
|
282
347
|
// Never prune a route we just wrote live — render wins a coalesced render+prune collision (see routesToPrune).
|
|
@@ -59,10 +59,12 @@ const rangeEnd = computed<string | null>({
|
|
|
59
59
|
<UiFieldset v-else :id="id" :label="name" :error="error" :required="required">
|
|
60
60
|
<template #default="f">
|
|
61
61
|
<template v-if="isTime">
|
|
62
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiTimeInput; no `for`/`id` pair needed, invisible to static analysis -->
|
|
62
63
|
<label class="field-datetime__sub">
|
|
63
64
|
<span class="field-datetime__sublabel">{{ t('field.datetime.range_start') }}</span>
|
|
64
65
|
<UiTimeInput v-model="rangeStart" :disabled="disabled" v-bind="f" />
|
|
65
66
|
</label>
|
|
67
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiTimeInput; no `for`/`id` pair needed, invisible to static analysis -->
|
|
66
68
|
<label class="field-datetime__sub">
|
|
67
69
|
<span class="field-datetime__sublabel">{{ t('field.datetime.range_end') }}</span>
|
|
68
70
|
<UiTimeInput v-model="rangeEnd" :disabled="disabled" v-bind="f" />
|
|
@@ -126,6 +126,7 @@ function insertRowAt(at: number) {
|
|
|
126
126
|
{{ name }}<span v-if="required" aria-hidden="true">*</span>
|
|
127
127
|
</legend>
|
|
128
128
|
|
|
129
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-up/move-down buttons below give the same reorder fully keyboard access -->
|
|
129
130
|
<div ref="rowsEl" class="ui-repeater__rows" @dragleave="onDragLeave">
|
|
130
131
|
<template v-for="(row, i) in rows" :key="keys[i]">
|
|
131
132
|
<div class="ui-repeater__insert-zone">
|
|
@@ -140,6 +141,7 @@ function insertRowAt(at: number) {
|
|
|
140
141
|
</button>
|
|
141
142
|
</div>
|
|
142
143
|
|
|
144
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the move-up/move-down buttons below give the same reorder fully keyboard access -->
|
|
143
145
|
<div
|
|
144
146
|
class="ui-repeater__row-wrap"
|
|
145
147
|
:class="{ 'ui-repeater__row-wrap--over': overIndex === i && dragIndex !== i }"
|
|
@@ -25,6 +25,7 @@ watch(() => props.indeterminate, syncIndeterminate)
|
|
|
25
25
|
</script>
|
|
26
26
|
|
|
27
27
|
<template>
|
|
28
|
+
<!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via UiField's attrs fallthrough (single root element), invisible to static analysis -->
|
|
28
29
|
<input
|
|
29
30
|
ref="input"
|
|
30
31
|
v-model="model"
|
|
@@ -23,6 +23,7 @@ function toggle(value: string, checked: boolean) {
|
|
|
23
23
|
|
|
24
24
|
<template>
|
|
25
25
|
<div class="ui-checkbox-group" role="group">
|
|
26
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
|
|
26
27
|
<label v-for="o in options" :key="o.value" class="ui-checkbox-group__item">
|
|
27
28
|
<UiCheckbox
|
|
28
29
|
:model-value="selected.includes(o.value)"
|
|
@@ -117,7 +117,9 @@ function moveChip(from: number, to: number) {
|
|
|
117
117
|
class="ui-combobox"
|
|
118
118
|
>
|
|
119
119
|
<ComboboxAnchor class="ui-combobox__anchor" :data-multiple="multiple || undefined" :aria-invalid="invalid || undefined">
|
|
120
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions, vuejs-accessibility/no-redundant-roles -- @dragleave is a mouse-only progressive enhancement (the chip-move buttons below give the same reorder fully keyboard access); role="list" is NOT redundant here, `list-style: none` below strips the implicit list semantics in WebKit, see the "WebKit list-semantics fix" test -->
|
|
120
121
|
<ul v-if="multiple && selected.length" ref="chips" class="ui-combobox__chips" role="list" @dragleave="onDragLeave">
|
|
122
|
+
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the chip-move buttons below give the same reorder fully keyboard access -->
|
|
121
123
|
<li
|
|
122
124
|
v-for="(s, i) in selected"
|
|
123
125
|
:key="s.value"
|