@byline/cli 6.4.0 → 6.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.
@@ -7,6 +7,8 @@ import type { InlineImagePosition, SerializedInlineImageNode } from '@byline/ric
7
7
  import { FadeInLift } from '@byline/ui/react'
8
8
 
9
9
  import { ResponsiveImage } from '../responsive-image/index.tsx'
10
+ import { getHref, type LinkAttributes } from '../link/link-lexical.tsx'
11
+ import { LangLink } from '@/ui/byline/components/link/lang-link'
10
12
  import type { Locale } from '@/ui/byline/types/i18n'
11
13
  import type { SerializeOptions, SerializeProps } from '../richtext-lexical/serialize/index.tsx'
12
14
 
@@ -44,7 +46,7 @@ interface Props {
44
46
  }
45
47
 
46
48
  export function InlineImageSerializer({ node, serialize, lng, options }: Props): React.JSX.Element {
47
- const { showCaption, position, altText, document: pickerDoc, caption } = node
49
+ const { showCaption, position, altText, document: pickerDoc, caption, link } = node
48
50
  const image = pickerDoc?.image as StoredFileValue | undefined
49
51
 
50
52
  const floatLeft = position === 'left'
@@ -86,16 +88,76 @@ export function InlineImageSerializer({ node, serialize, lng, options }: Props):
86
88
  </FadeInLift>
87
89
  )
88
90
 
