@unitninety/site-kit 1.6.1 → 1.6.2

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 CHANGED
@@ -5,9 +5,27 @@ The library a UnitNinety site is built on. It reads the site's content from Unit
5
5
  You do not usually add it by hand. The `u90` tool adds it when it converts a Next.js App Router site, at the one version that release of the tool works with:
6
6
 
7
7
  ```bash
8
- npx --yes @unitninety/u90@1.0.0 --version
8
+ npx --yes @unitninety/u90@1.0.1 --version
9
9
  ```
10
10
 
11
11
  UnitNinety imports packages made for that version only, so keep the version the tool names.
12
12
 
13
13
  Documentation: [converting a site](https://unitninety.com/docs/convert), [importing it](https://unitninety.com/docs/import) and [the U90 package](https://unitninety.com/docs/package).
14
+
15
+ ### Immediate collection-list previews
16
+
17
+ Render a query's `EntriesRead` with `Entries` from `@unitninety/site-kit/components` when its
18
+ cards should reorder and disappear immediately in Canvas. Pass the complete value, including
19
+ its draft `$path`, and render one card per entry:
20
+
21
+ ```tsx
22
+ <Entries value={projects} className="project-grid" empty={<p>No projects yet.</p>}>
23
+ {entry => <ProjectCard entry={entry} />}
24
+ </Entries>
25
+ ```
26
+
27
+ The card renderer runs on the server. In draft mode React owns the keyed cards and preserves
28
+ pending order/removal through slow refreshes; rejected saves reconcile to the server result.
29
+ Published output is an ordinary div and server-rendered cards. Newly added entries whose markup
30
+ is not already present appear when the save's server refresh arrives. Query results rendered
31
+ with a plain `.map()` still work, but wait for that refresh to change their order or membership.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitninety/site-kit",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "The client-site half of the UnitNinety platform contract: schema authoring, content fetching, the editing bridge and the pairing CLI.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import { Fragment, useLayoutEffect, useRef, useState, type HTMLAttributes, type ReactNode } from 'react'
4
+ import { PreviewCommitBoundary } from './PreviewCommitBoundary'
5
+ import { registry } from './registry'
6
+
7
+ export type EntryItem = { id: string; content: ReactNode }
8
+ export type EntryPicks = { order: string[]; exclude: string[]; pin: string[] }
9
+
10
+ function picked(items: EntryItem[], picks: EntryPicks, pins: readonly string[]): EntryItem[] {
11
+ const excluded = new Set([...picks.exclude, ...pins.filter(id => !picks.pin.includes(id))])
12
+ const ranks = new Map(picks.order.map((id, index) => [id, index]))
13
+ return items.filter(item => !excluded.has(item.id)).sort((a, b) =>
14
+ (ranks.get(a.id) ?? Infinity) - (ranks.get(b.id) ?? Infinity))
15
+ }
16
+
17
+ /** React owns the cards so pending picks survive a slow server refresh without a DOM undo flash. */
18
+ export function DraftEntries({ path, items, pins, empty, ...props }: HTMLAttributes<HTMLDivElement> & { path: string; items: EntryItem[]; pins: string[]; empty?: ReactNode }) {
19
+ const host = useRef<HTMLDivElement>(null)
20
+ const [shown, setShown] = useState({ source: items, pins, items, picks: null as EntryPicks | null })
21
+ const current = shown.source === items ? shown.items : shown.picks === null ? items : picked(items, shown.picks, pins)
22
+ if (shown.source !== items) setShown({ ...shown, source: items, pins, items: current })
23
+
24
+ useLayoutEffect(() => {
25
+ const handler = {
26
+ document: host.current!.ownerDocument,
27
+ // Clear only the picks included when this refresh starts, retaining the visible cards
28
+ // until its response arrives. A subsequent edit remains active across that older response.
29
+ beforeRefresh: () => setShown(value => ({ ...value, picks: null })),
30
+ pick: (at: string, picks: EntryPicks) => {
31
+ if (at !== path) return false
32
+ setShown(value => ({ ...value, items: picked(value.source, picks, value.pins), picks }))
33
+ return true
34
+ },
35
+ }
36
+ registry.entryLists.add(handler)
37
+ return () => { registry.entryLists.delete(handler) }
38
+ }, [path])
39
+
40
+ return <PreviewCommitBoundary source={items}><div {...props} data-u90-field={path} ref={host}>{current.length ? current.map(item => <Fragment key={item.id}>{item.content}</Fragment>) : empty}</div></PreviewCommitBoundary>
41
+ }
@@ -529,7 +529,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
529
529
  else if (refreshAgain) requestRefresh()
530
530
  }
