@growth-labs/cms 0.5.10 → 0.5.12

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.
Files changed (74) hide show
  1. package/README.md +28 -0
  2. package/dist/engine/index.d.ts +3 -2
  3. package/dist/engine/index.d.ts.map +1 -1
  4. package/dist/engine/index.js +3 -2
  5. package/dist/engine/index.js.map +1 -1
  6. package/dist/engine/publication.d.ts.map +1 -1
  7. package/dist/engine/publication.js +23 -5
  8. package/dist/engine/publication.js.map +1 -1
  9. package/dist/engine/published-content.d.ts.map +1 -1
  10. package/dist/engine/published-content.js +14 -5
  11. package/dist/engine/published-content.js.map +1 -1
  12. package/dist/engine/publisher.d.ts +3 -1
  13. package/dist/engine/publisher.d.ts.map +1 -1
  14. package/dist/engine/publisher.js +39 -31
  15. package/dist/engine/publisher.js.map +1 -1
  16. package/dist/engine/revisions.d.ts +3 -0
  17. package/dist/engine/revisions.d.ts.map +1 -1
  18. package/dist/engine/revisions.js +7 -1
  19. package/dist/engine/revisions.js.map +1 -1
  20. package/dist/engine/tags.d.ts +9 -2
  21. package/dist/engine/tags.d.ts.map +1 -1
  22. package/dist/engine/tags.js +32 -21
  23. package/dist/engine/tags.js.map +1 -1
  24. package/dist/engine/taxonomy.d.ts +31 -0
  25. package/dist/engine/taxonomy.d.ts.map +1 -0
  26. package/dist/engine/taxonomy.js +125 -0
  27. package/dist/engine/taxonomy.js.map +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/index.js.map +1 -1
  31. package/dist/routes/content.d.ts.map +1 -1
  32. package/dist/routes/content.js +80 -24
  33. package/dist/routes/content.js.map +1 -1
  34. package/dist/routes/tags.d.ts.map +1 -1
  35. package/dist/routes/tags.js +36 -3
  36. package/dist/routes/tags.js.map +1 -1
  37. package/dist/schema/migrations.d.ts.map +1 -1
  38. package/dist/schema/migrations.js +15 -1
  39. package/dist/schema/migrations.js.map +1 -1
  40. package/dist/schema/types.d.ts +1 -0
  41. package/dist/schema/types.d.ts.map +1 -1
  42. package/dist/schema/types.js.map +1 -1
  43. package/dist/ui/editor/Rte.d.ts.map +1 -1
  44. package/dist/ui/editor/Rte.js +2 -12
  45. package/dist/ui/editor/Rte.js.map +1 -1
  46. package/dist/ui/editor/extensions.d.ts +3 -0
  47. package/dist/ui/editor/extensions.d.ts.map +1 -0
  48. package/dist/ui/editor/extensions.js +18 -0
  49. package/dist/ui/editor/extensions.js.map +1 -0
  50. package/dist/ui/editor/serialize.d.ts.map +1 -1
  51. package/dist/ui/editor/serialize.js +4 -2
  52. package/dist/ui/editor/serialize.js.map +1 -1
  53. package/dist/ui/editor/trim-boundary-marks.d.ts +11 -0
  54. package/dist/ui/editor/trim-boundary-marks.d.ts.map +1 -0
  55. package/dist/ui/editor/trim-boundary-marks.js +157 -0
  56. package/dist/ui/editor/trim-boundary-marks.js.map +1 -0
  57. package/migrations/0023_content_tag_link_labels.sql +11 -0
  58. package/package.json +1 -1
  59. package/src/engine/index.ts +14 -0
  60. package/src/engine/publication.ts +27 -12
  61. package/src/engine/published-content.ts +14 -8
  62. package/src/engine/publisher.ts +76 -39
  63. package/src/engine/revisions.ts +10 -1
  64. package/src/engine/tags.ts +39 -19
  65. package/src/engine/taxonomy.ts +226 -0
  66. package/src/index.ts +2 -0
  67. package/src/routes/content.ts +72 -26
  68. package/src/routes/tags.ts +36 -3
  69. package/src/schema/migrations.ts +15 -1
  70. package/src/schema/types.ts +1 -0
  71. package/src/ui/editor/Rte.tsx +2 -12
  72. package/src/ui/editor/extensions.ts +18 -0
  73. package/src/ui/editor/serialize.ts +4 -2
  74. package/src/ui/editor/trim-boundary-marks.ts +181 -0