91
+ // Optional click-through target.
92
+ //
93
+ // The anchor goes INSIDE the floated wrapper and wraps the image only.
94
+ // Two reasons, both load-bearing:
95
+ //
96
+ // - the float and the 50% column live on the outer span, so an anchor
97
+ // outside it would take the float and change the layout;
98
+ // - the caption is a nested editor that can contain its own links, and
99
+ // an `<a>` inside an `<a>` is invalid — browsers un-nest it and the
100
+ // layout breaks with it. The caption stays outside the anchor.
101
+ //
102
+ // `display: block` stops the inline `<a>` adding a descender gap beneath
103
+ // the image. `getHref` returns '' for a target the walker marked
104
+ // unresolved, in which case the image renders with no anchor at all —
105
+ // the same degradation a text link gets.
106
+ //
107
+ // `options.insideLink` suppresses the anchor entirely: the image is
108
+ // already inside a text link, whose anchor would be invalidated by a
109
+ // nested one. The outer link wins.
110
+ const href = options?.insideLink === true || link == null ? '' : getHref(link as LinkAttributes)
111
+
112
+ // Target and rel are derived here rather than through the text link's
113
+ // `getAdditionalProps`. That helper forces `target="_blank"` for any
114
+ // external href regardless of the node's own `newTab`, which suits a text
115
+ // link (they also get a visible external-link icon) but makes the image
116
+ // dialog's "Open in new tab" checkbox a no-op for exactly the URLs an
117
+ // editor is most likely to tick it for. Here the editor's choice is
118
+ // authoritative.
119
+ //
120
+ // `rel="noopener"` is belt-and-braces, not a fix for a live hole: the
121
+ // HTML standard has `target="_blank"` imply `noopener` unless a rel
122
+ // explicitly opts back in, and conforming browsers have done so for
123
+ // years. It is stated anyway so the guarantee does not rest on the
124
+ // implied behaviour, and it matches what text links emit for `newTab`.
125
+ // `noreferrer` is deliberately NOT added — it would additionally strip
126
+ // the referrer, a separate behavioural choice that belongs with the
127
+ // site's referrer policy rather than with this checkbox.
128
+ const newTab = link?.newTab === true
129
+ const anchorProps = newTab
130
+ ? { target: '_blank', rel: 'noopener' }
131
+ : ({} as { target?: string; rel?: string })
132
+
133
+ const Linked =
134
+ href.length === 0 ? (
135
+ ImgSlot
136
+ ) : href.startsWith('/') ? (
137
+ <LangLink lng={lng} to={href} {...anchorProps} className="block">
138
+ {ImgSlot}
139
+ </LangLink>
140
+ ) : (
141
+ <a href={href} {...anchorProps} className="block">
142
+ {ImgSlot}
143
+ </a>
144
+ )
145
+
89
146
  if (showCaption) {
90
147
  return (
91
148
  <span className={classes}>
92
- {ImgSlot}
149
+ {Linked}
93
150
  <span className="block inline-image-block--caption">
94
151
  {caption?.editorState?.root?.children != null ? (
152
+ // The caption is a nested serialize pass and can hold its own
153
+ // links, so the surrounding options travel with it. Dropping
154
+ // them would let a caption link emit an anchor inside the text
155
+ // link this image sits in — the image's own anchor is already
156
+ // suppressed for that reason, and the caption is no different.
95
157
  serialize({
96
158
  nodes: caption.editorState.root.children,
97
159
  lng,
98
- options: { renderParagraphInline: true },
160
+ options: { ...options, renderParagraphInline: true },
99
161
  })
100
162
  ) : (
101
163
  <span>Caption not found for inline image.</span>
@@ -105,5 +167,5 @@ export function InlineImageSerializer({ node, serialize, lng, options }: Props):
105
167
  )
106
168
  }
107
169
 
108
- return <span className={classes}>{ImgSlot}</span>
170
+ return <span className={classes}>{Linked}</span>
109
171
  }
@@ -108,7 +108,7 @@ function isLocalHref(href: string): boolean {
108
108
  * walked sessions.
109
109
  * 4. Neither — strip wrapper.
110
110
  */
111
- function getHref(args: LinkAttributes): string {
111
+ export function getHref(args: LinkAttributes): string {
112
112
  let href = ''
113
113
  const publicWebsiteUrl = '/' // getPublicWebsiteUrl()
114
114
 
@@ -10,7 +10,7 @@ import { CodeSerializer } from '../../code/code-serializer.tsx'
10
10
  import { HeadingWithAnchorSerializer } from '../../heading-anchor/index.ts'
11
11
  import { InlineImageSerializer } from '../../inline-image/index.tsx'
12
12
  import { LayoutContainerSerializer, LayoutItemSerializer } from '../../layout/index.tsx'
13
- import { LinkLexicalSerializer } from '../../link/link-lexical.tsx'
13
+ import { getHref, LinkLexicalSerializer } from '../../link/link-lexical.tsx'
14
14
  import { ListItemSerializer, ListSerializer } from '../../list/index.ts'
15
15
  import { TableCellSerializer } from '../../table-cell/index.tsx'
16
16
  import { VimeoSerializer } from '../../vimeo/index.tsx'
@@ -30,6 +30,20 @@ import type { SerializedLexicalNode } from './types.ts'
30
30
  export interface SerializeOptions {
31
31
  renderParagraphInline: boolean
32
32
  disableAnimation?: boolean
33
+ /**
34
+ * True while serializing the children of a `link` node.
35
+ *
36
+ * An `<a>` may not contain another `<a>`: the HTML parser un-nests them,
37
+ * which silently changes the document structure and breaks the layout
38
+ * around it. An inline image carries its own optional click target, and
39
+ * an image inside a text link is reachable — a markdown import turns
40
+ * `[![alt](img)](url)` into a `link` node containing an `inline-image`
41
+ * node, and the image dialog can then give that image a link of its own.
42
+ *
43
+ * The outer link wins: it already makes the image clickable, which is
44
+ * what a reader expects from an image inside a link.
45
+ */
46
+ insideLink?: boolean
33
47
  }
34
48
 
35
49
  export interface SerializeProps {
@@ -148,9 +162,38 @@ export function Serialize({
148
162
  )
149
163
  }
150
164
  case 'link': {
165
+ // `LinkLexicalSerializer` renders its children plain when the
166
+ // href is unusable (an unresolved internal target, an empty
167
+ // custom url), so a link node does not always produce an
168
+ // anchor. `insideLink` must track the anchor, not the node —
169
+ // setting it unconditionally would strip a working image link
170
+ // for the benefit of an outer anchor that was never emitted.
171
+ //
172
+ // When an anchor IS emitted, children are re-serialized with
173
+ // the flag rather than reusing `serializedChildren`, so nested
174
+ // anchor-bearing nodes (an inline image with its own link)
175
+ // render without their own `<a>`.
176
+ //
177
+ // A link node reached while already inside an anchor renders its
178
+ // children plain. `<a>` inside `<a>` is un-nested by the parser
179
+ // whatever produced it, and a link node is just as capable of
180
+ // nesting as an inline image — an image caption, for instance,
181
+ // is a nested editor whose links arrive here with the flag set.
182
+ if (options?.insideLink === true) {
183
+ return <Fragment key={index}>{serializedChildren}</Fragment>
184
+ }
185
+ const rendersAnchor = getHref(node.attributes).length > 0
151
186
  return (
152
187
  <LinkLexicalSerializer key={index} attributes={node.attributes} lng={lng}>
153
- {serializedChildren}
188
+ {node.children != null && rendersAnchor ? (
189
+ <Serialize
190
+ nodes={node.children}
191
+ lng={lng}
192
+ options={{ ...options, insideLink: true }}
193
+ />
194
+ ) : (
195
+ serializedChildren
196
+ )}
154
197
  </LinkLexicalSerializer>
155
198
  )
156
199
  }
@@ -6,10 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import type {
10
- SerializedLexicalNode as LexicalSerializedLexicalNode,
11
- SerializedRootNode as LexicalSerializedRootNode,
12
- } from '@byline/richtext-lexical'
9
+ import type { SerializedRootNode as LexicalSerializedRootNode } from '@byline/richtext-lexical'
13
10
 
14
11
  /**
15
12
  * Root of a Lexical editor state — `editorState.root` from a persisted
@@ -33,10 +30,22 @@ export interface SerializedLexicalEditorState {
33
30
  * `checked`, `attributes`, `headerState`, `kind`, etc.) without
34
31
  * enumerating every node type.
35
32
  *
36
- * Structurally a superset of Lexical's `SerializedLexicalNode`, so
37
- * values of either flavor flow through the dispatcher without casts.
33
+ * Deliberately standalone rather than an intersection with Lexical's
34
+ * `SerializedLexicalNode`. Lexical 0.51 types a nested editor's children —
35
+ * an inline image's caption, say — as `SerializedPartialNode[]`, whose
36
+ * `version` and `$slots` relax because a compact export omits what parsing
37
+ * restores on its own. Intersecting with the strict type reimposes those
38
+ * requirements at every depth (`$slots` recurses), which would reject the
39
+ * partial form over properties this dispatcher never reads and force a cast
40
+ * at every nested-editor call site.
41
+ *
42
+ * The open index signature is what makes both flavours assignable, so
43
+ * strict and partial nodes alike flow through the dispatcher without casts.
38
44
  */
39
- export type SerializedLexicalNode = LexicalSerializedLexicalNode & {
45
+ export type SerializedLexicalNode = {
46
+ type: string
47
+ /** @deprecated Lexical writes it, nothing reads it; a compact export omits it. */
48
+ version?: number
40
49
  format?: number | string
41
50
  text?: string
42
51
  mode?: string
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": "6.4.0",
5
+ "version": "6.5.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -48,12 +48,12 @@
48
48
  "yaml": "^2.9.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@biomejs/biome": "2.5.13",
51
+ "@biomejs/biome": "2.5.14",
52
52
  "@types/mdast": "^4.0.4",
53
- "@types/node": "^26.6.1",
53
+ "@types/node": "^26.6.2",
54
54
  "@types/pg": "^8.23.1",
55
55
  "@types/semver": "^7.8.0",
56
- "dotenv": "^17.4.2",
56
+ "dotenv": "^18.0.1",
57
57
  "mdast-util-to-string": "^4.0.0",
58
58
  "remark-gfm": "^4.0.1",
59
59
  "remark-parse": "^11.0.0",
@@ -61,7 +61,7 @@
61
61
  "typescript": "^7.0.2",
62
62
  "unified": "^11.0.5",
63
63
  "vitest": "^5.0.1",
64
- "@byline/core": "6.4.0"
64
+ "@byline/core": "6.5.0"
65
65
  },
66
66
  "publishConfig": {
67
67
  "access": "public",