@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.
@@ -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
- const grammars = join(root, 'dist', 'grammars.js')
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)}\nexport * from ${JSON.stringify(grammars)}\n`,
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
- const sentry = api.dshSentryStyleGrammar()
160
- const font = api.dshFontQueryGrammar({
161
- catalogue: ['Inter', 'IBM Plex Mono', 'Geist Mono', 'Fira Code'],
162
- enumerated: true,
163
- styles: { 'Geist Mono': ['Regular', 'Medium', 'Bold'] },
164
- shippedWeight: 400,
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(sentry, 'running circle blue turn 3')
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(sentry, 'running bogus blue')
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
- sentry,
224
- 'running circle blue turn 3\nwaiting rounded amber blink 1.1',
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-state': '#b91c1c',
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-state') >= 0)
257
- check(themedState !== undefined, 'the themed document must have a state token')
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('running circle blue turn 3\nwaiting rounded amber blink 1.1\ndone circle green flush 1.6', true)
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('running circle blue turn 3', true)
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
- sentry,
296
- 'running circle blue turn 3\nwaiting rounded amber blink 1.1\ndone circle green flush 1.6\napproval rounded amber blink 1.9',
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(sentry, '')
387
+ const undoable = mount(fixture, '')
337
388
  undoable.focus()
338
- document.execCommand('insertText', false, 'runn')
389
+ document.execCommand('insertText', false, 'rou')
339
390
  check(
340
- undoable.value === 'runn',
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 state prefix must open the completion list',
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('running') === 0,
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(sentry, '')
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 === 'running ',
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
- const midword = mount(sentry, 'running circle blue turn 3\nwaiting rounded amber blink 1.1')
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('waiting')
458
+ const lineStart = midword.input.value.indexOf('rounded')
406
459
  midword.setSelection(lineStart + 1)
407
- document.execCommand('insertText', false, 'ai')
460
+ document.execCommand('insertText', false, 'ou')
408
461
  press(midword.input, 'Enter')
409
462
  check(
410
- midword.input.value.split('\n')[1] === 'waiting rounded amber blink 1.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(sentry, 'running circle blue turn 3\nwaiting rounded amber blink 1.1')
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(sentry, 'running ')
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 = litearea.defineGrammar({
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(sentry, 'running bogus blue', { hover: { enabled: true, delay: 0 } })
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 state must be underlined')
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
- // ── 9. the font grammar works in a browser too ─────────────────────
571
- const fonts = mount(font, 'Geist Mono m')
572
- fonts.focus()
573
- fonts.setSelection(fonts.input.value.length)
574
- fonts.showCompletions()
575
- const fontRows = [...rowsOf(fonts)].map((row) => row.textContent)
576
- check(
577
- fontRows.some((label) => label.indexOf('medium') >= 0),
578
- 'the font grammar must offer Geist Mono weights (got ' + JSON.stringify(fontRows.slice(0, 6)) + ')',
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(fonts).textContent === 'Geist Mono m',
582
- 'the font layer must reproduce the query',
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 one of the reference grammars would prove nothing,
593
- // because both of them happen to stay quiet about a scope they have no documentation
594
- // for — the check has to exercise the ENGINE's refusal to ask, not a grammar's manners.
595
- const naive = litearea.defineGrammar({
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` and `grammars`
30
- * are directories with an `index.d.ts` in them. That extra level is the price of not
31
- * depending on a declaration bundler that predates TypeScript 7, and it is encoded
32
- * here so a change to the layout cannot pass unnoticed.
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 grammar = grammars.dshSentryStyleGrammar()
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:state'),
189
- 'the built engine did not report the unknown state',
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(inspection, grammar, {
192
- text: 'running ',
193
- caret: 'running '.length,
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. The
71
- * font-query grammar paints `Geist Mono` as a family from the characters alone,
72
- * but which family is *in effect* depends on the installed catalogue — the same
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 reference grammar is
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 the engine and the DOM layer (this file)
10
- // @citisen/litearea/react a React binding over the same editor
11
- // @citisen/litearea/grammars two worked grammars for two real DSLs
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. `src/core/` is pure and needs no DOM; `src/dom/`
14
- // needs a document and no framework.
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,