@unitninety/site-kit 1.6.3 → 1.6.4
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 +1 -1
- package/package.json +1 -1
- package/src/components/Link.tsx +1 -1
- package/src/elements/leaves/link.ts +17 -2
- package/src/elements/leaves/rich-text.ts +32 -16
- package/src/legacy/cli.mjs +2 -2
- package/src/legacy/content.ts +1 -1
- package/src/site/fields.ts +1 -1
- package/src/site/version.ts +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ 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.4 --version
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
UnitNinety imports packages made for that version only, so keep the version the tool names.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unitninety/site-kit",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.4",
|
|
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": {
|
package/src/components/Link.tsx
CHANGED
|
@@ -20,5 +20,5 @@ export function Link({ value, children, ...rest }: { value: LinkRead | null | un
|
|
|
20
20
|
// element's props pass through). The href is always the value's.
|
|
21
21
|
const tab: Record<string, unknown> = value.newTab ? { target: '_blank', rel: 'noopener' } : {}
|
|
22
22
|
for (const key of ['target', 'rel'] as const) if (rest[key] !== undefined) tab[key] = rest[key]
|
|
23
|
-
return isInternal(value.href) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
23
|
+
return isInternal(value.href) && !(value.to && 'file' in value.to) ? createElement(NextLink, { ...attrs, href: value.href, ...tab } as never, label) : createElement('a', { ...attrs, href: value.href, ...tab }, label)
|
|
24
24
|
}
|
|
@@ -3,12 +3,13 @@ import { Id, TEL_RE, URL_RE, defineElement, requiredIssue, type ExpandCtx, type
|
|
|
3
3
|
import { isDocumentId } from '../ids'
|
|
4
4
|
|
|
5
5
|
const ANCHOR = z.string().regex(/^[A-Za-z][\w-]*$/)
|
|
6
|
-
const KINDS = ['page', 'entry', 'url', 'email', 'tel'] as const
|
|
6
|
+
const KINDS = ['page', 'entry', 'file', 'url', 'email', 'tel'] as const
|
|
7
7
|
export type LinkKind = (typeof KINDS)[number]
|
|
8
8
|
|
|
9
9
|
export const LinkTo = z.union([
|
|
10
10
|
z.strictObject({ page: Id, anchor: ANCHOR.optional() }),
|
|
11
11
|
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
12
|
+
z.strictObject({ file: Id }),
|
|
12
13
|
z.strictObject({ url: z.string().regex(URL_RE, 'Expected an absolute URL or a root-relative path.') }),
|
|
13
14
|
z.strictObject({ email: z.email() }),
|
|
14
15
|
z.strictObject({ tel: z.string().regex(TEL_RE, 'Expected a phone number.') }),
|
|
@@ -19,6 +20,7 @@ const LinkToSeed = z.union([
|
|
|
19
20
|
z.strictObject({ page: z.union([Id, z.string().min(1)]), anchor: ANCHOR.optional() }),
|
|
20
21
|
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), key: z.string().min(1) }) }),
|
|
21
22
|
z.strictObject({ entry: z.strictObject({ collection: z.string().min(1), id: Id }) }),
|
|
23
|
+
z.strictObject({ file: z.union([Id, z.strictObject({ source: z.string().trim().min(1) })]) }),
|
|
22
24
|
z.strictObject({ url: z.string().regex(URL_RE) }),
|
|
23
25
|
z.strictObject({ email: z.email() }),
|
|
24
26
|
z.strictObject({ tel: z.string().regex(TEL_RE) }),
|
|
@@ -71,7 +73,7 @@ export const withDraftLocale = (href: string, locale: string): string => {
|
|
|
71
73
|
/** In draft with a `draftLocale`, an internal href keeps the language; every other href, and every href outside draft, is unchanged. */
|
|
72
74
|
export const hrefOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
73
75
|
const r = targetOf(to, ctx)
|
|
74
|
-
return ctx.draftLocale && r.href && internal(r.href) ? { ...r, href: withDraftLocale(r.href, ctx.draftLocale) } : r
|
|
76
|
+
return ctx.draftLocale && r.href && !(to && 'file' in to) && internal(r.href) ? { ...r, href: withDraftLocale(r.href, ctx.draftLocale) } : r
|
|
75
77
|
}
|
|
76
78
|
const targetOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; status: 'ready' | 'missing' } => {
|
|
77
79
|
if (!to) return { href: null, status: 'ready' }
|
|
@@ -83,6 +85,13 @@ const targetOf = (to: LinkTo | null, ctx: ExpandCtx): { href: string | null; sta
|
|
|
83
85
|
const e = ctx.entry(to.entry.collection, to.entry.id, 0)
|
|
84
86
|
return e.status === 'ready' && e.route ? { href: e.route, status: 'ready' } : { href: null, status: 'missing' }
|
|
85
87
|
}
|
|
88
|
+
if ('file' in to) {
|
|
89
|
+
const media = ctx.media(to.file)
|
|
90
|
+
// Visibility is enforced by the platform's reference checks and media route. A resolver
|
|
91
|
+
// carrying additional visibility metadata must never turn a private file into a link.
|
|
92
|
+
const privateFile = media && 'visibility' in media && media.visibility === 'private'
|
|
93
|
+
return media?.url && !privateFile ? { href: media.url, status: 'ready' } : { href: null, status: 'missing' }
|
|
94
|
+
}
|
|
86
95
|
if ('url' in to) return { href: to.url, status: 'ready' }
|
|
87
96
|
if ('email' in to) return { href: `mailto:${to.email}`, status: 'ready' }
|
|
88
97
|
return { href: `tel:${to.tel.replace(/[^+\d]/g, '')}`, status: 'ready' }
|
|
@@ -100,6 +109,10 @@ const isUnresolvedSeedRef = (to: SeedTo | null): boolean =>
|
|
|
100
109
|
|
|
101
110
|
const expandSeed = (s: SeedValue, o: O, ctx: ExpandCtx): LinkRead => {
|
|
102
111
|
if (s === null) return null
|
|
112
|
+
if (s.to && 'file' in s.to && typeof s.to.file === 'object') {
|
|
113
|
+
const source = s.to.file.source
|
|
114
|
+
return { label: s.label, href: source, newTab: s.newTab, to: { file: source }, status: 'ready', ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
115
|
+
}
|
|
103
116
|
if (isUnresolvedSeedRef(s.to)) return { label: s.label, href: null, newTab: s.newTab, to: null, status: 'missing', ...(ctx.draft ? { $path: ctx.at } : {}) }
|
|
104
117
|
return expand({ label: s.label, to: s.to as LinkTo | null, newTab: s.newTab }, o, ctx)
|
|
105
118
|
}
|
|
@@ -124,6 +137,7 @@ export const link = defineElement({
|
|
|
124
137
|
if (!v?.to) return []
|
|
125
138
|
if ('page' in v.to) return [{ kind: 'page', id: v.to.page }]
|
|
126
139
|
if ('entry' in v.to) return [{ kind: 'entry', collection: v.to.entry.collection, id: v.to.entry.id, depth: 0 }]
|
|
140
|
+
if ('file' in v.to) return [{ kind: 'media', id: v.to.file }]
|
|
127
141
|
return []
|
|
128
142
|
},
|
|
129
143
|
toStored: async (s, _o, ctx: SeedCtx) => {
|
|
@@ -131,6 +145,7 @@ export const link = defineElement({
|
|
|
131
145
|
let to: LinkTo | null = null
|
|
132
146
|
if (s.to && 'page' in s.to) to = { page: typeof s.to.page === 'string' && !isDocumentId(s.to.page) ? ctx.resolvePage(s.to.page) : s.to.page, ...(s.to.anchor ? { anchor: s.to.anchor } : {}) }
|
|
133
147
|
else if (s.to && 'entry' in s.to) to = { entry: { collection: s.to.entry.collection, id: 'key' in s.to.entry ? ctx.resolveEntry(s.to.entry.collection, s.to.entry.key) : s.to.entry.id } }
|
|
148
|
+
else if (s.to && 'file' in s.to) to = { file: typeof s.to.file === 'object' ? await ctx.importAsset(s.to.file.source) : s.to.file }
|
|
134
149
|
else to = s.to
|
|
135
150
|
return { label: s.label, to, newTab: s.newTab }
|
|
136
151
|
},
|
|
@@ -20,15 +20,16 @@ type O = z.infer<typeof RichTextOptions>
|
|
|
20
20
|
const common = { direction: z.enum(['ltr', 'rtl']).nullable().default('ltr'), format: z.string().default(''), indent: z.int().default(0), version: z.int().default(1) }
|
|
21
21
|
const TextNode = z.object({ type: z.literal('text'), text: z.string(), format: z.int().nonnegative().default(0), detail: z.int().default(0), mode: z.string().default('normal'), style: z.string().default('') , version: z.int().default(1) })
|
|
22
22
|
const LineBreak = z.object({ type: z.literal('linebreak'), version: z.int().default(1) })
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
23
|
+
const FileSeedTo = z.strictObject({ file: z.strictObject({ source: z.string().trim().min(1) }) })
|
|
24
|
+
const LinkNode = (seed = false) => z.object({ type: z.literal('link'), fields: z.object({ to: seed ? z.union([LinkTo, FileSeedTo]) : LinkTo, newTab: z.boolean().default(false) }), children: z.array(z.union([TextNode, LineBreak])), ...common })
|
|
25
|
+
const inlines = (o: O, seed = false) => z.array(o.marks.includes('link') ? z.union([TextNode, LineBreak, LinkNode(seed)]) : z.union([TextNode, LineBreak]))
|
|
26
|
+
const Paragraph = (o: O, seed = false) => z.object({ type: z.literal('paragraph'), children: inlines(o, seed), textFormat: z.int().default(0), ...common })
|
|
27
|
+
const Heading = (o: O, seed = false) => z.object({ type: z.literal('heading'), tag: z.enum(o.headings as [string, ...string[]]), children: inlines(o, seed), ...common })
|
|
28
|
+
const Quote = (o: O, seed = false) => z.object({ type: z.literal('quote'), children: inlines(o, seed), ...common })
|
|
29
|
+
const ListItem = (o: O, seed = false) => z.object({ type: z.literal('listitem'), children: inlines(o, seed), value: z.int().default(1), ...common })
|
|
30
|
+
const List = (o: O, seed = false) => z.object({ type: z.literal('list'), listType: z.enum(['bullet', 'number']), tag: z.enum(['ul', 'ol']), start: z.int().default(1), children: z.array(ListItem(o, seed)), ...common })
|
|
31
|
+
const blockSchemas = (o: O, seed = false) => {
|
|
32
|
+
const by = { paragraph: Paragraph(o, seed), heading: Heading(o, seed), list: List(o, seed), quote: Quote(o, seed) }
|
|
32
33
|
const chosen = o.blocks.map((b) => by[b])
|
|
33
34
|
return chosen.length === 1 ? chosen[0] : z.union(chosen as z.ZodTypeAny[] as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]])
|
|
34
35
|
}
|
|
@@ -44,9 +45,9 @@ const eachLink = (node: Node, f: (n: Node) => void) => {
|
|
|
44
45
|
node.children?.forEach((c) => eachLink(c, f))
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
const stored = (o: O) =>
|
|
48
|
+
const stored = (o: O, seed = false) =>
|
|
48
49
|
z
|
|
49
|
-
.object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o)), ...common }) })
|
|
50
|
+
.object({ root: z.object({ type: z.literal('root'), children: z.array(blockSchemas(o, seed)), ...common }) })
|
|
50
51
|
.superRefine((v, ctx) => {
|
|
51
52
|
const root = v.root as unknown as Node
|
|
52
53
|
if (o.inline && (root.children!.length !== 1 || root.children![0].type !== 'paragraph'))
|
|
@@ -70,10 +71,16 @@ export const richTextToPlain = (v: Stored): string =>
|
|
|
70
71
|
v ? (v.root.children as unknown as Node[]).map((b) => { const parts: string[] = []; eachText(b, [], (n) => parts.push(n.text ?? '')); return parts.join('') }).join('\n') : ''
|
|
71
72
|
|
|
72
73
|
export type RichTextRead = ({ root: unknown; $path?: string }) | null
|
|
73
|
-
const expand = (v: Stored, o: O, ctx: ExpandCtx): RichTextRead => {
|
|
74
|
+
const expand = (v: Stored, o: O, ctx: ExpandCtx, seed = false): RichTextRead => {
|
|
74
75
|
if (v === null) return null
|
|
75
76
|
const root = structuredClone(v.root) as unknown as Node
|
|
76
|
-
eachLink(root, (n) => {
|
|
77
|
+
eachLink(root, (n) => {
|
|
78
|
+
const fields = n.fields as { to: z.infer<typeof LinkTo> | z.infer<typeof FileSeedTo> }
|
|
79
|
+
if (seed && 'file' in fields.to && typeof fields.to.file === 'object') {
|
|
80
|
+
const source = fields.to.file.source
|
|
81
|
+
Object.assign(fields, { to: { file: source }, href: source, status: 'ready' })
|
|
82
|
+
} else Object.assign(fields, hrefOf(fields.to as z.infer<typeof LinkTo>, ctx))
|
|
83
|
+
})
|
|
77
84
|
const split = (node: Node) => {
|
|
78
85
|
if (!node.children) return
|
|
79
86
|
node.children.forEach(split)
|
|
@@ -118,7 +125,7 @@ export const richText = defineElement({
|
|
|
118
125
|
label: 'Rich text',
|
|
119
126
|
placement: 'any',
|
|
120
127
|
options: RichTextOptions,
|
|
121
|
-
seed: stored,
|
|
128
|
+
seed: (o) => stored(o, true),
|
|
122
129
|
stored,
|
|
123
130
|
shape: () => SHAPE,
|
|
124
131
|
read: () => z.object({ root: z.unknown(), $path: z.string().optional() }).nullable(),
|
|
@@ -132,12 +139,21 @@ export const richText = defineElement({
|
|
|
132
139
|
const to = (n.fields as { to: z.infer<typeof LinkTo> }).to
|
|
133
140
|
if ('page' in to) refs.push({ kind: 'page', id: to.page })
|
|
134
141
|
else if ('entry' in to) refs.push({ kind: 'entry', collection: to.entry.collection, id: to.entry.id, depth: 0 })
|
|
142
|
+
else if ('file' in to) refs.push({ kind: 'media', id: to.file })
|
|
135
143
|
})
|
|
136
144
|
return refs
|
|
137
145
|
},
|
|
138
|
-
toStored: async (seed) =>
|
|
146
|
+
toStored: async (seed, _o, ctx) => {
|
|
147
|
+
if (seed === null) return null
|
|
148
|
+
const copy = structuredClone(seed)
|
|
149
|
+
await Promise.all(linkNodes(copy).map(async (node) => {
|
|
150
|
+
const fields = node.fields as { to: z.infer<typeof LinkTo> | z.infer<typeof FileSeedTo> }
|
|
151
|
+
if ('file' in fields.to && typeof fields.to.file === 'object') fields.to = { file: await ctx.importAsset(fields.to.file.source) }
|
|
152
|
+
}))
|
|
153
|
+
return copy
|
|
154
|
+
},
|
|
139
155
|
expand,
|
|
140
|
-
expandSeed: expand,
|
|
156
|
+
expandSeed: (s, o, ctx) => expand(s, o, ctx, true),
|
|
141
157
|
toText: richTextToPlain,
|
|
142
158
|
translatable: (v) => {
|
|
143
159
|
const out: { path: (string | number)[]; value: string }[] = []
|
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.4 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.4 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.4 pair\` writes both.`,
|
|
364
364
|
)
|
|
365
365
|
}
|
|
366
366
|
return true
|
package/src/site/fields.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const image = (o: Common & { default?: unknown; constraints?: unknown; va
|
|
|
19
19
|
leaf<ImageRead>('u90/image', o, { constraints: o.constraints, variants: o.variants })
|
|
20
20
|
export const video = (o: Common & { default?: unknown; poster?: unknown } = {}) => leaf<VideoRead>('u90/video', o, { poster: o.poster })
|
|
21
21
|
export const file = (o: Common & { default?: unknown; mimeTypes?: string[]; maxBytes?: number } = {}) => leaf<FileRead>('u90/file', o, { mimeTypes: o.mimeTypes, maxBytes: o.maxBytes })
|
|
22
|
-
export const link = (o: Common & { default?: unknown; allow?: Array<'page' | 'entry' | 'url' | 'email' | 'tel'> } = {}) => leaf<LinkRead>('u90/link', o, { allow: o.allow })
|
|
22
|
+
export const link = (o: Common & { default?: unknown; allow?: Array<'page' | 'entry' | 'file' | 'url' | 'email' | 'tel'> } = {}) => leaf<LinkRead>('u90/link', o, { allow: o.allow })
|
|
23
23
|
export const date = (o: Common & { default?: string; min?: string; max?: string } = {}) => leaf<DateRead>('u90/date', o, { min: o.min, max: o.max })
|
|
24
24
|
export const number = (o: Common & { default?: number; integer?: boolean; min?: number; max?: number; step?: number } = {}) => leaf<NumberRead>('u90/number', o, { integer: o.integer, min: o.min, max: o.max, step: o.step })
|
|
25
25
|
export const boolean = (o: Common & { default?: boolean } = {}) => leaf<BooleanRead>('u90/boolean', o, {})
|
package/src/site/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SITE_KIT_VERSION = '1.6.
|
|
1
|
+
export const SITE_KIT_VERSION = '1.6.4'
|