@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 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.3 --version
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",
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": {
@@ -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 LinkNode = z.object({ type: z.literal('link'), fields: z.object({ to: LinkTo, newTab: z.boolean().default(false) }), children: z.array(z.union([TextNode, LineBreak])), ...common })
24
- const inlines = (o: O) => z.array(o.marks.includes('link') ? z.union([TextNode, LineBreak, LinkNode]) : z.union([TextNode, LineBreak]))
25
- const Paragraph = (o: O) => z.object({ type: z.literal('paragraph'), children: inlines(o), textFormat: z.int().default(0), ...common })
26
- const Heading = (o: O) => z.object({ type: z.literal('heading'), tag: z.enum(o.headings as [string, ...string[]]), children: inlines(o), ...common })
27
- const Quote = (o: O) => z.object({ type: z.literal('quote'), children: inlines(o), ...common })
28
- const ListItem = (o: O) => z.object({ type: z.literal('listitem'), children: inlines(o), value: z.int().default(1), ...common })
29
- const List = (o: O) => 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)), ...common })
30
- const blockSchemas = (o: O) => {
31
- const by = { paragraph: Paragraph(o), heading: Heading(o), list: List(o), quote: Quote(o) }
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) => { const fields = n.fields as { to: z.infer<typeof LinkTo> }; Object.assign(fields, hrefOf(fields.to, ctx)) })
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) => 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 }[] = []
@@ -2,7 +2,7 @@
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
 
4
4
  /**
5
- * `npx --yes @unitninety/u90@1.0.3 pair <code>` — the one command an operator hands over. The u90 tool
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.3 pair <code> --site https://example.com [--platform https://cms…]')
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]
@@ -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.3 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.4 pair\` writes both.`,
364
364
  )
365
365
  }
366
366
  return true
@@ -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, {})
@@ -1 +1 @@
1
- export const SITE_KIT_VERSION = '1.6.3'
1
+ export const SITE_KIT_VERSION = '1.6.4'