@byline/cli 2.4.4 → 2.5.1
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/dist/templates/byline/server.config.ts +8 -2
- package/dist/templates/byline-examples/collections/pages/admin.tsx +11 -18
- package/dist/templates/byline-examples/collections/pages/schema.ts +20 -0
- package/dist/templates/byline-examples/fields/lexical-richtext-compact.ts +2 -2
- package/dist/templates/byline-examples/server.config.ts +8 -2
- package/dist/templates/ui-byline/components/link/link-lexical.tsx +109 -39
- package/package.json +1 -1
|
@@ -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 {
|
|
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: {
|
|
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.
|
|
123
|
-
*
|
|
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
|
|
126
|
-
* Direct relations are auto-populated by the edit view (depth
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
*
|
|
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 {
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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}
|