@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 +19 -1
- package/package.json +1 -1
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/createBridge.ts +7 -2
- package/src/bridge/protocol.ts +3 -1
- package/src/bridge/registry.ts +3 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/index.ts +1 -0
- package/src/elements/leaves/query.ts +1 -1
- package/src/elements/leaves/selection.ts +2 -2
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/site/version.ts +1 -1
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.
|
|
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.
|
|
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
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -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')
|
package/src/bridge/registry.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
package/src/legacy/cli.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* `npx --yes @unitninety/u90@1.0.
|
|
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.
|
|
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]
|
package/src/legacy/content.ts
CHANGED
|
@@ -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.
|
|
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
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.6.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.6.2'
|