@byline/cli 2.4.3 → 2.5.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.
@@ -24,7 +24,10 @@ import { type BylineCore, initBylineCore } from '@byline/core'
24
24
  import { pgAdapter } from '@byline/db-postgres'
25
25
  import { createAdminStore } from '@byline/db-postgres/admin'
26
26
  import { getAdminBylineClient } from '@byline/host-tanstack-start/integrations/byline-client'
27
- import { lexicalEditorServer } from '@byline/richtext-lexical/server'
27
+ import {
28
+ lexicalEditorEmbedServer,
29
+ lexicalEditorPopulateServer,
30
+ } from '@byline/richtext-lexical/server'
28
31
  import { localStorageProvider } from '@byline/storage-local'
29
32
 
30
33
  import { i18n } from './i18n.js'
@@ -128,7 +131,10 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
128
131
  // }),
129
132
  sessionProvider,
130
133
  fields: {
131
- richText: { populate: lexicalEditorServer({ getClient: getAdminBylineClient }) },
134
+ richText: {
135
+ embed: lexicalEditorEmbedServer({ getClient: getAdminBylineClient }),
136
+ populate: lexicalEditorPopulateServer({ getClient: getAdminBylineClient }),
137
+ },
132
138
  },
133
139
  })
134
140
 
@@ -119,29 +119,22 @@ export const PagesAdmin: CollectionAdminConfig = defineAdmin(Pages, {
119
119
  },
120
120
 
121
121
  /**
122
- * Preview URL builder for live preview links. Returns a URL string (relative
123
- * or absolute), or `null` to hide the preview affordance.
122
+ * Preview URL builder for live preview links. Delegates to the
123
+ * schema-side `Pages.buildDocumentPath` (the single source of truth
124
+ * for how a page composes into a public path — also driven by the
125
+ * richtext embed walker on internal-link nodes) and adds the request-
126
+ * locale prefix on top. Returns `null` to hide the preview affordance.
124
127
  *
125
- * `doc.path` is the top-level slug (derived from `useAsPath`), not a field.
126
- * Direct relations are auto-populated by the edit view (depth 1, picker
127
- * projection) and appear under `doc.fields.<name>?.document`.
128
- *
129
- * @example
130
- * preview: {
131
- * url: (doc, { locale }) => {
132
- * if (!doc.path) return null
133
- * const prefix = locale && locale !== 'en' ? `/${locale}` : ''
134
- * return `${prefix}/${doc.path}`
135
- * },
136
- * }
128
+ * `doc.path` is the top-level slug (derived from `useAsPath`), not a
129
+ * field. Direct relations are auto-populated by the edit view (depth
130
+ * 1, picker projection) and appear under `doc.fields.<name>?.document`.
137
131
  */
138
132
  preview: {
139
133
  url: (doc, { locale }) => {
140
- if (!doc.path) return null
134
+ const path = Pages.buildDocumentPath?.(doc, { collectionPath: Pages.path }) ?? null
135
+ if (path == null) return null
141
136
  const prefix = locale && locale !== i18n.interface.defaultLocale ? `/${locale}` : ''
142
- const pathWithArea =
143
- doc.fields?.area && doc.fields.area !== 'root' ? `${doc.fields.area}/${doc.path}` : doc.path
144
- return `${prefix}/${pathWithArea}`
137
+ return `${prefix}${path}`
145
138
  },
146
139
  },
147
140
 
@@ -36,6 +36,26 @@ export const Pages = defineCollection({
36
36
  search: { fields: ['title'] },
37
37
  useAsTitle: 'title',
38
38
  useAsPath: 'title',
39
+ /**
40
+ * Pages live at the site root (no `/pages/` prefix) and may be nested
41
+ * under an `area` segment. Same composition rule used by the admin
42
+ * preview button (`admin.tsx` delegates to this) and the richtext
43
+ * embed walker that refreshes `document.path` on internal links.
44
+ *
45
+ * Returns a locale-agnostic root-relative path; the renderer prepends
46
+ * the locale at request time. Returns `null` when no slug exists yet
47
+ * (brand-new draft) so the embed walker / preview both fall back to
48
+ * "no link available" rather than producing a broken URL.
49
+ */
50
+ buildDocumentPath: (doc, _ctx) => {
51
+ if (!doc.path) return null
52
+ const area = doc.fields?.area
53
+ if (typeof area === 'string' && area !== 'root') {
54
+ return `/${area}/${doc.path}`
55
+ }
56
+ return `/${doc.path}`
57
+ },
58
+ linksInEditor: true,
39
59
  fields: [
40
60
  { name: 'title', label: 'Title', type: 'text', localized: true },
41
61
  {
@@ -14,8 +14,8 @@
14
14
  * seeds (see `byline/server.config.ts`).
15
15
  *
16
16
  * **Constraint** — `editorConfig` baked into a schema can only override
17
- * **settings** (placeholder, toolbar UI flags, `embedRelationsOnSave`).
18
- * Extension references (TableExtension, AdmonitionExtension, etc.) are
17
+ * **settings** (placeholder, toolbar UI flags). Extension references
18
+ * (TableExtension, AdmonitionExtension, etc.) are
19
19
  * not JSON-safe and would break tsx-loaded seeds; per-field extension
20
20
  * removal goes through a client-side wrapper component registered via
21
21
  * `FieldAdminConfig.editor` — see `aiRichTextAdmin()` for the pattern.
@@ -20,7 +20,10 @@ import { type BylineCore, initBylineCore } from '@byline/core'
20
20
  import { pgAdapter } from '@byline/db-postgres'
21
21
  import { createAdminStore } from '@byline/db-postgres/admin'
22
22
  import { getAdminBylineClient } from '@byline/host-tanstack-start/integrations/byline-client'
23
- import { lexicalEditorServer } from '@byline/richtext-lexical/server'
23
+ import {
24
+ lexicalEditorEmbedServer,
25
+ lexicalEditorPopulateServer,
26
+ } from '@byline/richtext-lexical/server'
24
27
  import { localStorageProvider } from '@byline/storage-local'
25
28
 
26
29
  // Import collection definitions directly from schema files — NOT the full
@@ -200,7 +203,10 @@ async function buildBylineCore(): Promise<BylineCore<AdminStore>> {
200
203
  // server config singleton, which is only populated *after*
201
204
  // `initBylineCore()` returns. Passing a factory defers resolution
202
205
  // to populate-call time so registration order here doesn't matter.
203
- richText: { populate: lexicalEditorServer({ getClient: getAdminBylineClient }) },
206
+ richText: {
207
+ embed: lexicalEditorEmbedServer({ getClient: getAdminBylineClient }),
208
+ populate: lexicalEditorPopulateServer({ getClient: getAdminBylineClient }),
209
+ },
204
210
  },
205
211
  })
206
212
 
@@ -9,25 +9,53 @@ import type { Locale } from '@/ui/byline/types/i18n'
9
9
 
10
10
  // import { getPublicWebsiteUrl } from '@/utils/utils.framework.ts'
11
11
 
12
- export interface LinkAttributes {
13
- linkType?: 'custom' | 'internal'
12
+ interface BaseLinkAttributes {
14
13
  newTab?: boolean
15
14
  nofollow?: boolean
16
- rel?: string
15
+ rel?: string | null
16
+ }
17
+
18
+ export interface CustomLinkAttributes extends BaseLinkAttributes {
19
+ linkType?: 'custom'
17
20
  url?: string
18
- doc?: {
19
- value: string
20
- relationTo: string
21
- data: {
22
- id: string
23
- title: string
24
- slug: string
25
- area: string
26
- collectionAlias: string
27
- }
21
+ }
22
+
23
+ /**
24
+ * Internal link to a Byline document. Mirrors `DocumentRelation` —
25
+ * `targetDocumentId` / `targetCollectionId` / `targetCollectionPath`
26
+ * flattened onto the attributes, plus a `document` bag carrying the
27
+ * canonical `{ title, path }` envelope embedded by the picker at write
28
+ * time and refreshed by the server-side write-time embed walker.
29
+ *
30
+ * `document.path` has dual meaning during migration:
31
+ * - leading `/` — composed by `CollectionDefinition.buildDocumentPath`
32
+ * (or the generic `/${collectionPath}/${slug}` fallback) and treated
33
+ * as authoritative by this serializer.
34
+ * - no leading `/` — bare slug from `byline_document_paths`, either
35
+ * legacy data or a picker-time write that hasn't been through the
36
+ * walker yet. The serializer applies the generic compose fallback
37
+ * using `targetCollectionPath`.
38
+ *
39
+ * `document._resolved === false` means the most recent walker pass
40
+ * could not find the target document (deleted between picker and
41
+ * save / read). The serializer strips the `<a>` wrapper and renders
42
+ * children as plain text — persisted state is preserved so an editor
43
+ * can re-link later.
44
+ */
45
+ export interface InternalLinkAttributes extends BaseLinkAttributes {
46
+ linkType: 'internal'
47
+ targetDocumentId: string
48
+ targetCollectionId: string
49
+ targetCollectionPath: string
50
+ document?: {
51
+ title?: string
52
+ path?: string
53
+ _resolved?: false
28
54
  }
29
55
  }
30
56
 
57
+ export type LinkAttributes = CustomLinkAttributes | InternalLinkAttributes
58
+
31
59
  export type LinkType = 'internal' | 'custom'
32
60
 
33
61
  export interface LinkLexicalProps {
@@ -57,38 +85,55 @@ export function manageRel(input: string, action: 'add' | 'remove', value: string
57
85
  return result
58
86
  }
59
87
 
88
+ // Hrefs we treat as "stays in the page / app" — skip URL parsing,
89
+ // skip the external-link icon, don't force `target="_blank"`. `#anchor`
90
+ // is an intra-page jump; empty hrefs would be inert placeholders but
91
+ // `LinkLexicalSerializer` short-circuits those before reaching here.
92
+ function isLocalHref(href: string): boolean {
93
+ if (href.length === 0) return true
94
+ if (href.startsWith('#')) return true
95
+ return ['tel:', 'mailto:', '/'].some((prefix) => href.startsWith(prefix))
96
+ }
97
+
98
+ /**
99
+ * Resolve the renderable href for a link node. Returns `''` when no
100
+ * usable href can be built — the serializer treats that as the signal
101
+ * to strip the `<a>` / `<LangLink>` wrapper and render children plain.
102
+ *
103
+ * Internal-link fallback chain (see docs/RICHTEXT.md):
104
+ * 1. `document._resolved === false` → strip wrapper.
105
+ * 2. `document.path` starts with `/` → use as-is (canonicalised by
106
+ * the server-side embed walker via `buildDocumentPath`).
107
+ * 3. `document.path` is a bare slug + `targetCollectionPath` present
108
+ * → generic compose `/${targetCollectionPath}/${path}`. Heal-on-
109
+ * write fallback for legacy nodes and picker-time-but-not-yet-
110
+ * walked sessions.
111
+ * 4. Neither — strip wrapper.
112
+ */
60
113
  function getHref(args: LinkAttributes): string {
61
114
  let href = ''
62
115
  const publicWebsiteUrl = '/' // getPublicWebsiteUrl()
63
- const { linkType, url } = args
64
-
65
- if ((linkType === 'custom' || linkType === undefined) && url != null) {
66
- href = url
67
- } else if (
68
- linkType === 'internal' &&
69
- args.doc?.relationTo != null &&
70
- args.doc?.data?.slug != null
71
- ) {
72
- const collection = args.doc.relationTo
73
- const { slug, area, collectionAlias } = args.doc.data
74
- if (collectionAlias != null) {
75
- // The alias might be for the root
76
- if (collectionAlias.length === 0) {
77
- href = `/${slug}`
78
- } else {
79
- href = `/${collectionAlias}/${slug}`
80
- }
81
- } else {
82
- href = `/${collection}/${slug}`
83
- }
84
116
 
85
- if (area != null && area.length > 0 && area !== 'root') {
86
- href = `/${area}${href}`
117
+ if (args.linkType === 'internal') {
118
+ // Step 1 — walker explicitly marked the target as missing.
119
+ if (args.document?._resolved === false) return ''
120
+
121
+ const path = args.document?.path
122
+ if (path != null && path.length > 0) {
123
+ if (path.startsWith('/')) {
124
+ // Step 2 — canonical path written by the embed walker.
125
+ href = path
126
+ } else if (args.targetCollectionPath) {
127
+ // Step 3 — bare slug, generic compose fallback.
128
+ href = `/${args.targetCollectionPath}/${path}`
129
+ }
130
+ // else: fall through to step 4 — empty href, wrapper stripped.
87
131
  }
132
+ } else if (args.url != null) {
133
+ href = args.url
88
134
  }
89
135
 
90
- const hrefIsLocal = ['tel:', 'mailto:', '/'].some((prefix) => href.startsWith(prefix))
91
- if (!hrefIsLocal) {
136
+ if (!isLocalHref(href)) {
92
137
  try {
93
138
  const objectURL = new URL(href)
94
139
  if (objectURL.origin === publicWebsiteUrl) {
@@ -126,7 +171,7 @@ function getAdditionalProps(
126
171
  additionalProps.target = '_blank'
127
172
  }
128
173
 
129
- if (!href.startsWith('/')) {
174
+ if (!isLocalHref(href)) {
130
175
  additionalProps.target = '_blank'
131
176
  }
132
177
 
@@ -145,6 +190,16 @@ export function LinkLexicalSerializer({
145
190
  children,
146
191
  }: LinkLexicalProps): React.JSX.Element {
147
192
  const href = getHref(attributes)
193
+
194
+ // No usable href — render children plain (no anchor) so the public site
195
+ // never carries a broken `<a href="">`. Covers `_resolved: false` and
196
+ // every other empty-href branch of `getHref`. The admin editor reads
197
+ // `__attributes` directly via Lexical APIs, so the link node stays
198
+ // visible in the editor for re-linking.
199
+ if (href.length === 0) {
200
+ return <>{children}</>
201
+ }
202
+
148
203
  const additionalProps = getAdditionalProps(attributes, href)
149
204
 
150
205
  if (href.startsWith('/')) {
@@ -161,6 +216,21 @@ export function LinkLexicalSerializer({
161
216
  </LangLink>
162
217
  )
163
218
  }
219
+ // Local but not a router path (#anchor, tel:, mailto:): plain
220
+ // <a>, no external-link affordance.
221
+ if (isLocalHref(href)) {
222
+ return (
223
+ <a
224
+ href={href}
225
+ {...additionalProps}
226
+ className={cx(className, 'underline')}
227
+ onMouseEnter={onMouseEnter}
228
+ onMouseLeave={onMouseLeave}
229
+ >
230
+ {children}
231
+ </a>
232
+ )
233
+ }
164
234
  return (
165
235
  <a
166
236
  href={href}
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/cli",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "2.4.3",
5
+ "version": "2.5.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },