@citisen/litearea 0.1.0 → 0.2.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.
- package/README.md +39 -18
- package/README.zh.md +25 -11
- package/dist/types/core/types.d.ts +5 -5
- package/dist/types/index.d.ts.map +1 -1
- package/docs/architecture.md +10 -6
- package/docs/completion.md +3 -3
- package/docs/grammar.md +16 -15
- package/package.json +1 -6
- package/scripts/browser-check.mjs +133 -54
- package/scripts/verify-package.mjs +51 -27
- package/src/core/types.ts +5 -5
- package/src/index.ts +6 -5
- package/dist/grammars.cjs +0 -1228
- package/dist/grammars.cjs.map +0 -1
- package/dist/grammars.js +0 -1213
- package/dist/grammars.js.map +0 -1
- package/dist/types/grammars/dshFont.d.ts +0 -127
- package/dist/types/grammars/dshFont.d.ts.map +0 -1
- package/dist/types/grammars/dshSentry.d.ts +0 -84
- package/dist/types/grammars/dshSentry.d.ts.map +0 -1
- package/dist/types/grammars/index.d.ts +0 -3
- package/dist/types/grammars/index.d.ts.map +0 -1
- package/src/grammars/dshFont.ts +0 -1004
- package/src/grammars/dshSentry.ts +0 -742
- package/src/grammars/index.ts +0 -57
|
@@ -20,6 +20,12 @@
|
|
|
20
20
|
* 4. **Auto-sizing.** Whether a box has a scrollbar is `scrollHeight` against
|
|
21
21
|
* `clientHeight`, measured after a real layout.
|
|
22
22
|
*
|
|
23
|
+
* The checks are about the ENGINE, so the language they run against is a FIXTURE
|
|
24
|
+
* defined inside the checklist itself. The package ships no syntax and no grammar
|
|
25
|
+
* to import: a harness that borrowed a real DSL would be checking that DSL rather
|
|
26
|
+
* than the artifact this exists to check, and proving a particular product DSL
|
|
27
|
+
* belongs beside the plugin that owns it.
|
|
28
|
+
*
|
|
23
29
|
* Usage:
|
|
24
30
|
* node scripts/browser-check.mjs [path/to/chrome]
|
|
25
31
|
*
|
|
@@ -88,20 +94,20 @@ if (browser === undefined) {
|
|
|
88
94
|
* The page is opened over `file://`, and Chromium refuses to fetch an ES module from
|
|
89
95
|
* a file URL — so the library is bundled to an IIFE and inlined. The entry is built
|
|
90
96
|
* from the SHIPPED files rather than from `src/`, so what this check drives is the
|
|
91
|
-
* artifact a host would actually install
|
|
97
|
+
* artifact a host would actually install — and it is the root entry ALONE, because
|
|
98
|
+
* that is all the package publishes.
|
|
92
99
|
* @returns the script source.
|
|
93
100
|
*/
|
|
94
101
|
async function bundle() {
|
|
95
102
|
const index = join(root, 'dist', 'index.js')
|
|
96
|
-
|
|
97
|
-
if (!existsSync(index) || !existsSync(grammars)) {
|
|
103
|
+
if (!existsSync(index)) {
|
|
98
104
|
console.error('browser-check: dist/ is missing; run `npm run build` first')
|
|
99
105
|
process.exit(1)
|
|
100
106
|
}
|
|
101
107
|
const { build } = await import('esbuild')
|
|
102
108
|
const result = await build({
|
|
103
109
|
stdin: {
|
|
104
|
-
contents: `export * from ${JSON.stringify(index)}\
|
|
110
|
+
contents: `export * from ${JSON.stringify(index)}\n`,
|
|
105
111
|
resolveDir: root,
|
|
106
112
|
sourcefile: 'litearea-browser-entry.js',
|
|
107
113
|
loader: 'js',
|
|
@@ -156,12 +162,57 @@ const CHECKLIST = String.raw`
|
|
|
156
162
|
field.dispatchEvent(new KeyboardEvent('keydown', { key: key, bubbles: true, cancelable: true }))
|
|
157
163
|
}
|
|
158
164
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
+
// ── the fixture grammar ─────────────────────────────────────────────
|
|
166
|
+
// The package ships no syntax, so the checklist defines the language it
|
|
167
|
+
// drives. This is a FIXTURE and not a product DSL: one closed vocabulary,
|
|
168
|
+
// three rules, one completion source, and it is published nowhere. The two
|
|
169
|
+
// checks that need a differently-shaped grammar — the naive one in section
|
|
170
|
+
// 10 and the bulky one in section 8 — define their own, for the same reason.
|
|
171
|
+
const SHAPES = api.defineVocabulary({
|
|
172
|
+
id: 'shape',
|
|
173
|
+
words: ['circle', 'square', 'rounded'],
|
|
174
|
+
scope: 'shape',
|
|
175
|
+
unknownMessage: '"{word}" is not a shape — expected {allowed}.',
|
|
176
|
+
docs: {
|
|
177
|
+
circle: { detail: 'a disc', body: 'Fixture documentation for circle.' },
|
|
178
|
+
square: { detail: 'four corners', body: 'Fixture documentation for square.' },
|
|
179
|
+
rounded: { detail: 'a rounded box', body: 'Fixture documentation for rounded.' },
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const fixture = api.defineGrammar({
|
|
184
|
+
id: 'browser-fixture',
|
|
185
|
+
name: 'browser fixture',
|
|
186
|
+
rules: [
|
|
187
|
+
// The number rule sits ABOVE the vocabulary: digits are word characters,
|
|
188
|
+
// so a words rule placed first would swallow and reject them.
|
|
189
|
+
{ kind: 'match', scope: 'keyword', pattern: /draw|fill/ },
|
|
190
|
+
{ kind: 'match', scope: 'number', pattern: /\d+(?:\.\d+)?/ },
|
|
191
|
+
{ kind: 'words', words: SHAPES, unknown: {} },
|
|
192
|
+
],
|
|
193
|
+
compose: [
|
|
194
|
+
{
|
|
195
|
+
id: 'shape',
|
|
196
|
+
range: (context) => context.word,
|
|
197
|
+
items: (context) => {
|
|
198
|
+
const written = context.text.slice(0, context.caret)
|
|
199
|
+
return ['circle', 'square', 'rounded'].map((shape) => {
|
|
200
|
+
// A shape the document already uses leads the list, which is what
|
|
201
|
+
// sortText is for: the ranking must put that group first without
|
|
202
|
+
// pretending its label starts with a zero.
|
|
203
|
+
const used = written.indexOf(shape) >= 0
|
|
204
|
+
return {
|
|
205
|
+
label: shape,
|
|
206
|
+
append: ' ',
|
|
207
|
+
kind: 'shape',
|
|
208
|
+
detail: SHAPES.entryFor(shape)?.detail,
|
|
209
|
+
documentation: SHAPES.entryFor(shape)?.body,
|
|
210
|
+
sortText: used ? '0' : '1',
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
],
|
|
165
216
|
})
|
|
166
217
|
|
|
167
218
|
;(async () => {
|
|
@@ -169,7 +220,7 @@ const CHECKLIST = String.raw`
|
|
|
169
220
|
check(typeof api.createEditor === 'function', 'the bundle did not expose createEditor')
|
|
170
221
|
|
|
171
222
|
// ── 1. the layer reproduces the document, and lines up with it ──────
|
|
172
|
-
const one = mount(
|
|
223
|
+
const one = mount(fixture, 'draw circle fill rounded 10')
|
|
173
224
|
const paint = paintOf(one)
|
|
174
225
|
check(
|
|
175
226
|
paint.textContent === one.input.value,
|
|
@@ -190,7 +241,7 @@ const CHECKLIST = String.raw`
|
|
|
190
241
|
// screen: a stylesheet rule of higher specificity once set text-decoration on every
|
|
191
242
|
// painted span, which quietly switched off every squiggle in the library. Nothing in
|
|
192
243
|
// the DOM reveals that, so the only honest check is the browser's own computed style.
|
|
193
|
-
const marked = mount(
|
|
244
|
+
const marked = mount(fixture, 'draw bogus 10')
|
|
194
245
|
const markedSpans = [...marked.element.querySelectorAll('.litearea-paint > span')]
|
|
195
246
|
const flaggedSpan = markedSpans.find((span) => span.className.indexOf('litearea-diag-error') >= 0)
|
|
196
247
|
check(flaggedSpan !== undefined, 'a rejected word must carry a severity class')
|
|
@@ -220,14 +271,14 @@ const CHECKLIST = String.raw`
|
|
|
220
271
|
// is the first thing a host asks about, so it is measured rather than asserted in prose.
|
|
221
272
|
// Georgia is chosen precisely because it is unlike the default in every metric.
|
|
222
273
|
const themed = mount(
|
|
223
|
-
|
|
224
|
-
'
|
|
274
|
+
fixture,
|
|
275
|
+
'draw circle 10\nfill square 20',
|
|
225
276
|
{
|
|
226
277
|
variables: {
|
|
227
278
|
font: 'Georgia, Times New Roman, serif',
|
|
228
279
|
'font-size': '15px',
|
|
229
280
|
'line-height': '26px',
|
|
230
|
-
'scope-
|
|
281
|
+
'scope-keyword': '#b91c1c',
|
|
231
282
|
accent: '#c2410c',
|
|
232
283
|
},
|
|
233
284
|
},
|
|
@@ -253,8 +304,8 @@ const CHECKLIST = String.raw`
|
|
|
253
304
|
'a themed layer must still reproduce the document',
|
|
254
305
|
)
|
|
255
306
|
const themedState = [...themed.element.querySelectorAll('.litearea-paint > span')]
|
|
256
|
-
.find((span) => span.className.indexOf('litearea-scope-
|
|
257
|
-
check(themedState !== undefined, 'the themed document must have a
|
|
307
|
+
.find((span) => span.className.indexOf('litearea-scope-keyword') >= 0)
|
|
308
|
+
check(themedState !== undefined, 'the themed document must have a keyword token')
|
|
258
309
|
if (themedState !== undefined) {
|
|
259
310
|
check(
|
|
260
311
|
getComputedStyle(themedState).color === 'rgb(185, 28, 28)',
|
|
@@ -280,10 +331,10 @@ const CHECKLIST = String.raw`
|
|
|
280
331
|
' > ' + one.input.clientHeight + ')',
|
|
281
332
|
)
|
|
282
333
|
const oneLine = one.input.offsetHeight
|
|
283
|
-
one.setValue('
|
|
334
|
+
one.setValue('draw circle 10\nfill square 20\ndraw rounded 30', true)
|
|
284
335
|
const threeLines = one.input.offsetHeight
|
|
285
336
|
check(threeLines > oneLine, 'more lines must grow the box (was ' + oneLine + ', now ' + threeLines + ')')
|
|
286
|
-
one.setValue('
|
|
337
|
+
one.setValue('draw circle 10', true)
|
|
287
338
|
const backToOne = one.input.offsetHeight
|
|
288
339
|
check(
|
|
289
340
|
Math.abs(backToOne - oneLine) <= 1,
|
|
@@ -292,8 +343,8 @@ const CHECKLIST = String.raw`
|
|
|
292
343
|
|
|
293
344
|
// ── 3. a maximum height clamps the box and introduces a scrollbar ───
|
|
294
345
|
const clamped = mount(
|
|
295
|
-
|
|
296
|
-
'
|
|
346
|
+
fixture,
|
|
347
|
+
'draw circle 1\nfill square 2\ndraw rounded 3\nfill circle 4',
|
|
297
348
|
{ sizing: { maxRows: 2 } },
|
|
298
349
|
)
|
|
299
350
|
check(
|
|
@@ -333,22 +384,22 @@ const CHECKLIST = String.raw`
|
|
|
333
384
|
notes.push('control: a direct value assignment is not undoable, as expected')
|
|
334
385
|
|
|
335
386
|
// ── 5. undo and redo survive a completion ──────────────────────────
|
|
336
|
-
const undoable = mount(
|
|
387
|
+
const undoable = mount(fixture, '')
|
|
337
388
|
undoable.focus()
|
|
338
|
-
document.execCommand('insertText', false, '
|
|
389
|
+
document.execCommand('insertText', false, 'rou')
|
|
339
390
|
check(
|
|
340
|
-
undoable.value === '
|
|
391
|
+
undoable.value === 'rou',
|
|
341
392
|
'typing through the editing pipeline must land (value is ' + JSON.stringify(undoable.value) + ')',
|
|
342
393
|
)
|
|
343
394
|
check(
|
|
344
395
|
undoable.currentCompletion !== undefined,
|
|
345
|
-
'typing a
|
|
396
|
+
'typing a shape prefix must open the completion list',
|
|
346
397
|
)
|
|
347
398
|
check(rowsOf(undoable).length > 0, 'the completion list must render rows')
|
|
348
399
|
const beforeCompletion = undoable.value
|
|
349
400
|
press(undoable.input, 'Enter')
|
|
350
401
|
check(
|
|
351
|
-
undoable.value.indexOf('
|
|
402
|
+
undoable.value.indexOf('rounded') === 0,
|
|
352
403
|
'Enter must accept the highlighted completion (value is ' + JSON.stringify(undoable.value) + ')',
|
|
353
404
|
)
|
|
354
405
|
check(
|
|
@@ -381,7 +432,7 @@ const CHECKLIST = String.raw`
|
|
|
381
432
|
// letter — so accepting the suggestion produced the completion followed by the
|
|
382
433
|
// leftover character. Every character must be typed SEPARATELY here, so that the list
|
|
383
434
|
// is filtered once per keystroke exactly as a person would drive it.
|
|
384
|
-
const grown = mount(
|
|
435
|
+
const grown = mount(fixture, '')
|
|
385
436
|
grown.focus()
|
|
386
437
|
document.execCommand('insertText', false, 'r')
|
|
387
438
|
check(grown.currentCompletion !== undefined, 'the first letter must open the list')
|
|
@@ -394,26 +445,28 @@ const CHECKLIST = String.raw`
|
|
|
394
445
|
)
|
|
395
446
|
press(grown.input, 'Enter')
|
|
396
447
|
check(
|
|
397
|
-
grown.value === '
|
|
448
|
+
grown.value === 'rounded ',
|
|
398
449
|
'accepting after two letters must replace BOTH of them (got ' + JSON.stringify(grown.value) + ')',
|
|
399
450
|
)
|
|
400
451
|
|
|
401
452
|
// The same thing with a longer word and a middle-of-document caret, because the
|
|
402
|
-
// off-by-one is easiest to miss when the range is not anchored at zero.
|
|
403
|
-
|
|
453
|
+
// off-by-one is easiest to miss when the range is not anchored at zero. The
|
|
454
|
+
// inserted letters are a subsequence of the word rather than its next letters, so
|
|
455
|
+
// the row is still offered and the replacement has to cover everything typed.
|
|
456
|
+
const midword = mount(fixture, 'draw circle 1\nrounded square 2')
|
|
404
457
|
midword.focus()
|
|
405
|
-
const lineStart = midword.input.value.indexOf('
|
|
458
|
+
const lineStart = midword.input.value.indexOf('rounded')
|
|
406
459
|
midword.setSelection(lineStart + 1)
|
|
407
|
-
document.execCommand('insertText', false, '
|
|
460
|
+
document.execCommand('insertText', false, 'ou')
|
|
408
461
|
press(midword.input, 'Enter')
|
|
409
462
|
check(
|
|
410
|
-
midword.input.value.split('\n')[1] === '
|
|
463
|
+
midword.input.value.split('\n')[1] === 'rounded square 2',
|
|
411
464
|
'completing a word in the middle of a document must leave no debris (line is ' +
|
|
412
465
|
JSON.stringify(midword.input.value.split('\n')[1]) + ')',
|
|
413
466
|
)
|
|
414
467
|
|
|
415
468
|
// ── 7. a repaint does not move the caret ───────────────────────────
|
|
416
|
-
const careful = mount(
|
|
469
|
+
const careful = mount(fixture, 'draw circle fill rounded 10\nfill square 20')
|
|
417
470
|
careful.focus()
|
|
418
471
|
const at = careful.input.value.indexOf('rounded')
|
|
419
472
|
careful.setSelection(at)
|
|
@@ -435,7 +488,7 @@ const CHECKLIST = String.raw`
|
|
|
435
488
|
)
|
|
436
489
|
|
|
437
490
|
// ── 7. the list opens, navigates, and dismisses ─────────────────────
|
|
438
|
-
const listed = mount(
|
|
491
|
+
const listed = mount(fixture, 'draw ')
|
|
439
492
|
listed.focus()
|
|
440
493
|
listed.setSelection(listed.input.value.length)
|
|
441
494
|
listed.showCompletions()
|
|
@@ -470,7 +523,7 @@ const CHECKLIST = String.raw`
|
|
|
470
523
|
// row rather than the scrollbar, so a keyboard user never saw it, and a mouse user had
|
|
471
524
|
// to scroll down to read it and back up to reach the next row. The fix is positional, so
|
|
472
525
|
// this asks the layout rather than the DOM.
|
|
473
|
-
const bulky =
|
|
526
|
+
const bulky = api.defineGrammar({
|
|
474
527
|
id: 'bulky',
|
|
475
528
|
rules: [{ kind: 'match', scope: 'word', pattern: /[a-z]+/ }],
|
|
476
529
|
wordChars: /[\p{L}]/u,
|
|
@@ -540,10 +593,10 @@ const CHECKLIST = String.raw`
|
|
|
540
593
|
}
|
|
541
594
|
|
|
542
595
|
// ── 9. the tooltip explains a problem ───────────────────────────────
|
|
543
|
-
const hovered = mount(
|
|
596
|
+
const hovered = mount(fixture, 'draw bogus 10', { hover: { enabled: true, delay: 0 } })
|
|
544
597
|
const flagged = [...hovered.element.querySelectorAll('.litearea-paint > span')]
|
|
545
598
|
.find((span) => span.className.indexOf('litearea-diag-') >= 0)
|
|
546
|
-
check(flagged !== undefined, 'an unknown
|
|
599
|
+
check(flagged !== undefined, 'an unknown shape must be underlined')
|
|
547
600
|
if (flagged !== undefined) {
|
|
548
601
|
const box = flagged.getBoundingClientRect()
|
|
549
602
|
hovered.input.dispatchEvent(new MouseEvent('mousemove', {
|
|
@@ -567,19 +620,42 @@ const CHECKLIST = String.raw`
|
|
|
567
620
|
check(tip.dataset.open === 'false', 'leaving the field must close the tooltip')
|
|
568
621
|
}
|
|
569
622
|
|
|
570
|
-
// ──
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
)
|
|
623
|
+
// ── 9b. the fixture's own completion offers its own vocabulary ─────
|
|
624
|
+
// This replaced a check that drove the dsh-font DSL in a browser. Proving
|
|
625
|
+
// that a particular product DSL works is the plugin's job and not the
|
|
626
|
+
// library's: a DSL belongs beside the plugin that owns it. What is left is
|
|
627
|
+
// the engine claim the old check was reaching for — rows carry an append
|
|
628
|
+
// and a sortText, and the list honours both.
|
|
629
|
+
const offered = mount(fixture, 'draw circle 1\nfill ')
|
|
630
|
+
offered.focus()
|
|
631
|
+
offered.setSelection(offered.input.value.length)
|
|
632
|
+
offered.showCompletions()
|
|
633
|
+
check(
|
|
634
|
+
rowsOf(offered).length === 3,
|
|
635
|
+
'the fixture completion must offer its whole vocabulary (got ' + rowsOf(offered).length + ')',
|
|
636
|
+
)
|
|
637
|
+
const offeredState = offered.currentCompletion
|
|
638
|
+
check(offeredState !== undefined, 'showCompletions must open the list over an empty word')
|
|
639
|
+
if (offeredState !== undefined) {
|
|
640
|
+
const firstRow = offeredState.rows[0]
|
|
641
|
+
check(
|
|
642
|
+
firstRow !== undefined && firstRow.item.sortText === '0',
|
|
643
|
+
'A ROW THAT LEADS BY sortText MUST LEAD THE LIST (got ' +
|
|
644
|
+
JSON.stringify(firstRow === undefined ? null : firstRow.item.label) + ')',
|
|
645
|
+
)
|
|
646
|
+
check(
|
|
647
|
+
firstRow !== undefined && firstRow.item.label === 'circle',
|
|
648
|
+
'the shape the document already uses must be offered first (got ' +
|
|
649
|
+
JSON.stringify(firstRow === undefined ? null : firstRow.item.label) + ')',
|
|
650
|
+
)
|
|
651
|
+
check(
|
|
652
|
+
offeredState.rows.some((row) => row.item.append === ' '),
|
|
653
|
+
'a row must carry the append a host would type next',
|
|
654
|
+
)
|
|
655
|
+
}
|
|
580
656
|
check(
|
|
581
|
-
paintOf(
|
|
582
|
-
'the
|
|
657
|
+
paintOf(offered).textContent === offered.input.value,
|
|
658
|
+
'the fixture layer must reproduce the document it is showing a list over',
|
|
583
659
|
)
|
|
584
660
|
|
|
585
661
|
// ── 10. resting on blank space must explain nothing ────────────────
|
|
@@ -589,10 +665,10 @@ const CHECKLIST = String.raw`
|
|
|
589
665
|
// an implementation detail and not an explanation of anything.
|
|
590
666
|
//
|
|
591
667
|
// The grammar here is naive ON PURPOSE: it describes every token it is handed, including
|
|
592
|
-
// whitespace. Testing this against
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
const naive =
|
|
668
|
+
// whitespace. Testing this against a grammar that happens to stay quiet about a scope it
|
|
669
|
+
// has no documentation for would prove nothing — the check has to exercise the ENGINE's
|
|
670
|
+
// refusal to ask, not a grammar's manners.
|
|
671
|
+
const naive = api.defineGrammar({
|
|
596
672
|
id: 'naive',
|
|
597
673
|
rules: [{ kind: 'match', scope: 'word', pattern: /[a-z]+/ }],
|
|
598
674
|
describe: (context) =>
|
|
@@ -665,6 +741,9 @@ const CHECKLIST = String.raw`
|
|
|
665
741
|
document.querySelectorAll('style[data-litearea-styles]').length + ')',
|
|
666
742
|
)
|
|
667
743
|
|
|
744
|
+
// A host's own write at the very end: it must be accepted without throwing,
|
|
745
|
+
// and it is deliberately not a check, because a direct assignment is exactly
|
|
746
|
+
// what the control above proved the pipeline does not record.
|
|
668
747
|
const edits = one.input.value
|
|
669
748
|
one.input.value = edits
|
|
670
749
|
|
|
@@ -26,15 +26,17 @@ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
|
26
26
|
* Every entry point `package.json` promises, and where its declarations landed.
|
|
27
27
|
*
|
|
28
28
|
* The declarations come from `tsc` rather than from the JavaScript bundler, so they
|
|
29
|
-
* mirror the source tree: the root entry is one file, while `react`
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
* mirror the source tree: the root entry is one file, while `react` is a directory
|
|
30
|
+
* with an `index.d.ts` in it. That extra level is the price of not depending on a
|
|
31
|
+
* declaration bundler that predates TypeScript 7, and it is encoded here so a change
|
|
32
|
+
* to the layout cannot pass unnoticed.
|
|
33
|
+
*
|
|
34
|
+
* There is deliberately no `grammars` entry: the library ships no syntax, so it
|
|
35
|
+
* publishes no grammar for a host to import and none for this verifier to borrow.
|
|
33
36
|
*/
|
|
34
37
|
const ENTRIES = [
|
|
35
38
|
{ name: 'index', declarations: 'types/index.d.ts' },
|
|
36
39
|
{ name: 'react', declarations: 'types/react/index.d.ts' },
|
|
37
|
-
{ name: 'grammars', declarations: 'types/grammars/index.d.ts' },
|
|
38
40
|
{ name: 'styles', declarations: 'types/styles.d.ts' },
|
|
39
41
|
]
|
|
40
42
|
|
|
@@ -74,17 +76,6 @@ const CORE_EXPORTS = [
|
|
|
74
76
|
'canEditThroughPipeline',
|
|
75
77
|
]
|
|
76
78
|
|
|
77
|
-
/** The names the grammars entry must export. */
|
|
78
|
-
const GRAMMAR_EXPORTS = [
|
|
79
|
-
'dshFontQueryGrammar',
|
|
80
|
-
'dshSentryStyleGrammar',
|
|
81
|
-
'fontWeightWord',
|
|
82
|
-
'fontFaceWeights',
|
|
83
|
-
'quoteFontFamily',
|
|
84
|
-
'FONT_WEIGHT_WORDS',
|
|
85
|
-
'FONT_GENERIC_FAMILIES',
|
|
86
|
-
]
|
|
87
|
-
|
|
88
79
|
/** The names the React entry must export. */
|
|
89
80
|
const REACT_EXPORTS = ['LiteAreaEditor']
|
|
90
81
|
|
|
@@ -171,30 +162,63 @@ const core = await import(pathToFileURL(join(dist, 'index.js')).href)
|
|
|
171
162
|
const missing = CORE_EXPORTS.filter((name) => core[name] === undefined)
|
|
172
163
|
if (missing.length > 0) fail(`index.js does not export: ${missing.join(', ')}`)
|
|
173
164
|
|
|
174
|
-
const grammars = await import(pathToFileURL(join(dist, 'grammars.js')).href)
|
|
175
|
-
const missingGrammars = GRAMMAR_EXPORTS.filter((name) => grammars[name] === undefined)
|
|
176
|
-
if (missingGrammars.length > 0) fail(`grammars.js does not export: ${missingGrammars.join(', ')}`)
|
|
177
|
-
|
|
178
165
|
const react = await import(pathToFileURL(join(dist, 'react.js')).href)
|
|
179
166
|
const missingReact = REACT_EXPORTS.filter((name) => react[name] === undefined)
|
|
180
167
|
if (missingReact.length > 0) fail(`react.js does not export: ${missingReact.join(', ')}`)
|
|
181
168
|
|
|
182
169
|
// ── the engine works from the built artifact, not just from source ─────────
|
|
170
|
+
//
|
|
171
|
+
// The grammar below is a FIXTURE and lives only in this file. The package ships
|
|
172
|
+
// no syntax, so there is nothing to import: a verifier that borrowed a language
|
|
173
|
+
// from somewhere else would be checking that language rather than the artifact
|
|
174
|
+
// it is here to check. Two rules and one vocabulary are enough to prove the
|
|
175
|
+
// built engine still tokenizes, diagnoses, completes, and segments.
|
|
176
|
+
|
|
177
|
+
/** A closed set of shapes, small enough to read at a glance. */
|
|
178
|
+
const SHAPES = core.defineVocabulary({
|
|
179
|
+
id: 'shape',
|
|
180
|
+
words: ['circle', 'square'],
|
|
181
|
+
unknownMessage: '"{word}" is not a shape — expected {allowed}.',
|
|
182
|
+
docs: { circle: { detail: 'a disc', body: 'Verifier fixture documentation.' } },
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
/** The whole language: one keyword, one vocabulary, one completion source. */
|
|
186
|
+
const fixture = core.defineGrammar({
|
|
187
|
+
id: 'verify-fixture',
|
|
188
|
+
rules: [
|
|
189
|
+
{ kind: 'match', scope: 'keyword', pattern: /draw/ },
|
|
190
|
+
{ kind: 'words', words: SHAPES, unknown: {} },
|
|
191
|
+
],
|
|
192
|
+
compose: [
|
|
193
|
+
{
|
|
194
|
+
id: 'shape',
|
|
195
|
+
range: (context) => context.word,
|
|
196
|
+
items: () =>
|
|
197
|
+
['circle', 'square'].map((shape) => ({
|
|
198
|
+
label: shape,
|
|
199
|
+
append: ' ',
|
|
200
|
+
kind: 'shape',
|
|
201
|
+
detail: SHAPES.entryFor(shape)?.detail,
|
|
202
|
+
})),
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
})
|
|
183
206
|
|
|
184
|
-
const
|
|
185
|
-
const inspection = core.inspect('running circle blue turn 3\nbogus circle', grammar)
|
|
207
|
+
const inspection = core.inspect('draw circle\nbogus square', fixture)
|
|
186
208
|
assert.ok(inspection.tokens.length > 0, 'the built engine produced no tokens')
|
|
187
209
|
assert.ok(
|
|
188
|
-
inspection.diagnostics.some((diagnostic) => diagnostic.code === 'vocabulary:
|
|
189
|
-
'the built engine did not report the unknown
|
|
210
|
+
inspection.diagnostics.some((diagnostic) => diagnostic.code === 'vocabulary:shape'),
|
|
211
|
+
'the built engine did not report the unknown shape',
|
|
190
212
|
)
|
|
191
|
-
const completion = core.complete(
|
|
192
|
-
text: '
|
|
193
|
-
caret: '
|
|
213
|
+
const completion = core.complete(core.inspect('draw ', fixture), fixture, {
|
|
214
|
+
text: 'draw ',
|
|
215
|
+
caret: 'draw '.length,
|
|
194
216
|
trigger: 'explicit',
|
|
195
217
|
})
|
|
196
218
|
assert.ok(completion !== undefined, 'the built engine offered no completion')
|
|
197
219
|
assert.ok(completion.rows.length > 0, 'the built engine offered an empty completion')
|
|
220
|
+
assert.equal(completion.sourceId, 'shape', 'the built engine opened the wrong completion source')
|
|
221
|
+
assert.equal(completion.rows[0]?.item.append, ' ', 'the built engine dropped a row field')
|
|
198
222
|
|
|
199
223
|
const segments = core.buildSegments(inspection.text, inspection)
|
|
200
224
|
assert.equal(
|
package/src/core/types.ts
CHANGED
|
@@ -67,9 +67,9 @@ export interface Token extends Range {
|
|
|
67
67
|
* tokens and they are deliberately not tokens here.
|
|
68
68
|
*
|
|
69
69
|
* The distinction earns its keep. A token is what the *characters* are; a
|
|
70
|
-
* decoration is what they *mean*, and the two change on different schedules.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
70
|
+
* decoration is what they *mean*, and the two change on different schedules. A
|
|
71
|
+
* grammar can paint `Geist Mono` as a family from the characters alone, but which
|
|
72
|
+
* family is *in effect* depends on a catalogue the HOST supplied — the same
|
|
73
73
|
* characters mean something else on another machine. Painting that as a token
|
|
74
74
|
* would mean re-lexing the document whenever the catalogue changed; painting it
|
|
75
75
|
* as a decoration means recomputing one range list, which is what it is.
|
|
@@ -493,8 +493,8 @@ export interface CompletionContext<State = unknown> {
|
|
|
493
493
|
* `runn|` is no longer "at the start of the line" in the strict sense, but it is
|
|
494
494
|
* unmistakably completing the first word. A source that asked `firstOnLine` would
|
|
495
495
|
* switch itself off after the very first keystroke — which is exactly the bug this
|
|
496
|
-
* field was added to fix, and exactly the kind of thing a
|
|
497
|
-
* for finding.
|
|
496
|
+
* field was added to fix, and exactly the kind of thing a grammar written against
|
|
497
|
+
* a real document is for finding.
|
|
498
498
|
*/
|
|
499
499
|
firstWord: boolean
|
|
500
500
|
/** The first non-whitespace token on the caret's line, when there is one. */
|
package/src/index.ts
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
//
|
|
7
7
|
// The package has three entry points and this is the largest:
|
|
8
8
|
//
|
|
9
|
-
// @citisen/litearea
|
|
10
|
-
// @citisen/litearea/react
|
|
11
|
-
// @citisen/litearea/
|
|
9
|
+
// @citisen/litearea the engine and the DOM layer (this file)
|
|
10
|
+
// @citisen/litearea/react a React binding over the same editor
|
|
11
|
+
// @citisen/litearea/styles.css the stylesheet, for hosts that link CSS
|
|
12
12
|
//
|
|
13
|
-
// Nothing here knows any syntax
|
|
14
|
-
//
|
|
13
|
+
// Nothing here knows any syntax, and the package ships none: a caller supplies
|
|
14
|
+
// the rules. `src/core/` is pure and needs no DOM; `src/dom/` needs a document
|
|
15
|
+
// and no framework.
|
|
15
16
|
|
|
16
17
|
export type {
|
|
17
18
|
CheckRule,
|