@tessera-editor/core 0.1.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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Word paste cleaning (acceptance §9, v1.1).
3
+ *
4
+ * Word export HTML is full of Office plumbing that ProseMirror's default
5
+ * schema-based cleaning handles badly (mso styles leak as text, namespace
6
+ * tags become unknown nodes, empty paragraphs multiply). When pasted HTML
7
+ * is detected as Word source, these rules run BEFORE PM parses it:
8
+ *
9
+ * R1 drop comments incl. `<!--[if ...]>...<![endif]-->` conditionals
10
+ * R2 drop <style>/<script>/<meta>/<link> blocks and tags
11
+ * R3 strip Office namespace tags entirely (<o:p>, <w:*, <st1:*>, <v:*>)
12
+ * R4 drop class / style / lang attributes (visual plumbing; bold/italic/
13
+ * underline survive via <b>/<i>/<u> tags which PM keeps)
14
+ * R5 drop Word list markers ("l" bullets in mso-list spans are removed
15
+ * with R3/R4; list paragraphs become plain paragraphs — semantic list
16
+ * reconstruction is explicitly out of scope for v1.1)
17
+ * R6 &nbsp; collapses to a normal space
18
+ * R7 empty paragraphs (<p></p>, <p><br></p>, whitespace-only) are removed
19
+ */
20
+
21
+ export function isWordHtml(html: string): boolean {
22
+ return (
23
+ html.includes('urn:schemas-microsoft-com:office:word') ||
24
+ html.includes('urn:schemas-microsoft-com:office:office') ||
25
+ /mso-[\w-]+/.test(html) ||
26
+ /\bMso\w+/.test(html) ||
27
+ /<w:/i.test(html) ||
28
+ /<o:/i.test(html)
29
+ )
30
+ }
31
+
32
+ export function cleanWordHtml(html: string): string {
33
+ let out = html
34
+ // R1 comments + conditionals
35
+ out = out.replace(/<!--\[if[\s\S]*?<!\[endif\]-->/gi, '')
36
+ out = out.replace(/<!--[\s\S]*?-->/g, '')
37
+ // R2 style/script blocks, meta/link tags, xml prolog
38
+ out = out.replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, '')
39
+ out = out.replace(/<(meta|link)\b[^>]*>/gi, '')
40
+ out = out.replace(/<\?xml[^>]*\?>/gi, '')
41
+ out = out.replace(/<!DOCTYPE[^>]*>/gi, '')
42
+ // R3 office namespace tags
43
+ out = out.replace(/<\/?[a-z][a-z0-9]*:[^>]*>/gi, '')
44
+ // R4 visual-plumbing attributes (double- and single-quoted)
45
+ out = out.replace(/\s(class|style|lang|xml:lang|face)\s*=\s*"[^"]*"/gi, '')
46
+ out = out.replace(/\s(class|style|lang|xml:lang|face)\s*=\s*'[^']*'/gi, '')
47
+ // R6 non-breaking spaces
48
+ out = out.replace(/&nbsp;/gi, ' ')
49
+ // R7 empty paragraphs (iterate: nested whitespace collapses progressively)
50
+ let prev = ''
51
+ while (out !== prev) {
52
+ prev = out
53
+ out = out.replace(/<p\b[^>]*>(\s|<br\s*\/?>)*<\/p>/gi, '')
54
+ }
55
+ return out.trim()
56
+ }
@@ -0,0 +1,156 @@
1
+ import type { Editor, JSONContent } from '@tiptap/core'
2
+
3
+ /**
4
+ * Write-back protocol (ADR-0001 / product-definition §4): AI and host code
5
+ * mutate the document by stable block IDs — never whole-doc rewrites.
6
+ * Semantics mirror SliteML's modifyRange / appendBlocks / removeBlocks.
7
+ */
8
+
9
+ export interface BlockHandle {
10
+ /** stable block id (UniqueID extension); null when the block predates ids */
11
+ id: string | null
12
+ type: string
13
+ pos: number
14
+ node: import('@tiptap/pm/model').Node
15
+ }
16
+
17
+ /** Top-level blocks with their ids, in document order. */
18
+ export function getTopLevelBlocks(editor: Editor): BlockHandle[] {
19
+ const blocks: BlockHandle[] = []
20
+ editor.state.doc.forEach((node, offset) => {
21
+ blocks.push({ id: (node.attrs.id as string | undefined) ?? null, type: node.type.name, pos: offset, node })
22
+ })
23
+ return blocks
24
+ }
25
+
26
+ function findBlockPosById(editor: Editor, id: string): number | null {
27
+ let found: number | null = null
28
+ editor.state.doc.forEach((node, offset) => {
29
+ if (found === null && node.attrs.id === id) {
30
+ found = offset
31
+ }
32
+ })
33
+ return found
34
+ }
35
+
36
+ function newBlockId(): string {
37
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
38
+ return crypto.randomUUID()
39
+ }
40
+ return `tessera-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
41
+ }
42
+
43
+ function resolveBlocks(editor: Editor, blocks: JSONContent[]) {
44
+ return blocks.map(json => {
45
+ const clean = { ...json }
46
+ // Write-back content carries fresh ids of its own: UniqueID skips nodes
47
+ // that already have one, so the ids land inside the same transaction
48
+ // (single undo step) instead of a follow-up append.
49
+ clean.attrs = { ...(clean.attrs ?? {}), id: newBlockId() }
50
+ return editor.state.schema.nodeFromJSON(clean)
51
+ })
52
+ }
53
+
54
+ export interface ModifyRangeOptions {
55
+ /** first block to replace (default: first block in doc) */
56
+ fromId?: string
57
+ /** last block to replace, inclusive (default: fromId) */
58
+ toId?: string
59
+ content: JSONContent[]
60
+ }
61
+
62
+ /**
63
+ * Replace the inclusive block range [fromId … toId] with `content`.
64
+ * One transaction → one undo step.
65
+ */
66
+ export function modifyRange(editor: Editor, options: ModifyRangeOptions): boolean {
67
+ const blocks = getTopLevelBlocks(editor).filter(b => b.id)
68
+ if (blocks.length === 0) {
69
+ return false
70
+ }
71
+ const ids = blocks.map(b => b.id as string)
72
+ const fromId = options.fromId ?? ids[0]
73
+ const toId = options.toId ?? fromId
74
+
75
+ let fromIndex = ids.indexOf(fromId)
76
+ let toIndex = ids.indexOf(toId)
77
+ if (fromIndex === -1 || toIndex === -1) {
78
+ return false
79
+ }
80
+ if (fromIndex > toIndex) {
81
+ ;[fromIndex, toIndex] = [toIndex, fromIndex]
82
+ }
83
+
84
+ const fromBlock = blocks[fromIndex]
85
+ const toBlock = blocks[toIndex]
86
+ const from = fromBlock.pos
87
+ const to = toBlock.pos + toBlock.node.nodeSize
88
+
89
+ const nodes = resolveBlocks(editor, options.content)
90
+ const tr = editor.state.tr
91
+ tr.replaceWith(from, to, nodes)
92
+ editor.view.dispatch(tr)
93
+ return true
94
+ }
95
+
96
+ export interface AppendBlocksOptions {
97
+ /** insert after this block (default: end of document) */
98
+ afterId?: string
99
+ content: JSONContent[]
100
+ }
101
+
102
+ /** Append blocks after `afterId` (or at the document end). */
103
+ export function appendBlocks(editor: Editor, options: AppendBlocksOptions): boolean {
104
+ const nodes = resolveBlocks(editor, options.content)
105
+ if (nodes.length === 0) {
106
+ return false
107
+ }
108
+ let insertPos = editor.state.doc.content.size
109
+ if (options.afterId) {
110
+ const pos = findBlockPosById(editor, options.afterId)
111
+ if (pos === null) {
112
+ return false
113
+ }
114
+ const node = editor.state.doc.nodeAt(pos)
115
+ if (!node) {
116
+ return false
117
+ }
118
+ insertPos = pos + node.nodeSize
119
+ }
120
+ const tr = editor.state.tr
121
+ tr.insert(insertPos, nodes)
122
+ editor.view.dispatch(tr)
123
+ return true
124
+ }
125
+
126
+ /** Remove blocks by ids. One transaction → one undo step. */
127
+ export function removeBlocks(editor: Editor, ids: string[]): boolean {
128
+ if (ids.length === 0) {
129
+ return false
130
+ }
131
+ const ranges: { from: number; to: number }[] = []
132
+ editor.state.doc.forEach((node, offset) => {
133
+ if (node.attrs.id && ids.includes(node.attrs.id)) {
134
+ ranges.push({ from: offset, to: offset + node.nodeSize })
135
+ }
136
+ })
137
+ if (ranges.length === 0) {
138
+ return false
139
+ }
140
+ const tr = editor.state.tr
141
+ for (let i = ranges.length - 1; i >= 0; i--) {
142
+ tr.delete(ranges[i].from, ranges[i].to)
143
+ }
144
+ editor.view.dispatch(tr)
145
+ return true
146
+ }
147
+
148
+ /** Read a block (and its subtree) by id as canonical JSON. */
149
+ export function getBlockJson(editor: Editor, id: string): JSONContent | null {
150
+ const pos = findBlockPosById(editor, id)
151
+ if (pos === null) {
152
+ return null
153
+ }
154
+ const node = editor.state.doc.nodeAt(pos)
155
+ return node ? node.toJSON() : null
156
+ }