531
531
  function requestRefresh() {
532
- if (refreshing && [...registry.blockOrders].some((owner) => owner.document === doc)) {
532
+ if (refreshing && [...registry.blockOrders, ...registry.entryLists].some((owner) => owner.document === doc)) {
533
533
  refreshAgain = true
534
534
  return
535
535
  }
@@ -540,7 +540,7 @@ export function createBridge(win: Window, opts: BridgeOptions) {
540
540
  }
541
541
  // One refresh boundary at a time: newer moves stay visible through the older render.
542
542
  // Clear only the moves included when THIS request starts, never when another is queued.
543
- for (const owner of registry.blockOrders) if (owner.document === doc) owner.beforeRefresh()
543
+ for (const owner of [...registry.blockOrders, ...registry.entryLists]) if (owner.document === doc) owner.beforeRefresh()
544
544
  const restoreEditing = preserveEditing()
545
545
  try {
546
546
  undoPreviews()
@@ -554,6 +554,11 @@ export function createBridge(win: Window, opts: BridgeOptions) {
554
554
  const preview = (instruction: PreviewInstruction) => {
555
555
  // React-owned order is safe even while a refresh is pending. Only legacy DOM moves
556
556
  // still need the guard below; they can disagree with React's tree during a commit.
557
+ if (instruction.kind === 'entries') {
558
+ // All renderings of this placement update, including duplicate responsive layouts.
559
+ for (const owner of registry.entryLists) if (owner.document === doc) owner.pick(instruction.path, instruction)
560
+ return
561
+ }
557
562
  if (instruction.kind === 'move') {
558
563
  for (const owner of registry.blockOrders) {
559
564
  if (owner.document === doc && owner.move(instruction.path, instruction.before)) return
@@ -57,6 +57,7 @@ const isBridgeLinkTarget = (to: unknown): to is LinkTo => {
57
57
  * `before` names the sibling to insert in front of; `null` appends.
58
58
  */
59
59
  export type PreviewInstruction =
60
+ | { kind: 'entries'; path: string; order: string[]; exclude: string[]; pin: string[] }
60
61
  | { kind: 'move'; path: string; before: string | null }
61
62
  | { kind: 'remove'; path: string }
62
63
  | { kind: 'media'; path: string; src: string }
@@ -75,7 +76,7 @@ export type PreviewInstruction =
75
76
  */
76
77
  | { kind: 'fragment'; path: string; block: string }
77
78
 
78
- const PREVIEW_KINDS = new Set(['move', 'remove', 'media', 'text', 'link-label', 'rich', 'insert', 'replace', 'fragment'])
79
+ const PREVIEW_KINDS = new Set(['entries', 'move', 'remove', 'media', 'text', 'link-label', 'rich', 'insert', 'replace', 'fragment'])
79
80
  const isBefore = (v: unknown): boolean => v === null || typeof v === 'string'
80
81
 
81
82
  /** Whether a `u90:preview`'s instruction has a known kind and a well-formed payload. */
@@ -83,6 +84,7 @@ const isPreviewInstruction = (value: unknown): value is PreviewInstruction => {
83
84
  if (!(value && typeof value === 'object')) return false
84
85
  const r = value as Record<string, unknown>
85
86
  if (typeof r.kind !== 'string' || !PREVIEW_KINDS.has(r.kind) || typeof r.path !== 'string') return false
87
+ if (r.kind === 'entries') return [r.order, r.exclude, r.pin].every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))
86
88
  if (r.kind === 'media') return typeof r.src === 'string'
87
89
  if (r.kind === 'text' || r.kind === 'link-label') return typeof r.value === 'string'
88
90
  if (r.kind === 'rich') return Array.isArray(r.paragraphs) && r.paragraphs.every((paragraph) => typeof paragraph === 'string')
@@ -1,3 +1,4 @@
1
+ import type { EntryPicks } from './DraftEntries'
1
2
  import type { Bridge } from './createBridge'
2
3
 
3
4
  /**
@@ -19,9 +20,11 @@ import type { Bridge } from './createBridge'
19
20
  export const registry: {
20
21
  current: Bridge | null
21
22
  reveals: Set<{ prefix: string; fn: () => void }>
23
+ entryLists: Set<{ document: Document; beforeRefresh(): void; pick(path: string, picks: EntryPicks): boolean }>
22
24
  blockOrders: Set<{ document: Document; beforeRefresh(): void; needsReload(): boolean; move(path: string, before: string | null): boolean }>
23
25
  } = {
24
26
  current: null,
25
27
  reveals: new Set(),
26
28
  blockOrders: new Set(),
29
+ entryLists: new Set(),
27
30
  }
@@ -0,0 +1,16 @@
1
+ import { Fragment, type HTMLAttributes, type ReactNode } from 'react'
2
+ import type { EntryRead } from '../elements/define'
3
+ import type { EntriesRead } from '../elements/leaves/selection'
4
+ import { DraftEntries } from '../bridge/DraftEntries'
5
+
6
+ /** A placement-aware list of server-rendered cards. Draft picks update immediately in Canvas;
7
+ * published lists remain ordinary server markup. The renderer never crosses the client boundary. */
8
+ export function Entries({ value, children, empty, ...props }: Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
9
+ value: EntriesRead
10
+ children: (entry: EntryRead) => ReactNode
11
+ empty?: ReactNode
12
+ }) {
13
+ const items = value.entries.map(entry => ({ id: String(entry.id), content: children(entry) }))
14
+ if (value.$path) return <DraftEntries key={value.$path} path={value.$path} items={items} pins={value.$pins ?? []} empty={empty} {...props} />
15
+ return <div {...props}>{items.length ? items.map(item => <Fragment key={item.id}>{item.content}</Fragment>) : empty}</div>
16
+ }
@@ -1,3 +1,4 @@
1
+ export { Entries } from './Entries'
1
2
  export { Text } from './Text'
2
3
  export { RichText, richTextNodes } from './RichText'
3
4
  export { Link, isInternal } from './Link'
@@ -77,7 +77,7 @@ const stored = (o: O) =>
77
77
  ctx.addIssue({ code: 'custom', path: [k], message: 'Each entry may be listed once.', params: { code: 'duplicate' } })
78
78
  }
79
79
  })
80
- const expand = (v: QueryStored, o: O, ctx: ExpandCtx): EntriesRead => ({ ...ctx.query(o.to, v), ...(ctx.draft ? { $path: ctx.at } : {}) })
80
+ const expand = (v: QueryStored, o: O, ctx: ExpandCtx): EntriesRead => ({ ...ctx.query(o.to, v), ...(ctx.draft ? { $path: ctx.at, $pins: (v.pin ?? []).map(String) } : {}) })
81
81
 
82
82
  export const query = defineElement({
83
83
  id: 'u90/query',
@@ -5,8 +5,8 @@ import { EntryRef, resolveEntryRef, uniqueIds } from './relation'
5
5
  export const SelectionOptions = z.strictObject({ to: z.string().min(1), many: z.boolean().default(false), max: z.int().positive().optional(), depth: z.int().min(0).max(3).default(1) })
6
6
  type O = z.infer<typeof SelectionOptions>
7
7
  /** How a selection and a query read: the entries in order, how many there are, and `$path` in draft. */
8
- export type EntriesRead = { entries: EntryRead[]; total: number; $path?: string }
9
- export const EntriesReadSchema = z.object({ entries: z.array(EntryReadSchema), total: z.number(), $path: z.string().optional() })
8
+ export type EntriesRead = { entries: EntryRead[]; total: number; $path?: string; $pins?: string[] }
9
+ export const EntriesReadSchema = z.object({ entries: z.array(EntryReadSchema), total: z.number(), $path: z.string().optional(), $pins: z.array(z.string()).optional() })
10
10
 
11
11
  const stored = (o: O) => (o.many ? (o.max ? z.array(Id).max(o.max) : z.array(Id)).superRefine(uniqueIds) : Id.nullable())
12
12
  type SeedValue = z.infer<typeof EntryRef> | z.infer<typeof EntryRef>[] | null
@@ -2,7 +2,7 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
- * `npx --yes @unitninety/u90@1.0.0 pair <code>` — the one command an operator hands over. The u90 tool
5
+ * `npx --yes @unitninety/u90@1.0.1 pair <code>` — the one command an operator hands over. The u90 tool
6
6
  * runs this file for `u90 pair`. The command is scoped and pinned because an unscoped name, run in a
7
7
  * repository with no local bin, fetches whatever package npm holds under that name.
8
8
  *
@@ -115,7 +115,7 @@ const run = async () => {
115
115
  const args = process.argv.slice(2)
116
116
 
117
117
  if (args[0] !== 'pair' || !args[1]) {
118
- fail('Usage: npx --yes @unitninety/u90@1.0.0 pair <code> --site https://example.com [--platform https://cms…]')
118
+ fail('Usage: npx --yes @unitninety/u90@1.0.1 pair <code> --site https://example.com [--platform https://cms…]')
119
119
  }
120
120
 
121
121
  const code = args[1]
@@ -360,7 +360,7 @@ export const createContentClient = ({
360
360
  : !platformUrl
361
361
  ? 'PLATFORM_URL is unset'
362
362
  : 'PAYLOAD_API_KEY is unset'
363
- }. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.0 pair\` writes both.`,
363
+ }. Every page will render its committed fallback copy, and every collection route will 404. Set it in the site's environment — \`npx --yes @unitninety/u90@1.0.1 pair\` writes both.`,
364
364
  )
365
365
  }
366
366
  return true
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.6.1'
1
+ export const SITE_KIT_VERSION = '1.6.2'