@@ -19,6 +19,7 @@
19
19
  // :::tweet\n<url>\n::: tweet-embed directive
20
20
 
21
21
  import type { JSONContent } from '@tiptap/core'
22
+ import { normalizeBoundaryMarks } from './trim-boundary-marks.js'
22
23
 
23
24
  // ─────────────────────────────────────────────────────────────────────────────
24
25
  // docToMarkdown — JSONContent → string
@@ -26,8 +27,9 @@ import type { JSONContent } from '@tiptap/core'
26
27
 
27
28
  /** Serialise a Tiptap JSONContent document to a markdown string. */
28
29
  export function docToMarkdown(doc: JSONContent): string {
29
- if (!doc.content || doc.content.length === 0) return ''
30
- const lines = doc.content.map((node) => serializeBlock(node)).filter((s) => s !== null)
30
+ const normalized = normalizeBoundaryMarks(doc)
31
+ if (!normalized.content || normalized.content.length === 0) return ''
32
+ const lines = normalized.content.map((node) => serializeBlock(node)).filter((s) => s !== null)
31
33
  return lines.join('\n\n')
32
34
  }
33
35
 
@@ -0,0 +1,181 @@
1
+ import { Extension, type JSONContent } from '@tiptap/core'
2
+ import type { Mark, MarkType, Node as ProseMirrorNode } from '@tiptap/pm/model'
3
+ import { Plugin } from '@tiptap/pm/state'
4
+
5
+ const BOUNDARY_MARKS = ['link', 'underline'] as const
6
+
7
+ function markKey(mark: NonNullable<JSONContent['marks']>[number]): string {
8
+ return `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`
9
+ }
10
+
11
+ function sameMarks(
12
+ a: JSONContent['marks'] | undefined,
13
+ b: JSONContent['marks'] | undefined,
14
+ ): boolean {
15
+ return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
16
+ }
17
+
18
+ function mergeAdjacentText(nodes: JSONContent[]): JSONContent[] {
19
+ const merged: JSONContent[] = []
20
+ for (const node of nodes) {
21
+ const previous = merged.at(-1)
22
+ if (
23
+ previous?.type === 'text' &&
24
+ node.type === 'text' &&
25
+ sameMarks(previous.marks, node.marks)
26
+ ) {
27
+ previous.text = (previous.text ?? '') + (node.text ?? '')
28
+ } else {
29
+ merged.push(node)
30
+ }
31
+ }
32
+ return merged
33
+ }
34
+
35
+ function trimJsonMark(nodes: JSONContent[], markType: (typeof BOUNDARY_MARKS)[number]) {
36
+ const output: JSONContent[] = []
37
+ let index = 0
38
+
39
+ while (index < nodes.length) {
40
+ const node = nodes[index]
41
+ const mark =
42
+ node.type === 'text'
43
+ ? node.marks?.find((candidate) => candidate.type === markType)
44
+ : undefined
45
+ if (!mark) {
46
+ output.push(node)
47
+ index += 1
48
+ continue
49
+ }
50
+
51
+ const key = markKey(mark)
52
+ const run: JSONContent[] = []
53
+ while (index < nodes.length) {
54
+ const candidate = nodes[index]
55
+ const candidateMark =
56
+ candidate.type === 'text'
57
+ ? candidate.marks?.find((item) => item.type === markType && markKey(item) === key)
58
+ : undefined
59
+ if (!candidateMark) break
60
+ run.push(candidate)
61
+ index += 1
62
+ }
63
+
64
+ const text = run.map((item) => item.text ?? '').join('')
65
+ const leading = text.match(/^\s+/u)?.[0].length ?? 0
66
+ const trailing = text.match(/\s+$/u)?.[0].length ?? 0
67
+ const markedEnd = Math.max(leading, text.length - trailing)
68
+ let offset = 0
69
+
70
+ for (const item of run) {
71
+ const itemText = item.text ?? ''
72
+ const itemStart = offset
73
+ const itemEnd = offset + itemText.length
74
+ const cuts = [itemStart, Math.max(itemStart, leading), Math.min(itemEnd, markedEnd), itemEnd]
75
+ .filter(
76
+ (value, cutIndex, all) =>
77
+ value >= itemStart && value <= itemEnd && all.indexOf(value) === cutIndex,
78
+ )
79
+ .sort((a, b) => a - b)
80
+
81
+ for (let cutIndex = 0; cutIndex < cuts.length - 1; cutIndex += 1) {
82
+ const from = cuts[cutIndex]
83
+ const to = cuts[cutIndex + 1]
84
+ if (from === to) continue
85
+ const keepMark = from >= leading && to <= markedEnd
86
+ const marks = keepMark
87
+ ? item.marks
88
+ : item.marks?.filter((candidate) => candidate.type !== markType)
89
+ output.push({
90
+ ...item,
91
+ text: itemText.slice(from - itemStart, to - itemStart),
92
+ ...(marks?.length ? { marks } : { marks: undefined }),
93
+ })
94
+ }
95
+ offset = itemEnd
96
+ }
97
+ }
98
+
99
+ return mergeAdjacentText(output)
100
+ }
101
+
102
+ /**
103
+ * Remove link and underline marks from leading/trailing whitespace in every
104
+ * inline mark run. Underline follows the same rule as links: whitespace is
105
+ * layout, not editorial emphasis, and pasted underline is the visible half of
106
+ * the malformed-link report.
107
+ */
108
+ export function normalizeBoundaryMarks(node: JSONContent): JSONContent {
109
+ if (!node.content) return node
110
+ let content = node.content.map(normalizeBoundaryMarks)
111
+ for (const markType of BOUNDARY_MARKS) content = trimJsonMark(content, markType)
112
+ return { ...node, content }
113
+ }
114
+
115
+ function matchingMark(
116
+ node: ProseMirrorNode,
117
+ markType: MarkType,
118
+ previous?: Mark,
119
+ ): Mark | undefined {
120
+ const mark = markType.isInSet(node.marks)
121
+ if (!mark) return undefined
122
+ return previous && !mark.eq(previous) ? undefined : mark
123
+ }
124
+
125
+ /** Keep the live Tiptap document visually consistent after toolbar and paste transactions. */
126
+ export const TrimBoundaryMarks = Extension.create({
127
+ name: 'trimBoundaryMarks',
128
+
129
+ addProseMirrorPlugins() {
130
+ return [
131
+ new Plugin({
132
+ appendTransaction: (transactions, _oldState, newState) => {
133
+ if (!transactions.some((transaction) => transaction.docChanged)) return null
134
+ const transaction = newState.tr
135
+
136
+ newState.doc.descendants((parent, parentPosition) => {
137
+ if (!parent.isTextblock) return true
138
+
139
+ for (const markName of BOUNDARY_MARKS) {
140
+ const markType = newState.schema.marks[markName]
141
+ if (!markType) continue
142
+ const children: Array<{ node: ProseMirrorNode; from: number }> = []
143
+ parent.forEach((child, offset) => {
144
+ if (child.isText) children.push({ node: child, from: parentPosition + 1 + offset })
145
+ })
146
+
147
+ let index = 0
148
+ while (index < children.length) {
149
+ const first = children[index]
150
+ const mark = matchingMark(first.node, markType)
151
+ if (!mark) {
152
+ index += 1
153
+ continue
154
+ }
155
+
156
+ const run: typeof children = []
157
+ while (
158
+ index < children.length &&
159
+ matchingMark(children[index].node, markType, mark)
160
+ ) {
161
+ run.push(children[index])
162
+ index += 1
163
+ }
164
+ const text = run.map((item) => item.node.text ?? '').join('')
165
+ const leading = text.match(/^\s+/u)?.[0].length ?? 0
166
+ const trailing = text.match(/\s+$/u)?.[0].length ?? 0
167
+ const runFrom = run[0].from
168
+ const runTo = run.at(-1)!.from + (run.at(-1)!.node.text?.length ?? 0)
169
+ if (leading) transaction.removeMark(runFrom, runFrom + leading, markType)
170
+ if (trailing) transaction.removeMark(runTo - trailing, runTo, markType)
171
+ }
172
+ }
173
+ return false
174
+ })
175
+
176
+ return transaction.steps.length ? transaction : null
177
+ },
178
+ }),
179
+ ]
180
+ },
181
+ })