@michaelthielemann/kestrel 1.6.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.
Files changed (79) hide show
  1. package/README.md +8 -4
  2. package/layers/access/server/utils/grant-registry.ts +1 -1
  3. package/layers/access/server/utils/ip-allowlist.ts +4 -1
  4. package/layers/admin/app/components/BlocksBody.vue +2 -1
  5. package/layers/admin/app/components/CollectionEditor.vue +124 -22
  6. package/layers/admin/app/components/CollectionList.vue +8 -4
  7. package/layers/admin/app/components/EditorStatus.vue +11 -0
  8. package/layers/admin/app/components/SeoFields.vue +1 -0
  9. package/layers/admin/app/components/SingletonEditor.vue +6 -6
  10. package/layers/admin/app/composables/useCollectionOps.ts +15 -3
  11. package/layers/admin/app/composables/useEditForm.ts +6 -3
  12. package/layers/admin/app/composables/useListColumns.ts +1 -1
  13. package/layers/admin/app/composables/usePublishStatus.ts +9 -0
  14. package/layers/admin/app/pages/admin/[collection]/[id].vue +7 -7
  15. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +142 -0
  16. package/layers/admin/app/utils/editor-expose.ts +8 -0
  17. package/layers/collections/server/plugins/02.register-relation-populate.ts +6 -3
  18. package/layers/collections/server/utils/populate-relations.ts +18 -5
  19. package/layers/core/modules/auto-discovery/extract-block.ts +5 -2
  20. package/layers/core/modules/kestrel/index.ts +1 -0
  21. package/layers/core/server/api/[collection]/[id].get.ts +4 -1
  22. package/layers/core/server/api/[collection]/index.get.ts +7 -2
  23. package/layers/core/server/api/[collection]/populate-scope.test.ts +119 -0
  24. package/layers/core/server/api/references/broken.get.ts +7 -1
  25. package/layers/core/server/schema/dialect.ts +5 -3
  26. package/layers/core/server/schema/introspect.ts +4 -3
  27. package/layers/core/server/schema/sync.ts +1 -1
  28. package/layers/core/server/utils/crud.ts +11 -10
  29. package/layers/core/server/utils/kestrel-config.ts +10 -3
  30. package/layers/core/server/utils/populate.ts +3 -1
  31. package/layers/fields/server/field-registry/index.ts +2 -1
  32. package/layers/fields/server/field-registry/sanitize.ts +6 -3
  33. package/layers/media/app/components/MediaLibrary.vue +4 -1
  34. package/layers/media/app/components/MediaToolbar.vue +1 -1
  35. package/layers/media/app/components/field/Media.vue +2 -0
  36. package/layers/media/app/composables/useMediaLibrary.ts +2 -1
  37. package/layers/media/server/utils/backfill.ts +24 -12
  38. package/layers/media/server/utils/variants.ts +27 -12
  39. package/layers/public/app/pages/[...slug].vue +35 -7
  40. package/layers/public/app/pages/__kestrel/preview.vue +15 -3
  41. package/layers/public/app/utils/preview-protocol.ts +36 -0
  42. package/layers/public/server/api/preview.get.ts +28 -0
  43. package/layers/public/server/api/preview.post.ts +93 -0
  44. package/layers/public/server/api/publish-status.get.ts +23 -9
  45. package/layers/public/server/api/publish.post.ts +84 -0
  46. package/layers/public/server/api/route.get.ts +26 -6
  47. package/layers/public/server/plugins/02.register-links.ts +4 -4
  48. package/layers/public/server/plugins/zz.publish.ts +13 -4
  49. package/layers/public/server/routes/llms.txt.get.ts +5 -2
  50. package/layers/public/server/routes/sitemap.xml.get.ts +5 -2
  51. package/layers/public/server/tasks/publish/run.ts +2 -1
  52. package/layers/public/server/utils/link-resolve.ts +5 -2
  53. package/layers/public/server/utils/page-resolve.ts +23 -9
  54. package/layers/public/server/utils/preview-token.ts +109 -0
  55. package/layers/public/server/utils/publish/invalidation.ts +43 -4
  56. package/layers/public/server/utils/publish/pending.ts +74 -0
  57. package/layers/public/server/utils/publish/publish-runtime.ts +28 -0
  58. package/layers/public/server/utils/publish/publish-status.ts +18 -0
  59. package/layers/public/server/utils/publish/publisher.ts +74 -9
  60. package/layers/ui/app/components/field/Datetime.vue +2 -0
  61. package/layers/ui/app/components/field/Repeater.vue +2 -0
  62. package/layers/ui/app/components/ui/Checkbox.vue +1 -0
  63. package/layers/ui/app/components/ui/CheckboxGroup.vue +1 -0
  64. package/layers/ui/app/components/ui/Combobox.vue +2 -0
  65. package/layers/ui/app/components/ui/Field.vue +1 -0
  66. package/layers/ui/app/components/ui/Fieldset.vue +2 -1
  67. package/layers/ui/app/components/ui/Icon.vue +2 -2
  68. package/layers/ui/app/components/ui/NumberInput.vue +2 -0
  69. package/layers/ui/app/components/ui/Richtext.vue +25 -3
  70. package/layers/ui/app/components/ui/Select.vue +1 -0
  71. package/layers/ui/app/components/ui/TextInput.vue +1 -0
  72. package/layers/ui/app/components/ui/Textarea.vue +1 -0
  73. package/layers/ui/app/components/ui/TimeInput.vue +1 -0
  74. package/layers/ui/app/i18n/de.ts +10 -0
  75. package/layers/ui/app/i18n/en.ts +10 -0
  76. package/package.json +9 -4
  77. package/scripts/kestrel.mjs +48 -19
  78. package/scripts/lib/scaffold.mjs +39 -8
  79. package/templates/starter/app/blocks/Prose.vue +1 -0
