dsh-paste-code-block 0.2.5 → 0.2.7
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/LICENSE +21 -21
- package/README.md +118 -118
- package/README.zh-CN.md +118 -118
- package/cordis.patch.yml +4 -4
- package/docs/design.md +373 -337
- package/docs/images/hero.svg +3 -3
- package/docs/images/languages.svg +3 -3
- package/docs/images/render.cjs +20 -20
- package/docs/images/workflow.svg +3 -3
- package/package.json +3 -2
- package/src/chip-nodes.js +46 -46
- package/src/client.js +279 -62
- package/src/i18n.js +140 -140
- package/src/index.js +11 -11
- package/src/parse.js +159 -6
- package/src/projection.js +54 -54
- package/tests/chip-nodes.test.js +71 -0
- package/tests/fixtures.js +9 -0
- package/tests/fold-smoke.test.js +642 -0
- package/tests/fold.test.js +147 -0
- package/tests/i18n.test.js +112 -0
- package/tests/lexical-chip-probe.mjs +109 -0
- package/tests/parse.test.js +183 -0
- package/tests/projection.test.js +64 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Regression tests for the "pasted block is rendered wrong in the sent message" bug.
|
|
2
|
+
//
|
|
3
|
+
// Two real shapes are under test (see fixtures.js, captured from the user's own
|
|
4
|
+
// session logs):
|
|
5
|
+
// P the prose the user originally pasted
|
|
6
|
+
// W what 0.2.5 serialised it to - a clean ```text fence plus a typed tail
|
|
7
|
+
// B what 0.2.5 produced when W was pasted back in: a BARE fence wrapped
|
|
8
|
+
// around an already-fenced text, which is what broke the transcript.
|
|
9
|
+
//
|
|
10
|
+
// Run: node --test (from the package root)
|
|
11
|
+
import test from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
parseBlock,
|
|
16
|
+
serializeBlock,
|
|
17
|
+
splitFencedSegments,
|
|
18
|
+
wholeFence,
|
|
19
|
+
fenceMarkerFor,
|
|
20
|
+
fenceRanges,
|
|
21
|
+
planFoldRuns,
|
|
22
|
+
} from '../src/parse.js'
|
|
23
|
+
import { W, B, B2, P } from './fixtures.js'
|
|
24
|
+
|
|
25
|
+
const lf = (s) => s.replace(/\r\n/g, '\n')
|
|
26
|
+
const unwrap = (s) => s.replace(/^\n/, '').replace(/\n$/, '')
|
|
27
|
+
|
|
28
|
+
// DSH renders a sent user message as ONE plain-text run per @mention chip: the
|
|
29
|
+
// chip keeps only the label (the part after the last "/"), so the run before it
|
|
30
|
+
// stops at the "@". Mirrors what the transcript really contains.
|
|
31
|
+
function dshRuns(message) {
|
|
32
|
+
const out = []
|
|
33
|
+
const re = /@([A-Za-z0-9._-]+\/[A-Za-z0-9._/-]+)/g
|
|
34
|
+
let last = 0
|
|
35
|
+
let m
|
|
36
|
+
while ((m = re.exec(message)) !== null) {
|
|
37
|
+
out.push(message.slice(last, m.index))
|
|
38
|
+
out.push(m[1].split('/').pop())
|
|
39
|
+
last = m.index + m[0].length
|
|
40
|
+
}
|
|
41
|
+
out.push(message.slice(last))
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('a plain prose paste becomes a TEXT block with one clean fence', () => {
|
|
46
|
+
const block = parseBlock(P)
|
|
47
|
+
assert.equal(block.isCode, false)
|
|
48
|
+
assert.equal(unwrap(serializeBlock(block)), '```text\n' + P + '\n```')
|
|
49
|
+
// ... which is exactly the prefix of the message the user actually sent.
|
|
50
|
+
assert.ok(W.startsWith('```text\n' + P))
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('0.2.5 broke when the previous message was pasted back (real captured data)', () => {
|
|
54
|
+
// B is W wrapped in one extra BARE fence - the reported breakage.
|
|
55
|
+
assert.ok(B.startsWith('```\n```text'))
|
|
56
|
+
assert.equal(lf(B), lf('```\n' + W + '\n```'))
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('re-pasting the previous message now stays a TEXT block (the user bug)', () => {
|
|
60
|
+
const block = parseBlock(W)
|
|
61
|
+
assert.equal(block.isCode, false, 'W was mis-typed as CODE by 0.2.5')
|
|
62
|
+
const sent = serializeBlock(block)
|
|
63
|
+
// The wrapper marker grows so the inner ``` fence can never close it.
|
|
64
|
+
assert.ok(sent.startsWith('\n````text\n'), 'wrapper marker must grow past the body fences')
|
|
65
|
+
assert.equal(unwrap(sent), '````text\n' + W + '\n````')
|
|
66
|
+
assert.notEqual(unwrap(sent), B, 'must not reproduce the 0.2.5 output')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('the whole-bubble plan sees the fence that straddles DSH mention chips', () => {
|
|
70
|
+
const runs = dshRuns(B2)
|
|
71
|
+
assert.deepEqual(runs.map((r) => r.length), [16, 37, 71, 37, 281])
|
|
72
|
+
const plan = planFoldRuns(B2, runs)
|
|
73
|
+
assert.equal(plan.mode, 'bubble')
|
|
74
|
+
assert.equal(plan.fences.length, 1)
|
|
75
|
+
// Same answer when the message still carries CRLF from the Windows clipboard.
|
|
76
|
+
assert.equal(planFoldRuns(B, dshRuns(B)).mode, 'bubble')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('per-run scanning is what produced the bogus one-line card', () => {
|
|
80
|
+
for (const [msg, tailBody] of [[B, '解决这个'], [B2, '测试,不需要执行']]) {
|
|
81
|
+
const runs = dshRuns(msg)
|
|
82
|
+
const tail = runs[runs.length - 1]
|
|
83
|
+
const fences = splitFencedSegments(tail).filter((s) => s.kind === 'fence')
|
|
84
|
+
assert.equal(fences.length, 1)
|
|
85
|
+
assert.equal(fences[0].body.trim(), tailBody)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('a fence that sits inside a single run keeps the per-run path', () => {
|
|
90
|
+
const msg = '看这个 @a/b\n```js\nconst a = 1\n```\n谢谢'
|
|
91
|
+
const runs = dshRuns(msg)
|
|
92
|
+
const plan = planFoldRuns(msg, runs)
|
|
93
|
+
assert.equal(plan.mode, 'single')
|
|
94
|
+
assert.deepEqual(plan.runs, [2])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('prose alone plans no fold', () => {
|
|
98
|
+
assert.equal(planFoldRuns(P, [P]), null)
|
|
99
|
+
assert.equal(planFoldRuns('hello\nworld\n', ['hello\nworld\n']), null)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('fenceRanges agrees with splitFencedSegments', () => {
|
|
103
|
+
const samples = [W, B, B2, '````text\na\n```\nb\n```\n````', '前言\n```\nx\n```\n后记']
|
|
104
|
+
for (const s of samples) {
|
|
105
|
+
const segs = splitFencedSegments(s).filter((x) => x.kind === 'fence')
|
|
106
|
+
const ranges = fenceRanges(s)
|
|
107
|
+
assert.equal(ranges.length, segs.length, `range count for ${JSON.stringify(s.slice(0, 12))}`)
|
|
108
|
+
// splitFencedSegments rebuilds raw/body from line arrays, so it normalises
|
|
109
|
+
// CRLF to LF; the offsets themselves must still line up exactly.
|
|
110
|
+
segs.forEach((seg, i) => assert.equal(lf(s.slice(ranges[i].start, ranges[i].end)), seg.raw))
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('a clean fenced paste takes its type from the info string', () => {
|
|
115
|
+
assert.equal(parseBlock('```text\nhello\n```').isCode, false)
|
|
116
|
+
assert.equal(parseBlock('```text\nhello\n```').lang, '')
|
|
117
|
+
const js = parseBlock('```js\nconst a = 1\n```')
|
|
118
|
+
assert.equal(js.isCode, true)
|
|
119
|
+
assert.equal(js.lang, 'js')
|
|
120
|
+
assert.equal(unwrap(serializeBlock(js)), '```js\nconst a = 1\n```')
|
|
121
|
+
// Bare fence with no info string stays code.
|
|
122
|
+
assert.equal(parseBlock('```\nhello\n```').isCode, true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('wholeFence only matches a fence that is the whole paste', () => {
|
|
126
|
+
assert.equal(wholeFence('```text\nx\n```').lang, 'text')
|
|
127
|
+
assert.equal(wholeFence('```text\nx\n```\n尾注'), null)
|
|
128
|
+
assert.ok(wholeFence('\n```text\nx\n```\n'), 'blank lines around the fence are tolerated')
|
|
129
|
+
assert.equal(wholeFence('前言\n```text\nx\n```'), null)
|
|
130
|
+
assert.equal(wholeFence('no fence here'), null)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('the wrapper marker always outgrows the body', () => {
|
|
134
|
+
assert.equal(fenceMarkerFor('no fences'), '```')
|
|
135
|
+
assert.equal(fenceMarkerFor('a\n```\nb\n```'), '````')
|
|
136
|
+
assert.equal(fenceMarkerFor('`````'), '``````')
|
|
137
|
+
const nested = unwrap(serializeBlock(parseBlock(W)))
|
|
138
|
+
assert.equal(fenceMarkerFor(nested), '`````')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('re-pasting the fixed output is stable', () => {
|
|
142
|
+
const once = unwrap(serializeBlock(parseBlock(W)))
|
|
143
|
+
const twice = unwrap(serializeBlock(parseBlock(once)))
|
|
144
|
+
// Stable modulo the CRLF -> LF normalisation splitFencedSegments performs.
|
|
145
|
+
assert.equal(lf(twice), lf(once))
|
|
146
|
+
assert.equal(planFoldRuns(once, [once]).mode, 'single')
|
|
147
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import {
|
|
4
|
+
NS, zh, en, dictionaries, createT, blockLabel, parseBlockLabel, detectBrowserLocale, foldTitle,
|
|
5
|
+
} from '../src/i18n.js'
|
|
6
|
+
|
|
7
|
+
test('namespace is the plugin source name', () => {
|
|
8
|
+
assert.equal(NS, 'paste-code-block')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('zh and en dictionaries carry identical key sets', () => {
|
|
12
|
+
const zhKeys = Object.keys(zh).sort()
|
|
13
|
+
const enKeys = Object.keys(en).sort()
|
|
14
|
+
assert.deepEqual(zhKeys, enKeys)
|
|
15
|
+
assert.ok(zhKeys.length >= 10, 'dictionaries should be reasonably complete')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('block labels render in both locales with the number interpolated', () => {
|
|
19
|
+
assert.equal(blockLabel(createT('zh'), 'code', 2), '代码块 2')
|
|
20
|
+
assert.equal(blockLabel(createT('zh'), 'text', 7), '文本块 7')
|
|
21
|
+
assert.equal(blockLabel(createT('en'), 'code', 2), 'Code #2')
|
|
22
|
+
assert.equal(blockLabel(createT('en'), 'text', 7), 'Text #7')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('chip names stay action-free (no “复制” wording on reference chips)', () => {
|
|
26
|
+
// The chip is a reference badge, not a copy button; the old 0.1.2 names
|
|
27
|
+
// read like an imperative and were dropped in 0.1.3.
|
|
28
|
+
assert.ok(!zh['block.code'].includes('复制'))
|
|
29
|
+
assert.ok(!zh['block.text'].includes('复制'))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('parseBlockLabel round-trips every locale and both block types', () => {
|
|
33
|
+
for (const locale of ['zh', 'en']) {
|
|
34
|
+
const t = createT(locale)
|
|
35
|
+
for (const type of ['code', 'text']) {
|
|
36
|
+
for (const n of [1, 2, 9, 10, 123]) {
|
|
37
|
+
const label = blockLabel(t, type, n)
|
|
38
|
+
assert.deepEqual(parseBlockLabel(label), { type, n }, `${locale} ${type} #${n}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a label in one language still parses while the other is active', () => {
|
|
45
|
+
// Chips keep the insert-time label until the client retitles them; identity
|
|
46
|
+
// recovery must be locale-independent either way.
|
|
47
|
+
assert.deepEqual(parseBlockLabel('代码块 3'), { type: 'code', n: 3 })
|
|
48
|
+
assert.deepEqual(parseBlockLabel('Text #4'), { type: 'text', n: 4 })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('legacy pre-0.1.3 labels still resolve to their block identity', () => {
|
|
52
|
+
// Chips inserted by an older bundle keep working until the next retitle.
|
|
53
|
+
assert.deepEqual(parseBlockLabel('复制代码块3'), { type: 'code', n: 3 })
|
|
54
|
+
assert.deepEqual(parseBlockLabel('复制文本块12'), { type: 'text', n: 12 })
|
|
55
|
+
assert.deepEqual(parseBlockLabel('Code block #4'), { type: 'code', n: 4 })
|
|
56
|
+
assert.deepEqual(parseBlockLabel('Text block #1'), { type: 'text', n: 1 })
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('parseBlockLabel rejects anything that is not a block label', () => {
|
|
60
|
+
assert.equal(parseBlockLabel(''), null)
|
|
61
|
+
assert.equal(parseBlockLabel(null), null)
|
|
62
|
+
assert.equal(parseBlockLabel('Code block x'), null)
|
|
63
|
+
assert.equal(parseBlockLabel('Code block #x'), null)
|
|
64
|
+
assert.equal(parseBlockLabel('复制代码块'), null)
|
|
65
|
+
assert.equal(parseBlockLabel('复制文本块abc'), null)
|
|
66
|
+
assert.equal(parseBlockLabel('Hello world'), null)
|
|
67
|
+
assert.equal(parseBlockLabel('Pasted Code Block 1'), null)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('createT interpolates params, falls back to en, then to the raw key', () => {
|
|
71
|
+
const t = createT('zh')
|
|
72
|
+
assert.equal(t('lines.other', { count: 5 }), '5 行')
|
|
73
|
+
assert.equal(createT('en')('lines.one', { count: 1 }), '1 line')
|
|
74
|
+
assert.equal(createT('en')('lines.other', { count: 2 }), '2 lines')
|
|
75
|
+
// missing param keeps the placeholder
|
|
76
|
+
assert.equal(t('lines.other'), '{count} 行')
|
|
77
|
+
// unknown key with an en counterpart falls through to en
|
|
78
|
+
assert.equal(t('detail.aria'), '代码块详情')
|
|
79
|
+
assert.equal(createT('fr')('detail.aria'), 'Block details') // unknown locale -> en
|
|
80
|
+
// unknown key everywhere -> the key itself
|
|
81
|
+
assert.equal(t('no.such.key'), 'no.such.key')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('dictionaries map exposes both locales', () => {
|
|
85
|
+
assert.deepEqual(Object.keys(dictionaries).sort(), ['en', 'zh'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ===== foldTitle (sent-message fold cards) ==================================
|
|
89
|
+
test('fold title localizes the type name and line count', () => {
|
|
90
|
+
assert.equal(foldTitle(createT('zh'), 'text', '', 128), '文本块 · 128 行')
|
|
91
|
+
assert.equal(foldTitle(createT('en'), 'text', '', 128), 'Text · 128 lines')
|
|
92
|
+
assert.equal(foldTitle(createT('en'), 'text', '', 1), 'Text · 1 line')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('a code card shows the fence language instead of the type name', () => {
|
|
96
|
+
assert.equal(foldTitle(createT('zh'), 'code', 'python', 42), 'python · 42 行')
|
|
97
|
+
assert.equal(foldTitle(createT('en'), 'code', 'python', 42), 'python · 42 lines')
|
|
98
|
+
// Untagged code falls back to the localized type name.
|
|
99
|
+
assert.equal(foldTitle(createT('zh'), 'code', '', 3), '代码块 · 3 行')
|
|
100
|
+
assert.equal(foldTitle(createT('en'), 'code', '', 3), 'Code · 3 lines')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('fold dictionary keys exist in both locales', () => {
|
|
104
|
+
for (const key of ['fold.code', 'fold.text', 'fold.aria']) {
|
|
105
|
+
assert.ok(zh[key], `zh missing ${key}`)
|
|
106
|
+
assert.ok(en[key], `en missing ${key}`)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('detectBrowserLocale never throws outside a browser', () => {
|
|
111
|
+
assert.ok(['zh', 'en'].includes(detectBrowserLocale()))
|
|
112
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Reproduction probe for the chip-removal node-map shape (NOT part of the test
|
|
2
|
+
// glob: it needs optional deps, so CI never runs it).
|
|
3
|
+
//
|
|
4
|
+
// npm i --no-save lexical@0.49.0 @lexical/headless@0.49.0
|
|
5
|
+
// node tests/lexical-chip-probe.mjs
|
|
6
|
+
//
|
|
7
|
+
// Expected output:
|
|
8
|
+
// _nodeMap value shape: valueIsNode true, hasNodeProp false
|
|
9
|
+
// OLD code (value.node): removed = 0 | refs after = r1,r2,r3 <- v0.1.2 silent no-op
|
|
10
|
+
// NEW code (value is node): removed = 1 | refs after = r1,r3
|
|
11
|
+
import { createHeadlessEditor } from '@lexical/headless'
|
|
12
|
+
import { $createParagraphNode, $createTextNode, $getRoot, DecoratorNode } from 'lexical'
|
|
13
|
+
|
|
14
|
+
const SOURCE = 'paste-code-block'
|
|
15
|
+
|
|
16
|
+
class ReferenceChipNode extends DecoratorNode {
|
|
17
|
+
static getType() { return 'reference-chip' }
|
|
18
|
+
static clone(node) { return new ReferenceChipNode(node.__source, node.__ref, node.__label, node.__key) }
|
|
19
|
+
constructor(source = SOURCE, ref = '', label = '', key) {
|
|
20
|
+
super(key)
|
|
21
|
+
this.__source = source
|
|
22
|
+
this.__ref = ref
|
|
23
|
+
this.__label = label
|
|
24
|
+
}
|
|
25
|
+
createDOM() { return document.createElement('span') }
|
|
26
|
+
updateDOM() { return false }
|
|
27
|
+
isInline() { return true }
|
|
28
|
+
isKeyboardSelectable() { return false }
|
|
29
|
+
getSource() { return this.__source }
|
|
30
|
+
getReference() { return this.__ref }
|
|
31
|
+
getLabel() { return this.__label }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function freshEditor() {
|
|
35
|
+
const editor = createHeadlessEditor({
|
|
36
|
+
namespace: 'probe',
|
|
37
|
+
nodes: [ReferenceChipNode],
|
|
38
|
+
onError: (err) => { throw err },
|
|
39
|
+
})
|
|
40
|
+
editor.update(() => {
|
|
41
|
+
const p = $createParagraphNode()
|
|
42
|
+
p.append($createTextNode('alpha '))
|
|
43
|
+
p.append(new ReferenceChipNode(SOURCE, 'r1', 'block 1'))
|
|
44
|
+
p.append(new ReferenceChipNode(SOURCE, 'r2', 'block 2'))
|
|
45
|
+
p.append(new ReferenceChipNode(SOURCE, 'r3', 'block 3'))
|
|
46
|
+
p.append($createTextNode(' omega'))
|
|
47
|
+
const root = $getRoot()
|
|
48
|
+
root.clear()
|
|
49
|
+
root.append(p)
|
|
50
|
+
}, { discrete: true })
|
|
51
|
+
return editor
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function refsOf(editor) {
|
|
55
|
+
let refs = []
|
|
56
|
+
editor.getEditorState().read(() => {
|
|
57
|
+
refs = $getRoot().getChildren()[0].getChildren()
|
|
58
|
+
.filter((n) => typeof n.getSource === 'function')
|
|
59
|
+
.map((n) => n.getReference())
|
|
60
|
+
})
|
|
61
|
+
return refs
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function shapeOf(editor) {
|
|
65
|
+
const found = []
|
|
66
|
+
editor.getEditorState().read(() => {
|
|
67
|
+
const map = editor.getEditorState()._nodeMap
|
|
68
|
+
for (const [key, value] of map) {
|
|
69
|
+
if (typeof value.getSource === 'function') {
|
|
70
|
+
found.push({ valueIsNode: true, hasNodeProp: 'node' in value, key: value.getKey() === key })
|
|
71
|
+
} else if (value && value.node) {
|
|
72
|
+
found.push({ valueIsNode: false, hasNodeProp: true, key: value.node.getKey() === key })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
return found
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function removeWith(editor, pick) {
|
|
80
|
+
let hits = 0
|
|
81
|
+
editor.update(() => {
|
|
82
|
+
const map = editor.getEditorState()._nodeMap
|
|
83
|
+
const targets = []
|
|
84
|
+
for (const value of map.values()) {
|
|
85
|
+
const node = pick(value)
|
|
86
|
+
if (!node || typeof node.getSource !== 'function' || typeof node.getReference !== 'function') continue
|
|
87
|
+
if (node.getSource() === SOURCE && node.getReference() === 'r2') targets.push(node)
|
|
88
|
+
}
|
|
89
|
+
for (const node of targets) node.remove()
|
|
90
|
+
hits = targets.length
|
|
91
|
+
}, { discrete: true })
|
|
92
|
+
return hits
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const probe = freshEditor()
|
|
96
|
+
console.log('1) _nodeMap value shape:', JSON.stringify(shapeOf(probe)))
|
|
97
|
+
|
|
98
|
+
const oldWay = freshEditor()
|
|
99
|
+
console.log('2) OLD code (value.node): removed =', removeWith(oldWay, (v) => v && v.node), '| refs after =', refsOf(oldWay).join(','))
|
|
100
|
+
|
|
101
|
+
const newWay = freshEditor()
|
|
102
|
+
console.log('3) NEW code (value is node): removed =', removeWith(newWay, (v) => (v && typeof v.getSource === 'function' ? v : v && v.node)), '| refs after =', refsOf(newWay).join(','))
|
|
103
|
+
|
|
104
|
+
const textAfter = (editor) => {
|
|
105
|
+
let text = ''
|
|
106
|
+
editor.getEditorState().read(() => { text = $getRoot().getTextContent() })
|
|
107
|
+
return text
|
|
108
|
+
}
|
|
109
|
+
console.log('4) draft text after NEW removal:', JSON.stringify(textAfter(newWay)))
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { parseBlock, detectLang, serializeBlock, splitFencedSegments } from '../src/parse.js'
|
|
4
|
+
|
|
5
|
+
test('ordinary short prose is not treated as a block', () => {
|
|
6
|
+
assert.equal(parseBlock('hello world'), null)
|
|
7
|
+
assert.equal(parseBlock('a'), null)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('multi-line text becomes a block', () => {
|
|
11
|
+
const b = parseBlock('line one\nline two\nline three')
|
|
12
|
+
assert.ok(b)
|
|
13
|
+
assert.equal(b.isCode, false)
|
|
14
|
+
assert.equal(b.lines.length, 3)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('a short line below the length threshold is null', () => {
|
|
18
|
+
// 95 chars, no newline, no fences, no indent -> not a block
|
|
19
|
+
const s = 'a'.repeat(95)
|
|
20
|
+
assert.equal(parseBlock(s), null)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a long single line is treated as a block', () => {
|
|
24
|
+
// >= 96 chars, no newline -> still a block (length rule)
|
|
25
|
+
const s = 'a'.repeat(96)
|
|
26
|
+
assert.ok(parseBlock(s))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('fenced code block is parsed with its language tag', () => {
|
|
30
|
+
const raw = '```js\nconst x = 1;\n```'
|
|
31
|
+
const b = parseBlock(raw)
|
|
32
|
+
assert.ok(b)
|
|
33
|
+
assert.equal(b.isCode, true)
|
|
34
|
+
assert.equal(b.lang, 'js')
|
|
35
|
+
assert.equal(b.content, 'const x = 1;')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('fenced block with no language tags as empty lang but still code', () => {
|
|
39
|
+
const b = parseBlock('```\nplain\n```')
|
|
40
|
+
assert.ok(b)
|
|
41
|
+
assert.equal(b.isCode, true)
|
|
42
|
+
assert.equal(b.lang, '')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('indented, brace-dense code is detected as code', () => {
|
|
46
|
+
const raw = ' function main() {\n console.log("hi")\n return 0;\n }'
|
|
47
|
+
const b = parseBlock(raw)
|
|
48
|
+
assert.ok(b)
|
|
49
|
+
assert.equal(b.isCode, true)
|
|
50
|
+
assert.equal(b.lang, 'javascript')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('python shebang is detected', () => {
|
|
54
|
+
const raw = '#!/usr/bin/env python3\nprint("hi")'
|
|
55
|
+
const b = parseBlock(raw)
|
|
56
|
+
assert.ok(b)
|
|
57
|
+
assert.equal(b.isCode, true)
|
|
58
|
+
assert.equal(b.lang, 'python')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('markdown prose with several signals is text, not code', () => {
|
|
62
|
+
const md = '# Title\n\nSome **bold** text with a [link](https://x.test).\n\n- item\n- item2'
|
|
63
|
+
const b = parseBlock(md)
|
|
64
|
+
assert.ok(b)
|
|
65
|
+
assert.equal(b.isCode, false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('empty input returns null', () => {
|
|
69
|
+
assert.equal(parseBlock(''), null)
|
|
70
|
+
assert.equal(parseBlock(null), null)
|
|
71
|
+
assert.equal(parseBlock(undefined), null)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('detectLang: common languages', () => {
|
|
75
|
+
assert.equal(detectLang('const a = 1;'), 'javascript')
|
|
76
|
+
assert.equal(detectLang('def foo():'), 'python')
|
|
77
|
+
assert.equal(detectLang('SELECT * FROM t'), 'sql')
|
|
78
|
+
assert.equal(detectLang('{"a":1}'), 'json')
|
|
79
|
+
assert.equal(detectLang('<root></root>'), 'xml')
|
|
80
|
+
assert.equal(detectLang(''), '')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('serializeBlock wraps plain text in a text fence', () => {
|
|
84
|
+
const b = parseBlock('line one\nline two')
|
|
85
|
+
assert.ok(b)
|
|
86
|
+
assert.equal(serializeBlock(b), '\n```text\nline one\nline two\n```\n')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('serializeBlock preserves the detected language for code', () => {
|
|
90
|
+
const b = parseBlock('function main() {\n console.log(1)\n}')
|
|
91
|
+
assert.ok(b)
|
|
92
|
+
assert.equal(b.isCode, true)
|
|
93
|
+
assert.equal(b.lang, 'javascript')
|
|
94
|
+
assert.ok(serializeBlock(b).startsWith('\n```javascript\n'))
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// ===== splitFencedSegments (sent-message fold segmentation) ================
|
|
98
|
+
test('empty text yields no segments', () => {
|
|
99
|
+
assert.deepEqual(splitFencedSegments(''), [])
|
|
100
|
+
assert.deepEqual(splitFencedSegments(null), [])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('prose-only text is one prose segment', () => {
|
|
104
|
+
const segs = splitFencedSegments('hello\nworld')
|
|
105
|
+
assert.equal(segs.length, 1)
|
|
106
|
+
assert.equal(segs[0].kind, 'prose')
|
|
107
|
+
assert.equal(segs[0].text, 'hello\nworld')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a single fenced block is extracted with lang, body and raw', () => {
|
|
111
|
+
const text = '```python\ndef f():\n pass\n```'
|
|
112
|
+
const segs = splitFencedSegments(text)
|
|
113
|
+
assert.equal(segs.length, 1)
|
|
114
|
+
assert.equal(segs[0].kind, 'fence')
|
|
115
|
+
assert.equal(segs[0].lang, 'python')
|
|
116
|
+
assert.equal(segs[0].body, 'def f():\n pass')
|
|
117
|
+
assert.equal(segs[0].raw, text)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('prose around fences is kept verbatim and in order', () => {
|
|
121
|
+
const text = '看一下\n```js\nconst x = 1;\n```\n最后一个问题'
|
|
122
|
+
const segs = splitFencedSegments(text)
|
|
123
|
+
assert.deepEqual(segs.map((s) => s.kind), ['prose', 'fence', 'prose'])
|
|
124
|
+
assert.equal(segs[0].text, '看一下')
|
|
125
|
+
assert.equal(segs[1].lang, 'js')
|
|
126
|
+
assert.equal(segs[2].text, '最后一个问题')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('two serialized plugin blocks split apart with blank prose', () => {
|
|
130
|
+
// serializeBlock emits `\n```text\n…\n```\n` per block — back to back.
|
|
131
|
+
const text = '\n```text\nfirst block\n```\n\n```python\nprint(1)\n```\n'
|
|
132
|
+
const segs = splitFencedSegments(text)
|
|
133
|
+
const fences = segs.filter((s) => s.kind === 'fence')
|
|
134
|
+
assert.equal(fences.length, 2)
|
|
135
|
+
assert.equal(fences[0].body, 'first block')
|
|
136
|
+
assert.equal(fences[1].body, 'print(1)')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('tilde fences and info strings with extra words', () => {
|
|
140
|
+
const segs = splitFencedSegments('~~~yaml title: a\nkey: value\n~~~')
|
|
141
|
+
assert.equal(segs.length, 1)
|
|
142
|
+
assert.equal(segs[0].kind, 'fence')
|
|
143
|
+
assert.equal(segs[0].lang, 'yaml')
|
|
144
|
+
assert.equal(segs[0].body, 'key: value')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
test('a longer fence wraps inner shorter fences', () => {
|
|
148
|
+
const text = '````markdown\n```js\nx\n```\n````'
|
|
149
|
+
const segs = splitFencedSegments(text)
|
|
150
|
+
assert.equal(segs.length, 1)
|
|
151
|
+
assert.equal(segs[0].kind, 'fence')
|
|
152
|
+
assert.equal(segs[0].lang, 'markdown')
|
|
153
|
+
assert.equal(segs[0].body, '```js\nx\n```')
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test('unterminated fences stay prose', () => {
|
|
157
|
+
const segs = splitFencedSegments('before\n```json\n{"a": 1}')
|
|
158
|
+
assert.equal(segs.length, 1)
|
|
159
|
+
assert.equal(segs[0].kind, 'prose')
|
|
160
|
+
assert.equal(segs[0].text, 'before\n```json\n{"a": 1}')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('fence indented up to 3 spaces opens; 4+ stays prose (indented code)', () => {
|
|
164
|
+
const ok = splitFencedSegments(' ```\nx\n ```')
|
|
165
|
+
assert.equal(ok.length, 1)
|
|
166
|
+
assert.equal(ok[0].kind, 'fence')
|
|
167
|
+
const deep = splitFencedSegments(' ```\n x\n ```')
|
|
168
|
+
assert.equal(deep.length, 1)
|
|
169
|
+
assert.equal(deep[0].kind, 'prose')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('backticks inside a ``` info string mean prose, not a fence', () => {
|
|
173
|
+
const segs = splitFencedSegments('```weird `tick`\nbody\n```')
|
|
174
|
+
assert.equal(segs.length, 1)
|
|
175
|
+
assert.equal(segs[0].kind, 'prose')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('empty-body fence is still reported as a fence segment', () => {
|
|
179
|
+
const segs = splitFencedSegments('```\n```')
|
|
180
|
+
assert.equal(segs.length, 1)
|
|
181
|
+
assert.equal(segs[0].kind, 'fence')
|
|
182
|
+
assert.equal(segs[0].body, '')
|
|
183
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { detectOffsetOf, separatorSpaceSpan } from '../src/projection.js'
|
|
5
|
+
|
|
6
|
+
/** Occurrence factory: our chips project as one zero-width char. */
|
|
7
|
+
function occ(offset, { length = 1, source = 'paste-code-block', ref = 'r1' } = {}) {
|
|
8
|
+
return { offset, length, source, ref }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
test('detect offset equals the clipboard offset when no chip precedes', () => {
|
|
12
|
+
const c = occ(4)
|
|
13
|
+
assert.equal(detectOffsetOf([c], c), 4)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('a preceding foreign chip (longer clipboard text) shifts detect offsets', () => {
|
|
17
|
+
const fileChip = occ(0, { length: 5, source: 'dsh-file-upload', ref: 'f1' })
|
|
18
|
+
const block = occ(5, { ref: 'r1' })
|
|
19
|
+
assert.equal(detectOffsetOf([fileChip, block], block), 1)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('trailing occurrences never shift an earlier one', () => {
|
|
23
|
+
const block = occ(0, { ref: 'r1' })
|
|
24
|
+
const later = occ(9, { length: 5, source: 'dsh-file-upload', ref: 'f2' })
|
|
25
|
+
assert.equal(detectOffsetOf([block, later], block), 0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('separator space right after our chip is located in detect coordinates', () => {
|
|
29
|
+
const block = occ(0)
|
|
30
|
+
const projection = { draft: '\u200B ', occurrences: [block] }
|
|
31
|
+
assert.deepEqual(separatorSpaceSpan(projection, block), { start: 1, end: 2 })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('separator span accounts for chips that precede ours', () => {
|
|
35
|
+
const fileChip = occ(0, { length: 5, source: 'dsh-file-upload', ref: 'f1' })
|
|
36
|
+
const block = occ(5)
|
|
37
|
+
// clipboard: [file(0..5)][chip(5)][' '(6)] -> detect: [file=1][chip][space]
|
|
38
|
+
const projection = { draft: '/file\u200B ', occurrences: [fileChip, block] }
|
|
39
|
+
assert.deepEqual(separatorSpaceSpan(projection, block), { start: 2, end: 3 })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('a pre-existing space before the chip is not what gets removed', () => {
|
|
43
|
+
const block = occ(4)
|
|
44
|
+
// "abc " was typed by the user; the chip was appended, then the shell's space.
|
|
45
|
+
const projection = { draft: 'abc \u200B ', occurrences: [block] }
|
|
46
|
+
const span = separatorSpaceSpan(projection, block)
|
|
47
|
+
assert.deepEqual(span, { start: 5, end: 6 })
|
|
48
|
+
// the character under the span is the appended one, not the user's space
|
|
49
|
+
assert.equal(projection.draft.charAt(4), '\u200B')
|
|
50
|
+
assert.equal(projection.draft.charAt(3), ' ')
|
|
51
|
+
assert.equal(projection.draft.charAt(span.start), ' ')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('no span when the character after our chip is not a space', () => {
|
|
55
|
+
const block = occ(0)
|
|
56
|
+
assert.equal(separatorSpaceSpan({ draft: '\u200B', occurrences: [block] }, block), null)
|
|
57
|
+
assert.equal(separatorSpaceSpan({ draft: '\u200Btext', occurrences: [block] }, block), null)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('missing projection or occurrence yields no span', () => {
|
|
61
|
+
const block = occ(0)
|
|
62
|
+
assert.equal(separatorSpaceSpan(null, block), null)
|
|
63
|
+
assert.equal(separatorSpaceSpan({ draft: '\u200B ', occurrences: [] }, null), null)
|
|
64
|
+
})
|