@@ -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) routes.add(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(routes, driver, deps)
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
- if (!failed.length && rendered === routes.length) saveDiscoveredVariants(useDb())
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"
@@ -22,6 +22,7 @@ const slotProps = computed(() => ({
22
22
 
23
23
  <template>
24
24
  <div class="ui-field" :data-invalid="error ? 'true' : undefined">
25
+ <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- the matching control is the default slot's content, invisible to static analysis across the slot boundary -->
25
26
  <label v-if="label" :for="fieldId" class="ui-field__label">
26
27
  {{ label }}<span v-if="required" class="ui-field__required" aria-hidden="true">*</span>
27
28
  </label>
@@ -10,7 +10,8 @@ const props = defineProps<{
10
10
  id?: string
11
11
  }>()
12
12
 
13
- const { fieldId, hintId, errId, describedby, ariaInvalid, ariaRequired } = useFieldA11y(props)
13
+ // No `fieldId`: a group has no single control to point a label `for` at (see below).
14
+ const { hintId, errId, describedby, ariaInvalid, ariaRequired } = useFieldA11y(props)
14
15
 
15
16
  // No `id` — a group has no single control to point a label `for` at; the
16
17
  // <legend> labels the group natively. Consumers spread these onto the group root.
@@ -17,7 +17,8 @@ const hasA11yName = computed(() => props.label != null || attrs.role != null ||
17
17
  <template>
18
18
  <!-- v-html body is either a static registry icon or developer-supplied SVG allowlist-sanitised by
19
19
  resolveIconBody → sanitizeIconSvg (no script-capable elements/attributes survive). -->
20
- <svg
20
+ <!-- eslint-disable-next-line vue/no-v-html -- sanitized via resolveIconBody -> sanitizeIconSvg (allowlist-only tags/attrs) -->
21
+ <svg v-html="body"
21
22
  class="ui-icon"
22
23
  :data-icon="dataIcon"
23
24
  :width="dim"
@@ -31,7 +32,6 @@ const hasA11yName = computed(() => props.label != null || attrs.role != null ||
31
32
  :role="label ? 'img' : undefined"
32
33
  :aria-label="label || undefined"
33
34
  :aria-hidden="hasA11yName ? undefined : 'true'"
34
- v-html="body"
35
35
  />
36
36
  </template>
37
37
 
@@ -55,6 +55,7 @@ function onKeydown(e: KeyboardEvent) {
55
55
 
56
56
  <template>
57
57
  <div v-if="suffix" class="ui-number-wrap" :class="{ 'ui-number-wrap--slim': slim }" :data-disabled="disabled || undefined">
58
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
58
59
  <input
59
60
  v-model="text"
60
61
  type="number"
@@ -70,6 +71,7 @@ function onKeydown(e: KeyboardEvent) {
70
71
  >
71
72
  <span class="ui-number-wrap__suffix" aria-hidden="true">{{ suffix }}</span>
72
73
  </div>
74
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
73
75
  <input
74
76
  v-else
75
77
  v-model="text"
@@ -8,6 +8,7 @@ import { Superscript } from '@tiptap/extension-superscript'
8
8
  import { TextAlign } from '@tiptap/extension-text-align'
9
9
  import RichtextToolbar from './RichtextToolbar.vue'
10
10
  import { RichtextSpanClass, RichtextBlockClass } from '../../utils/richtext-preserve-class'
11
+ import { RICHTEXT_LINK_SCHEME } from '../../../../fields/app/utils/richtext-links'
11
12
 
12
13
  const props = withDefaults(
13
14
  defineProps<{
@@ -24,6 +25,11 @@ const props = withDefaults(
24
25
  )
25
26
  const model = defineModel<string | null>()
26
27
 
28
+ // TipTap emits `update` for non-content changes too — `setEditable`/`setOptions` emit directly, past the
29
+ // transaction pipeline and its docChanged guard — and that echo reaches the edit form as a user edit.
30
+ // Only a changed doc is one, so every emit is checked against the doc it last saw.
31
+ let lastDoc: unknown = null
32
+
27
33
  // StarterKit (v3) already bundles Link + Underline; only the rest are added here.
28
34
  const editor = useEditor({
29
35
  content: model.value ?? '',
@@ -33,7 +39,13 @@ const editor = useEditor({
33
39
  extensions: [
34
40
  StarterKit.configure({
35
41
  heading: { levels: [1, 2, 3, 4, 5, 6] },
36
- link: { openOnClick: false },
42
+ // `protocols`: the Link mark validates every href against its own scheme allowlist, which has no
43
+ // `kestrel:`. Without this it drops the whole anchor when stored content is loaded and makes the
44
+ // toolbar's `setLink` a silent no-op — opening and saving a record would destroy its internal links.
45
+ // `target`/`rel` null: the sanitizer owns that policy — it decorates absolute http(s) links and
46
+ // strips the attributes from every other kind. The mark's own defaults would stamp them on ANY
47
+ // anchor, so a stored internal/relative/mailto link came back changed and read as an unsaved edit.
48
+ link: { openOnClick: false, protocols: [RICHTEXT_LINK_SCHEME], HTMLAttributes: { target: null, rel: null } },
37
49
  }),
38
50
  Highlight,
39
51
  Subscript,
@@ -41,7 +53,12 @@ const editor = useEditor({
41
53
  TextAlign.configure({ types: ['heading', 'paragraph'] }),
42
54
  RichtextSpanClass, RichtextBlockClass, // preserve consumer presentational classes on round-trip
43
55
  ],
56
+ onCreate: ({ editor }) => {
57
+ lastDoc = editor.state.doc
58
+ },
44
59
  onUpdate: ({ editor }) => {
60
+ if (editor.state.doc === lastDoc) return
61
+ lastDoc = editor.state.doc
45
62
  model.value = editor.isEmpty ? '' : editor.getHTML()
46
63
  },
47
64
  })
@@ -53,10 +70,15 @@ watch(model, (value) => {
53
70
  const current = e.isEmpty ? '' : e.getHTML()
54
71
  // `emitUpdate: false`: this value came FROM the model, so re-emitting it would reach the edit form as
55
72
  // a user edit — clearing the redo stack and turning a restored null back into ''.
56
- if (next !== current) e.commands.setContent(next, { emitUpdate: false })
73
+ if (next !== current) {
74
+ e.commands.setContent(next, { emitUpdate: false })
75
+ lastDoc = e.state.doc
76
+ }
57
77
  })
58
78
 
59
- watch(() => props.disabled, (d) => editor.value?.setEditable(!d))
79
+ // `false` — the second arg suppresses the update event; `disabled` tracks the form's `saving` flag, so
80
+ // the post-save toggle would otherwise re-dirty the form that was just rebaselined.
81
+ watch(() => props.disabled, (d) => editor.value?.setEditable(!d, false))
60
82
 
61
83
  // Apply the accessible name + textbox semantics directly to the ProseMirror surface, reactively (the
62
84
  // error-driven describedby/invalid arrive after mount). `editor.view.dom` is the contenteditable element.
@@ -17,6 +17,7 @@ const model = defineModel<string | null>()
17
17
 
18
18
  <template>
19
19
  <span class="ui-select">
20
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
20
21
  <select v-model="model" :disabled="disabled" class="ui-select__field" v-bind="$attrs">
21
22
  <option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
22
23
  <option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
@@ -31,6 +31,7 @@ const inputType = computed(() => (revealable.value && revealed.value ? 'text' :
31
31
 
32
32
  <template>
33
33
  <div class="ui-input-wrap">
34
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via v-bind="$attrs" from the wrapping UiField, invisible to static analysis -->
34
35
  <input
35
36
  v-model="model"
36
37
  :type="inputType"
@@ -12,6 +12,7 @@ const model = defineModel<string | null>()
12
12
  </script>
13
13
 
14
14
  <template>
15
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via Vue's default attrs fallthrough (single root element) from the wrapping UiField -->
15
16
  <textarea
16
17
  v-model="model"
17
18
  :rows="rows"
@@ -5,6 +5,7 @@ const model = defineModel<string | null>()
5
5
  </script>
6
6
 
7
7
  <template>
8
+ <!-- eslint-disable-next-line vuejs-accessibility/form-control-has-label -- id/label land via Vue's default attrs fallthrough (single root element) from the wrapping UiField -->
8
9
  <input v-model="model" type="time" :disabled="disabled" class="ui-time">
9
10
  </template>
10
11
 
@@ -4,6 +4,7 @@ import type { Catalog } from '../composables/useT'
4
4
  export const de: Catalog = {
5
5
  'common.new': '{label} anlegen',
6
6
  'common.save': 'Speichern',
7
+ 'common.publish': 'Veröffentlichen',
7
8
  'common.cancel': 'Abbrechen',
8
9
  'common.create': 'Erstellen',
9
10
  'common.delete': 'Löschen',
@@ -142,6 +143,11 @@ export const de: Catalog = {
142
143
  'pageSettings.statusPublished': 'Veröffentlicht',
143
144
 
144
145
  'editor.openInNewTab': 'In neuem Tab öffnen',
146
+ 'editor.previewUnsaved': 'Ungespeicherte Änderungen in neuem Tab ansehen',
147
+ 'editor.previewFailed': 'Vorschau konnte nicht geöffnet werden.',
148
+ 'editor.publishFailed': 'Veröffentlichen fehlgeschlagen.',
149
+ 'editor.publishNotGenerated': 'Statischer Output ist in dieser Umgebung aus — es wurde nichts geschrieben.',
150
+ 'editor.publishDraft': 'Ein Entwurf hat keine öffentliche Seite. Zum Veröffentlichen den Status auf Veröffentlicht setzen.',
145
151
  // editor status light — LEFT lamp: ONE word + details tooltip (save/publish status)
146
152
  'editorStatus.word.saving': 'Speichert…',
147
153
  'editorStatus.word.unsaved': 'Ungespeichert',
@@ -156,12 +162,16 @@ export const de: Catalog = {
156
162
  // editor status light — RIGHT lamp: ONE word + details tooltip (static-page generation status)
157
163
  'editorStatus.word.live': 'Live',
158
164
  'editorStatus.word.generating': 'Generiert…',
165
+ 'editorStatus.word.notPublished': 'Nicht veröffentlicht',
159
166
  'editorStatus.word.notBuilt': 'Nicht gebaut',
160
167
  'editorStatus.word.notLive': 'Nicht live',
161
168
  'editorStatus.word.error': 'Fehler',
169
+ 'editorStatus.word.outdated': 'Veraltet',
162
170
  'editorStatus.live.liveS3': 'Live — Seite auf S3 abgelegt.',
163
171
  'editorStatus.live.liveLocal': 'Live — Seite in den statischen Output geschrieben.',
164
172
  'editorStatus.live.pending': 'Wird veröffentlicht…',
173
+ 'editorStatus.live.outdated': 'Gespeichert, aber nicht veröffentlicht — live steht noch die zuletzt veröffentlichte Fassung.',
174
+ 'editorStatus.live.notPublished': 'Nie veröffentlicht — mit Veröffentlichen kommt die Seite auf die Live-Site.',
165
175
  'editorStatus.live.notBuilt': 'In dieser Umgebung nicht generiert — die statische Seite wird beim Deploy gebaut.',
166
176
  'editorStatus.live.error': 'Generieren der statischen Seite fehlgeschlagen.',
167
177
  'editorStatus.live.draft': 'Entwurf — nicht generiert. Erscheint erst nach Veröffentlichung auf der Live-Seite.',
@@ -7,6 +7,7 @@ export const en: Catalog = {
7
7
  // common / shared
8
8
  'common.new': 'New {label}',
9
9
  'common.save': 'Save',
10
+ 'common.publish': 'Publish',
10
11
  'common.cancel': 'Cancel',
11
12
  'common.create': 'Create',
12
13
  'common.delete': 'Delete',
@@ -154,6 +155,11 @@ export const en: Catalog = {
154
155
 
155
156
  // editor action bar — live preview + status "Ampel"
156
157
  'editor.openInNewTab': 'Open in new tab',
158
+ 'editor.previewUnsaved': 'Preview unsaved changes in a new tab',
159
+ 'editor.previewFailed': 'Could not open the preview.',
160
+ 'editor.publishFailed': 'Publishing failed.',
161
+ 'editor.publishNotGenerated': 'Static output is off in this environment — nothing was written.',
162
+ 'editor.publishDraft': 'A draft has no public page. Publish it by setting its status to Published.',
157
163
  // editor Ampel — LEFT lamp: one WORD + a details tooltip (save + publish lifecycle of this record)
158
164
  'editorStatus.word.saving': 'Saving…',
159
165
  'editorStatus.word.unsaved': 'Unsaved',
@@ -168,12 +174,16 @@ export const en: Catalog = {
168
174
  // editor Ampel — RIGHT lamp: one WORD + a details tooltip (generated state of the static page)
169
175
  'editorStatus.word.live': 'Live',
170
176
  'editorStatus.word.generating': 'Generating…',
177
+ 'editorStatus.word.notPublished': 'Not published',
171
178
  'editorStatus.word.notBuilt': 'Not built',
172
179
  'editorStatus.word.notLive': 'Not live',
173
180
  'editorStatus.word.error': 'Error',
181
+ 'editorStatus.word.outdated': 'Outdated',
174
182
  'editorStatus.live.liveS3': 'Live — page uploaded to S3.',
175
183
  'editorStatus.live.liveLocal': 'Live — page written to the static output.',
176
184
  'editorStatus.live.pending': 'Publishing in progress…',
185
+ 'editorStatus.live.outdated': 'Saved but not published — the live page still shows the last published version.',
186
+ 'editorStatus.live.notPublished': 'Never published — press Publish to put this page on the live site.',
177
187
  'editorStatus.live.notBuilt': 'Not generated in this environment — the static page is built on deploy.',
178
188
  'editorStatus.live.error': 'Publishing the static page failed.',
179
189
  'editorStatus.live.draft': 'Draft — not generated. It stays off the live site until published.',