@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.
@@ -1,1004 +0,0 @@
1
- // ─── dsh-font's font query, as a litearea grammar ───────────────────────────
2
- //
3
- // The language is a CSS font-family list plus the one thing the list cannot carry:
4
- //
5
- // Geist Mono medium, "Zhuque Fangsong (technical preview)", monospace
6
- // └─────┬────┘ └──┬─┘
7
- // family weight
8
- //
9
- // `font-family: "Geist Mono" 500, monospace` is an invalid declaration that drops
10
- // the whole stack, so the weight has to be its own `font-weight` — but it can
11
- // still be WRITTEN next to the family it belongs to, which is what lets one field
12
- // describe both. A weight is chosen for a family; having to change them in two
13
- // places is exactly the problem this language exists to remove.
14
- //
15
- // query := entry ("," entry)*
16
- // entry := family | weight
17
- // family := '"' … '"' | "'" … "'" | word (space word)*
18
- // weight := a word from WEIGHT_WORDS
19
- //
20
- // Only the LAST word of an unquoted entry may be a weight, and only when the whole
21
- // entry is not itself a catalogued family: `Book Antiqua` and `Franklin Gothic
22
- // Medium` are real families, and stripping either would silently retarget the stack
23
- // at a font nobody picked. Quoting always means "this is the family name,
24
- // verbatim", so a quoted entry never splits.
25
- //
26
- // The parser is forgiving on purpose. This text is typed by hand into a small box,
27
- // not generated, so anything it cannot place is kept as written and reported as a
28
- // diagnostic instead of being silently dropped. This grammar keeps that behaviour
29
- // AND adds the one thing the original editor could not do: it tells you at the
30
- // moment you type, rather than after you press a key.
31
- //
32
- // This is a REFERENCE grammar, not a built-in one. Nothing in `src/core/` knows
33
- // this language exists.
34
- //
35
- // Semantic colouring, and why it is not a token rule's business
36
- // ------------------------------------------------------------
37
- // Two of the original's token classes are not lexical at all. `weightMissing` — a
38
- // weight the chosen family does not have — depends on the machine's installed
39
- // faces, and `unknown` depends on whether the catalogue is authoritative. Both are
40
- // still painted here, from a `scope` FUNCTION that reads the analysis, because a
41
- // rule may look at `match.state`. That is this library's answer to VSCode's split
42
- // between a TextMate grammar and semantic tokens: one rule set, two sources of
43
- // truth about a span, and the semantic one is allowed to override.
44
-
45
- import type {
46
- Decoration,
47
- Grammar,
48
- Scope,
49
- Severity,
50
- SuggestionItem,
51
- } from '../core/types.js'
52
- import { defineGrammar } from '../core/grammar.js'
53
- import { defineVocabulary } from '../core/vocabulary.js'
54
-
55
- /** Every weight word the language accepts, mapped to its CSS number. */
56
- export const FONT_WEIGHT_WORDS: Readonly<Record<string, number>> = {
57
- thin: 100,
58
- hairline: 100,
59
- extralight: 200,
60
- ultralight: 200,
61
- light: 300,
62
- book: 400,
63
- normal: 400,
64
- regular: 400,
65
- roman: 400,
66
- medium: 500,
67
- demibold: 600,
68
- semibold: 600,
69
- bold: 700,
70
- extrabold: 800,
71
- ultrabold: 800,
72
- black: 900,
73
- heavy: 900,
74
- extrablack: 900,
75
- ultrablack: 900,
76
- }
77
-
78
- /** The CSS weight scale, in the order a picker shows it. */
79
- export const FONT_WEIGHT_SCALE: readonly number[] = [100, 200, 300, 400, 500, 600, 700, 800, 900]
80
-
81
- /** The canonical word per weight — `500` is `medium`. */
82
- export const FONT_WEIGHT_LABELS: Readonly<Record<number, string>> = {
83
- 100: 'thin',
84
- 200: 'extralight',
85
- 300: 'light',
86
- 400: 'regular',
87
- 500: 'medium',
88
- 600: 'semibold',
89
- 700: 'bold',
90
- 800: 'extrabold',
91
- 900: 'black',
92
- }
93
-
94
- /**
95
- * Generic CSS families. Valid anywhere in a list, and only useful at the end,
96
- * which is why they are coloured apart from an installed family: picking one is a
97
- * different intent from picking a font.
98
- */
99
- export const FONT_GENERIC_FAMILIES: readonly string[] = [
100
- 'system-ui',
101
- 'sans-serif',
102
- 'serif',
103
- 'monospace',
104
- 'cursive',
105
- 'fantasy',
106
- 'math',
107
- 'emoji',
108
- 'fangsong',
109
- 'ui-sans-serif',
110
- 'ui-serif',
111
- 'ui-monospace',
112
- 'ui-rounded',
113
- ]
114
-
115
- /**
116
- * Popular families offered as suggestions even when not detected.
117
- *
118
- * Detection cannot see every font and a family may be installed later, so the
119
- * picker must not present itself as the complete truth. A host that has read the
120
- * real catalogue passes it in; this list is what a host with no permission to read
121
- * one can still offer.
122
- */
123
- export const FONT_COMMON_FAMILIES: readonly string[] = [
124
- 'Inter',
125
- 'IBM Plex Sans',
126
- 'IBM Plex Mono',
127
- 'Noto Sans',
128
- 'Noto Sans SC',
129
- 'Noto Serif',
130
- 'Source Han Sans SC',
131
- 'Source Han Serif SC',
132
- 'JetBrains Mono',
133
- 'Fira Code',
134
- 'Fira Sans',
135
- 'Cascadia Code',
136
- 'Cascadia Mono',
137
- 'Maple Mono',
138
- 'Roboto',
139
- 'Roboto Mono',
140
- 'Open Sans',
141
- 'Lato',
142
- 'Montserrat',
143
- 'Poppins',
144
- 'Ubuntu',
145
- 'Ubuntu Mono',
146
- 'DejaVu Sans',
147
- 'DejaVu Sans Mono',
148
- 'Hack',
149
- 'Inconsolata',
150
- 'Iosevka',
151
- 'Comic Sans MS',
152
- 'PingFang SC',
153
- 'Hiragino Sans GB',
154
- 'Microsoft YaHei',
155
- 'Microsoft YaHei UI',
156
- 'Microsoft JhengHei',
157
- 'SimSun',
158
- 'SimHei',
159
- 'KaiTi',
160
- 'Segoe UI',
161
- 'Segoe UI Variable',
162
- 'Helvetica Neue',
163
- 'Arial',
164
- 'Consolas',
165
- 'Menlo',
166
- 'Monaco',
167
- 'SF Mono',
168
- 'Courier New',
169
- 'Times New Roman',
170
- 'Georgia',
171
- ]
172
-
173
- /** The scope names this grammar paints with. */
174
- const SCOPE = {
175
- family: 'family',
176
- generic: 'family.generic',
177
- unknown: 'family.unknown',
178
- unclosed: 'family.unclosed',
179
- weight: 'weight',
180
- weightMissing: 'weight.missing',
181
- separator: 'separator',
182
- } as const
183
-
184
- /** One entry of the query, as it was found in the source. */
185
- export interface DshFontEntry {
186
- /** The whole entry, whitespace included. */
187
- from: number
188
- to: number
189
- /** The trimmed core, and its range. */
190
- coreFrom: number
191
- coreTo: number
192
- core: string
193
- /** Whether the core opens with a quote. */
194
- quoted: boolean
195
- /** The family name, with the quotes and any weight word taken out. */
196
- name: string
197
- nameFrom: number
198
- nameTo: number
199
- /** The weight word the entry carries, if any. */
200
- word: string | undefined
201
- wordFrom: number
202
- wordTo: number
203
- /** What the reader made of the entry. */
204
- kind: 'empty' | 'family' | 'generic' | 'unknown' | 'weight'
205
- }
206
-
207
- /** A problem the structural pass found. */
208
- export interface DshFontProblem {
209
- from: number
210
- to: number
211
- message: string
212
- code: string
213
- severity: Severity
214
- }
215
-
216
- /** What one pass over the query produced. */
217
- export interface DshFontState {
218
- entries: DshFontEntry[]
219
- /** The family names the entries name, in order. */
220
- families: string[]
221
- /** Which of them the browser will actually paint with; -1 when none will. */
222
- effective: number
223
- /** The numeric weight the query states, if it states one. */
224
- weight: number | undefined
225
- weightWord: string | undefined
226
- problems: DshFontProblem[]
227
- }
228
-
229
- /** What a host supplies to describe the machine the query will run on. */
230
- export interface DshFontQueryOptions {
231
- /** The family names the machine has. */
232
- catalogue?: readonly string[]
233
- /**
234
- * Whether that catalogue was READ from the machine.
235
- *
236
- * It changes what an unrecognized name means. With a read catalogue the browser
237
- * will fall through to a later family, which is worth warning about; without one
238
- * the catalogue is only a suggestion list, and the parser has no business
239
- * second-guessing a name the user typed.
240
- */
241
- enumerated?: boolean
242
- /** The face style names of each family, as the Local Font Access API reports them. */
243
- styles?: Readonly<Record<string, readonly string[]>>
244
- /** The weight the axis ships with, offered as a row that takes the word away. */
245
- shippedWeight?: number
246
- /** Families offered even when not detected. */
247
- commonFamilies?: readonly string[]
248
- /** The generic CSS families. */
249
- genericFamilies?: readonly string[]
250
- /** How many words one unquoted family name may span when matching the catalogue. */
251
- phraseWords?: number
252
- }
253
-
254
- /**
255
- * The word a numeric weight is spelled with: `500` becomes `medium`.
256
- * @param weight - a numeric weight.
257
- * @returns the canonical word, or the number as text when it is off the scale.
258
- */
259
- export function fontWeightWord(weight: number): string {
260
- return FONT_WEIGHT_LABELS[weight] ?? String(weight)
261
- }
262
-
263
- /**
264
- * The weights one family actually has, read off its faces' style names.
265
- *
266
- * The Local Font Access API reports a face's `style` (`Regular`, `SemiBold`,
267
- * `Bold Italic`) rather than a number, and is not obliged to spell a two-word
268
- * style with a hyphen, so `Semi Bold` has to be folded back into one word before
269
- * the lookup.
270
- *
271
- * An empty result means "unknown", never "none": enumerating faces is
272
- * permission-gated, and a family may report styles this vocabulary cannot read.
273
- * @param styles - the face style names of one family.
274
- * @returns the distinct weights, ascending.
275
- */
276
- export function fontFaceWeights(styles: readonly string[] | undefined): number[] {
277
- if (styles === undefined) return []
278
- const found = new Set<number>()
279
- for (const style of styles) {
280
- const text = String(style)
281
- .toLowerCase()
282
- .replace(/\b(semi|demi)\s+(?=[a-z])/g, 'semi')
283
- .replace(/\b(extra|ultra)\s+(?=[a-z])/g, 'extra')
284
- for (const word of text.split(/[^a-z]+/)) {
285
- const weight = FONT_WEIGHT_WORDS[word]
286
- if (weight !== undefined) found.add(weight)
287
- }
288
- }
289
- return [...found].sort((left, right) => left - right)
290
- }
291
-
292
- /**
293
- * Render a family name for a CSS list, quoting it only when CSS requires it.
294
- *
295
- * A generic family or a CSS-wide keyword must NOT be quoted, because `"sans-serif"`
296
- * names a literal font instead of the generic family. A single leading hyphen is
297
- * part of an identifier (`-apple-system`), so it survives unquoted too.
298
- * @param family - a bare family name.
299
- * @returns the CSS token for it.
300
- */
301
- export function quoteFontFamily(family: string): string {
302
- const name = family.trim()
303
- if (name === '') return ''
304
- if (/^-?[A-Za-z][\w-]*$/.test(name)) return name
305
- return `"${name.replaceAll('"', '')}"`
306
- }
307
-
308
- /** Whether a lowercase name is a generic CSS family. */
309
- function isGenericName(name: string, generics: readonly string[]): boolean {
310
- return generics.includes(name)
311
- }
312
-
313
- /**
314
- * Find the entries of a query, keeping every raw slice.
315
- *
316
- * Commas inside quotes are not separators, and an unclosed quote swallows the rest
317
- * of the document — the same reading the original takes, which is what makes the
318
- * unclosed-quote diagnostic worth having.
319
- * @param source - the query.
320
- * @returns one range per entry, in order. A blank query yields one empty entry.
321
- */
322
- function splitEntries(source: string): Array<{ from: number; to: number }> {
323
- const bounds: Array<{ from: number; to: number }> = []
324
- let start = 0
325
- let quote = ''
326
- for (let index = 0; index < source.length; index += 1) {
327
- const char = source.charAt(index)
328
- if (quote !== '') {
329
- if (char === quote) quote = ''
330
- continue
331
- }
332
- if (char === '"' || char === "'") {
333
- quote = char
334
- continue
335
- }
336
- if (char === ',') {
337
- bounds.push({ from: start, to: index })
338
- start = index + 1
339
- }
340
- }
341
- bounds.push({ from: start, to: source.length })
342
- return bounds
343
- }
344
-
345
- /**
346
- * Build the grammar for dsh-font's font query.
347
- * @param options - the machine's catalogue, its faces, and the shipped weight.
348
- * @returns a grammar that paints, completes, diagnoses, and explains the language.
349
- */
350
- export function dshFontQueryGrammar(options: DshFontQueryOptions = {}): Grammar<DshFontState> {
351
- const CATALOGUE = options.catalogue ?? []
352
- const COMMON = options.commonFamilies ?? FONT_COMMON_FAMILIES
353
- const GENERICS = options.genericFamilies ?? FONT_GENERIC_FAMILIES
354
- const STYLES = options.styles ?? {}
355
- const ENUMERATED = options.enumerated === true
356
- const SHIPPED_WEIGHT = options.shippedWeight
357
- const PHRASE_WORDS = options.phraseWords ?? 4
358
-
359
- /** The lowercase catalogue, which is what every lookup folds against. */
360
- const KNOWN = new Set(CATALOGUE.map((family) => family.toLowerCase()))
361
- /** The suggestion space: the catalogue when there is one, plus the curated list. */
362
- const SUGGESTION_SPACE = [...new Set([...CATALOGUE, ...COMMON, ...GENERICS])]
363
- /** Every weight word, as a literal alternation, for the lexical weight rule. */
364
- const WEIGHT_ALTERNATION = Object.keys(FONT_WEIGHT_WORDS).join('|')
365
-
366
- /** The families that explain themselves through hover. */
367
- const FAMILY_VOCAB = defineVocabulary<DshFontState>({
368
- id: 'family',
369
- words: SUGGESTION_SPACE,
370
- scope: SCOPE.family,
371
- format: quoteFontFamily,
372
- })
373
-
374
- const GENERIC_VOCAB = defineVocabulary<DshFontState>({
375
- id: 'generic',
376
- words: GENERICS,
377
- scope: SCOPE.generic,
378
- docs: Object.fromEntries(
379
- GENERICS.map((name) => [
380
- name,
381
- {
382
- detail: 'a generic CSS family',
383
- body: 'Always valid, and only useful at the end of the list: it is what the browser falls back to when nothing before it resolves.',
384
- },
385
- ]),
386
- ),
387
- })
388
-
389
- const WEIGHT_VOCAB = defineVocabulary<DshFontState>({
390
- id: 'weight',
391
- words: Object.keys(FONT_WEIGHT_WORDS),
392
- scope: SCOPE.weight,
393
- unknownScope: SCOPE.weight,
394
- docs: Object.fromEntries(
395
- Object.entries(FONT_WEIGHT_WORDS).map(([word, weight]) => [
396
- word,
397
- { title: word, detail: `font-weight ${String(weight)}` },
398
- ]),
399
- ),
400
- })
401
-
402
- /**
403
- * Read one entry range into the family it names and the weight it carries.
404
- *
405
- * The two shapes a weight can take are both here: a bare entry whose last word
406
- * is a weight (`Geist Mono medium`), and a quoted family followed by one
407
- * (`"Geist Mono" medium`). Only the last word of an UNQUOTED entry may be a
408
- * weight, and only when the whole entry is not itself a catalogued family.
409
- * @param source - the query.
410
- * @param bounds - the entry's range.
411
- * @returns the entry, and any complaint about it.
412
- */
413
- const readEntry = (
414
- source: string,
415
- bounds: { from: number; to: number },
416
- ): { entry: DshFontEntry; problems: DshFontProblem[] } => {
417
- const raw = source.slice(bounds.from, bounds.to)
418
- const trimmed = raw.trim()
419
- const lead = trimmed === '' ? raw.length : raw.indexOf(trimmed)
420
- const coreFrom = bounds.from + lead
421
- const core = trimmed
422
- const coreTo = coreFrom + core.length
423
- const problems: DshFontProblem[] = []
424
-
425
- const entry: DshFontEntry = {
426
- from: bounds.from,
427
- to: bounds.to,
428
- coreFrom,
429
- coreTo,
430
- core,
431
- quoted: false,
432
- name: '',
433
- nameFrom: coreFrom,
434
- nameTo: coreTo,
435
- word: undefined,
436
- wordFrom: coreTo,
437
- wordTo: coreTo,
438
- kind: 'empty',
439
- }
440
- if (core === '') return { entry, problems }
441
-
442
- const quoteChar = core.charAt(0)
443
- const quote = quoteChar === '"' || quoteChar === "'" ? quoteChar : ''
444
- const lower = core.toLowerCase()
445
- /**
446
- * Whether the entry is still worth looking up.
447
- *
448
- * A quote that never closes, or text after a closing one that is neither a weight
449
- * nor part of the name, means the entry is already reported — and the name the
450
- * reader salvaged from it is a consequence of that mistake, not a separate fact
451
- * about the machine. Warning that this consequence is "not in the font list" would
452
- * bury the cause under its symptom.
453
- */
454
- let lookUp = true
455
-
456
- if (quote !== '') {
457
- entry.quoted = true
458
- const close = core.indexOf(quote, 1)
459
- if (close < 0) {
460
- // An unclosed quote runs to the end of the document, which is why it is
461
- // reported rather than repaired: everything after it became one name.
462
- entry.name = core.slice(1).trim()
463
- entry.nameFrom = coreFrom + 1
464
- entry.kind = 'family'
465
- problems.push({
466
- from: coreFrom,
467
- to: coreTo,
468
- message: 'This quote is never closed, so everything after it is read as part of one family name.',
469
- code: 'unclosed-quote',
470
- severity: 'error',
471
- })
472
- return { entry, problems }
473
- }
474
- entry.name = core.slice(1, close)
475
- entry.nameFrom = coreFrom + 1
476
- entry.nameTo = coreFrom + close
477
- const rest = core.slice(close + 1).trim()
478
- if (rest !== '') {
479
- const restFrom = coreTo - rest.length
480
- if (Object.hasOwn(FONT_WEIGHT_WORDS, rest.toLowerCase())) {
481
- entry.word = rest.toLowerCase()
482
- entry.wordFrom = restFrom
483
- entry.wordTo = coreTo
484
- } else {
485
- problems.push({
486
- from: restFrom,
487
- to: coreTo,
488
- message: `"${rest}" follows a quoted family name but is neither a weight nor part of it, so it is ignored.`,
489
- code: 'trailing-text',
490
- severity: 'error',
491
- })
492
- lookUp = false
493
- }
494
- }
495
- entry.kind = 'family'
496
- } else if (isGenericName(lower, GENERICS) || KNOWN.has(lower)) {
497
- // The whole entry is one family: there is nothing to strip off it.
498
- entry.name = core
499
- entry.kind = isGenericName(lower, GENERICS) ? 'generic' : 'family'
500
- } else if (Object.hasOwn(FONT_WEIGHT_WORDS, lower)) {
501
- // A bare weight word stands on its own, without a family.
502
- entry.kind = 'weight'
503
- entry.word = lower
504
- entry.wordFrom = coreFrom
505
- entry.wordTo = coreTo
506
- } else {
507
- const cut = lower.lastIndexOf(' ')
508
- const tail = cut > 0 ? lower.slice(cut + 1) : ''
509
- if (cut > 0 && Object.hasOwn(FONT_WEIGHT_WORDS, tail)) {
510
- entry.word = tail
511
- entry.wordFrom = coreFrom + cut + 1
512
- entry.wordTo = coreTo
513
- entry.name = core.slice(0, cut).trim()
514
- entry.nameTo = entry.nameFrom + entry.name.length
515
- } else {
516
- entry.name = core
517
- }
518
- entry.kind = 'family'
519
- }
520
-
521
- const nameLower = entry.name.trim().toLowerCase()
522
- const generic = nameLower !== '' && isGenericName(nameLower, GENERICS)
523
- const catalogued = nameLower !== '' && KNOWN.has(nameLower)
524
- if (lookUp && nameLower !== '' && !generic && !catalogued && ENUMERATED) {
525
- entry.kind = 'unknown'
526
- problems.push({
527
- from: entry.nameFrom,
528
- to: entry.nameFrom + entry.name.length,
529
- message: `"${entry.name}" is not in this machine's font list. It is still written, and the browser will fall back to whatever comes after it.`,
530
- code: 'unknown-family',
531
- severity: 'warning',
532
- })
533
- } else if (generic) {
534
- entry.kind = 'generic'
535
- }
536
- return { entry, problems }
537
- }
538
-
539
- return defineGrammar<DshFontState>({
540
- id: 'dsh-font-query',
541
- name: 'dsh-font font query',
542
-
543
- // A hyphen belongs to a name (`-apple-system`, `Helvetica Neue` has spaces and
544
- // is reached by the phrase rule instead), and `.` deliberately does not: no
545
- // family name in this vocabulary contains one, and leaving it out keeps a
546
- // stray `Inter.` from being read as a single unknown name.
547
- wordChars: /[\p{L}\p{N}_-]/u,
548
-
549
- rules: [
550
- // ── quoted names, before anything can split them ───────────────────
551
- {
552
- kind: 'match',
553
- pattern: /"[^"\n]*"|'[^'\n]*'/,
554
- // The scope is decided by what is INSIDE the quotes: a quoted generic is
555
- // still a generic, and a quoted name the machine does not have is still
556
- // worth painting as unknown.
557
- scope: (match) => quotedScope(match.text, KNOWN, GENERICS, ENUMERATED),
558
- },
559
- {
560
- kind: 'match',
561
- pattern: /["'][^\n]*/,
562
- scope: SCOPE.unclosed,
563
- },
564
-
565
- { kind: 'match', scope: SCOPE.separator, pattern: /,/ },
566
-
567
- // ── a weight word, but only where a weight may stand ──────────────
568
- // A weight is the LAST word of an entry, so the rule looks ahead for a comma
569
- // or the end of a line. Without that lookahead `Book` in `Book Antiqua`
570
- // would be painted as a weight. `prevNot` stops it matching the tail of a
571
- // longer word.
572
- //
573
- // The scope is decided from the ANALYSIS rather than from the characters,
574
- // which is the one place this grammar needs that: `weight` and
575
- // `weightMissing` are the same word, and only the machine knows which one it
576
- // is. A rule may look at `match.state` precisely so that a semantic
577
- // judgement does not have to become a second highlighter.
578
- {
579
- kind: 'match',
580
- pattern: new RegExp(`(?:${WEIGHT_ALTERNATION})(?=\\s*(?:,|$))`, 'im'),
581
- when: { prevNot: '\\w' },
582
- scope: (match) => {
583
- const state = match.state
584
- if (state.weight === undefined || state.effective < 0) return SCOPE.weight
585
- const family = state.families[state.effective] ?? ''
586
- const faces = fontFaceWeights(lookupStyles(STYLES, family))
587
- // An unread face list means "unknown", never "absent".
588
- if (faces.length === 0 || faces.includes(state.weight)) return SCOPE.weight
589
- return SCOPE.weightMissing
590
- },
591
- },
592
-
593
- // ── the catalogue, longest phrase first ──────────────────────────
594
- // The generics come first because they are ALSO in the suggestion space,
595
- // and a generic painted as an installed family would say the wrong thing
596
- // about a word whose whole point is that it names no particular font.
597
- { kind: 'words', words: GENERIC_VOCAB },
598
- {
599
- kind: 'words',
600
- words: FAMILY_VOCAB,
601
- phrase: { max: PHRASE_WORDS },
602
- },
603
-
604
- // ── anything else is a name the reader has not seen ──────────────
605
- // `unknown` when the catalogue is authoritative, because then the browser
606
- // really will fall through; plain `family` when it is only a suggestion
607
- // list, because the parser has no business doubting the user.
608
- {
609
- kind: 'match',
610
- pattern: /[^\s,]+/,
611
- scope: ENUMERATED ? SCOPE.unknown : SCOPE.family,
612
- },
613
- ],
614
-
615
- fallbackScope: 'text',
616
-
617
- // ── what the query means ──────────────────────────────────────────────
618
- analyze: (text) => {
619
- const entries: DshFontEntry[] = []
620
- const problems: DshFontProblem[] = []
621
- for (const bounds of splitEntries(text)) {
622
- const read = readEntry(text, bounds)
623
- entries.push(read.entry)
624
- problems.push(...read.problems)
625
- }
626
-
627
- const families: string[] = []
628
- let weight: number | undefined
629
- let weightWord: string | undefined
630
- let sawWeight = false
631
- for (const entry of entries) {
632
- if (entry.name.trim() !== '') families.push(entry.name.trim())
633
- if (entry.word !== undefined) {
634
- if (!sawWeight) {
635
- sawWeight = true
636
- weight = FONT_WEIGHT_WORDS[entry.word]
637
- weightWord = entry.word
638
- } else {
639
- problems.push({
640
- from: entry.wordFrom,
641
- to: entry.wordTo,
642
- message: `The weight is stated more than once. The first one is used and this "${entry.word}" is ignored.`,
643
- code: 'duplicate-weight',
644
- severity: 'warning',
645
- })
646
- }
647
- }
648
- }
649
-
650
- // ── the family that is actually in effect ───────────────────────────
651
- // With a catalogue read from the machine the first INSTALLED family wins,
652
- // because that is the one the browser will paint with. Without one the
653
- // catalogue is only a suggestion list, so the first entry stands.
654
- let effective = -1
655
- if (families.length > 0) {
656
- if (!ENUMERATED) {
657
- effective = 0
658
- } else {
659
- for (let index = 0; index < families.length; index += 1) {
660
- const lower = (families[index] ?? '').toLowerCase()
661
- if (isGenericName(lower, GENERICS) || KNOWN.has(lower)) {
662
- effective = index
663
- break
664
- }
665
- }
666
- }
667
- }
668
-
669
- // ── a weight the effective family does not have ──────────────────────
670
- // Only reported when that family's faces are actually known: an empty face
671
- // list means "not read", never "not installed".
672
- if (weight !== undefined && effective >= 0) {
673
- const family = families[effective] ?? ''
674
- const faces = fontFaceWeights(lookupStyles(STYLES, family))
675
- if (faces.length > 0 && !faces.includes(weight)) {
676
- const entry = entries.find((candidate) => candidate.word !== undefined)
677
- if (entry !== undefined) {
678
- problems.push({
679
- from: entry.wordFrom,
680
- to: entry.wordTo,
681
- message: `"${family}" has no ${String(weight)} face, so the browser will synthesise one. It does have ${faces.join(', ')}.`,
682
- code: 'missing-weight',
683
- severity: 'warning',
684
- })
685
- }
686
- }
687
- }
688
-
689
- // Only the unenumerated, non-generic case needs the old generic warning: a list
690
- // with no generic tail is a list with nothing to fall back to.
691
- if (
692
- families.length > 0 &&
693
- !families.some((family) => isGenericName(family.toLowerCase(), GENERICS))
694
- ) {
695
- // A note about the document as a whole belongs to no character, so its range is
696
- // empty and sits at the end. Underlining an entry to say the LIST is incomplete
697
- // would put a squiggle on text that is perfectly correct — and, because a
698
- // diagnostic outranks a description in a tooltip, it would also hide what that
699
- // entry has to say about itself.
700
- problems.push({
701
- from: text.length,
702
- to: text.length,
703
- message:
704
- 'No generic family at the end, so a name that fails to resolve has nothing to fall back to. Adding one, such as `sans-serif`, is free.',
705
- code: 'no-generic-fallback',
706
- severity: 'info',
707
- })
708
- }
709
-
710
- return { entries, families, effective, weight, weightWord, problems }
711
- },
712
-
713
- validate: (context) => {
714
- for (const problem of context.state.problems) {
715
- context.report({
716
- from: problem.from,
717
- to: problem.to,
718
- message: problem.message,
719
- code: problem.code,
720
- severity: problem.severity,
721
- })
722
- }
723
- },
724
-
725
- // ── the family in effect, marked rather than recoloured ───────────────
726
- // It is a decoration and not a scope because it depends on the machine, not on
727
- // the characters: the same text means something else on a computer with
728
- // different fonts, and re-lexing the document whenever the catalogue changed
729
- // would be the wrong shape of work.
730
- decorate: (_text, state) => {
731
- const decorations: Decoration[] = []
732
- if (state.effective < 0) return decorations
733
- const family = state.families[state.effective] ?? ''
734
- const target = state.entries.find((candidate) => candidate.name.trim() === family)
735
- if (target !== undefined) {
736
- decorations.push({
737
- from: target.nameFrom,
738
- to: target.nameFrom + target.name.length,
739
- kind: 'effective',
740
- title: `in effect: ${family}`,
741
- })
742
- }
743
- return decorations
744
- },
745
-
746
- // ── what can come next ────────────────────────────────────────────────
747
- compose: [
748
- {
749
- id: 'family',
750
- // The whole query is entries, so this source is always eligible; the
751
- // entry under the caret decides what it replaces.
752
- when: () => true,
753
- range: (context) => entryRange(context.state, context.caret),
754
- items: (context) => {
755
- const entry = entryAt(context.state.entries, context.caret)
756
- const core = entry?.core ?? ''
757
- const quoted = entry?.quoted === true
758
- // The needle is the FAMILY part of the entry: a weight word the entry
759
- // already carries would otherwise make `"Geist Mono" medium` match no
760
- // family at all.
761
- const inner = unquote(core)
762
- const carried = entry?.word
763
- const needle = carried === undefined ? inner.trim() : inner.slice(0, -(carried.length)).trim()
764
- const exact = findExact(SUGGESTION_SPACE, needle)
765
- // What the caret has already spelled, with any weight word still to come
766
- // left out. This is what makes `Geist Mono b` offer Geist Mono's weights:
767
- // the entry as a whole is not a family name, but the part before the word
768
- // being typed is. Without this fallback a weight becomes unreachable the
769
- // moment the first letter of it is typed.
770
- const headFrom = entry?.coreFrom ?? context.word.from
771
- const head = unquote(context.text.slice(headFrom, Math.max(context.word.from, headFrom))).trim()
772
- // Where the caret sits decides whether a pick replaces the entry or is
773
- // inserted ahead of it. A caret at the very start of a COMPLETE entry is
774
- // a boundary, not an edit: the user put it there to place another family
775
- // in front, which is how a fallback stays a fallback.
776
- const atBoundary = entry !== undefined && context.caret <= entry.coreFrom && exact !== undefined
777
-
778
- const familyInEntry = exact ?? findExact(SUGGESTION_SPACE, head)
779
- // Where the caret sits decides what leads. Past the family name the user
780
- // is reaching for a weight (`Geist Mono b` → Bold); inside the name they
781
- // are still spelling it out, so the family list leads and the list never
782
- // fills with weights while a name is half-written.
783
- const atFamilyEnd = entry === undefined || context.caret >= entry.wordFrom
784
-
785
- // A weight is only offered once the entry names a family.
786
- const weightRows: SuggestionItem[] = []
787
- if (familyInEntry !== undefined && !atBoundary && atFamilyEnd) {
788
- const detected = fontFaceWeights(lookupStyles(STYLES, familyInEntry))
789
- const pool = detected.length > 0 ? detected : [...FONT_WEIGHT_SCALE]
790
- const typedWord = context.word.prefix.toLowerCase()
791
- for (const value of pool) {
792
- const word = fontWeightWord(value)
793
- if (typedWord !== '' && !word.startsWith(typedWord)) continue
794
- weightRows.push({
795
- label: `${familyInEntry} ${word}`,
796
- insert: quoteFontFamily(familyInEntry) + (value === SHIPPED_WEIGHT ? '' : ` ${word}`),
797
- kind: 'weight',
798
- detail: `font-weight ${String(value)}`,
799
- documentation:
800
- value === SHIPPED_WEIGHT
801
- ? 'The weight this axis already uses, so picking it takes the word away rather than spelling out a value nobody chose.'
802
- : undefined,
803
- // A weight's position is decided by the SCALE and not by the length of
804
- // its label, which is what the zero-padded key is for: without it the
805
- // shortest word would lead, so `bold` would sit above the shipped
806
- // `regular` and the list would look shuffled. The weight the entry
807
- // already states outranks all of them, so an Enter that accepts the top
808
- // row re-applies what is written.
809
- sortText:
810
- word === carried ? '0' : `1${String(value).padStart(3, '0')}`,
811
- })
812
- }
813
- }
814
-
815
- const familyRows: SuggestionItem[] = []
816
- for (const name of SUGGESTION_SPACE) {
817
- const generic = isGenericName(name.toLowerCase(), GENERICS)
818
- familyRows.push({
819
- label: name,
820
- // A completion never drops a weight the entry already states: the
821
- // word travels with the pick, so swapping the family does not quietly
822
- // reset the weight.
823
- insert: quoteFontFamily(name) + (carried === undefined ? '' : ` ${carried}`),
824
- mode: atBoundary ? 'before' : 'replace',
825
- // A comma invites the next fallback. Only after a family, never after
826
- // a weight, which completes the entry instead of starting one.
827
- append: atBoundary || lastEntry(context.state, context.caret) ? ', ' : '',
828
- kind: generic ? 'generic' : 'family',
829
- detail: generic
830
- ? 'generic family'
831
- : KNOWN.has(name.toLowerCase())
832
- ? 'installed'
833
- : 'suggested',
834
- // After the weights when weights lead, which is the whole reason a
835
- // grammar gets to name the group: `2` sorts above `1xxx` and below `0`.
836
- sortText: weightRows.length > 0 ? '2' : '0',
837
- })
838
- }
839
-
840
- const rows = weightRows.length > 0 ? [...weightRows, ...familyRows] : familyRows
841
- // The entry exactly as typed, so a name nobody catalogued can still be
842
- // completed to itself rather than being impossible to accept.
843
- if (needle !== '' && exact === undefined && !isGenericName(needle.toLowerCase(), GENERICS)) {
844
- rows.push({
845
- label: inner,
846
- insert: inner,
847
- kind: 'custom',
848
- detail: 'as typed',
849
- documentation:
850
- 'Written exactly as it stands. A name the browser does not have is still a valid declaration: it is what lets a stack work on a machine this one cannot see.',
851
- sortText: '3',
852
- })
853
- }
854
- return rows
855
- },
856
- },
857
- ],
858
-
859
- // ── what a thing is ───────────────────────────────────────────────────
860
- describe: (context) => {
861
- const token = context.token
862
- if (token === undefined) return undefined
863
- const state = context.state
864
- const entry = entryAt(state.entries, context.offset)
865
-
866
- if (token.scope === SCOPE.separator) return undefined
867
-
868
- if (token.scope === SCOPE.weight || token.scope === SCOPE.weightMissing) {
869
- const word = token.text.toLowerCase()
870
- const value = FONT_WEIGHT_WORDS[word]
871
- const family = state.effective >= 0 ? state.families[state.effective] : undefined
872
- if (value === undefined) return { title: token.text }
873
- const faces = family === undefined ? [] : fontFaceWeights(lookupStyles(STYLES, family))
874
- return {
875
- title: `${word} — font-weight ${String(value)}`,
876
- detail: entry?.name === '' ? 'applies to the whole axis' : `applies to ${family ?? 'the first family'}`,
877
- body:
878
- faces.length === 0
879
- ? `${family ?? 'This family'} was not read, so whether it has a ${String(value)} face is unknown.`
880
- : faces.includes(value)
881
- ? `${family ?? 'This family'} has this face.`
882
- : `${family ?? 'This family'} has no ${String(value)} face, so the browser will synthesise one.`,
883
- }
884
- }
885
-
886
- if (token.scope === SCOPE.generic) {
887
- return {
888
- title: token.text,
889
- detail: 'a generic CSS family',
890
- body: 'Always valid, and only useful at the end of the list: it is what the browser falls back to when nothing before it resolves.',
891
- }
892
- }
893
-
894
- if (token.scope === SCOPE.unclosed) {
895
- return {
896
- title: token.text,
897
- detail: 'unclosed quote',
898
- body: 'The closing quote is missing, so this and everything after it are read as one family name. A font family containing a quote character has to be written with the other quote style, because backslash escapes are deliberately not interpreted.',
899
- }
900
- }
901
-
902
- if (entry !== undefined && entry.kind === 'unknown') {
903
- return {
904
- title: entry.name,
905
- detail: 'not installed here',
906
- body: 'Still a valid declaration: it is written to the setting, and the browser falls through to the next family in the list when it cannot resolve. Reordering it to the end of the list is what the fallbacks are for.',
907
- }
908
- }
909
-
910
- const entryDoc = FAMILY_VOCAB.entryFor(token.text)
911
- const installed = KNOWN.has(token.text.toLowerCase())
912
- const faces = fontFaceWeights(lookupStyles(STYLES, token.text))
913
- return {
914
- title: token.text,
915
- detail: installed ? 'installed' : 'not read on this machine',
916
- body:
917
- entryDoc?.body ??
918
- (faces.length > 0
919
- ? `Faces read from this machine: ${faces.join(', ')}.`
920
- : 'This family has no faces recorded, so its weights are unknown rather than absent.'),
921
- }
922
- },
923
- })
924
- }
925
-
926
- /** The scope a closed quoted entry is painted with. */
927
- function quotedScope(
928
- text: string,
929
- known: ReadonlySet<string>,
930
- generics: readonly string[],
931
- enumerated: boolean,
932
- ): Scope {
933
- const quote = text.charAt(0)
934
- const inner = text.length >= 2 && text.endsWith(quote) ? text.slice(1, -1) : text.slice(1)
935
- const lower = inner.trim().toLowerCase()
936
- if (lower === '') return SCOPE.family
937
- if (generics.includes(lower)) return SCOPE.generic
938
- if (known.has(lower)) return SCOPE.family
939
- return enumerated ? SCOPE.unknown : SCOPE.family
940
- }
941
-
942
- /** Take one matching pair of surrounding quotes off a name. */
943
- function unquote(name: string): string {
944
- const text = String(name)
945
- const quote = text.charAt(0)
946
- if ((quote === '"' || quote === "'") && text.length >= 2 && text.endsWith(quote)) {
947
- return text.slice(1, -1)
948
- }
949
- return text
950
- }
951
-
952
- /** The entry an offset falls in, when it falls in one. */
953
- function entryAt(entries: readonly DshFontEntry[], offset: number): DshFontEntry | undefined {
954
- for (const entry of entries) {
955
- if (offset >= entry.from && offset <= entry.to) return entry
956
- }
957
- return entries[entries.length - 1]
958
- }
959
-
960
- /**
961
- * The range a completion over the caret replaces: the CORE of the entry it sits
962
- * in, without the whitespace around it.
963
- *
964
- * The whitespace is left out on purpose, and it matters twice. It keeps the
965
- * replacement from swallowing the separator that belongs to the comma before it,
966
- * and it keeps the needle honest — the text between the range's start and the
967
- * caret is what the list is filtered by, and a leading space in it would match
968
- * nothing while looking like it should match everything.
969
- * @param state - the analysis.
970
- * @param caret - the caret offset.
971
- * @returns the range to replace.
972
- */
973
- function entryRange(state: DshFontState, caret: number): { from: number; to: number } {
974
- const entry = entryAt(state.entries, caret)
975
- return entry === undefined ? { from: caret, to: caret } : { from: entry.coreFrom, to: entry.coreTo }
976
- }
977
-
978
- /** Whether the caret's entry is the last one in the document. */
979
- function lastEntry(state: DshFontState, caret: number): boolean {
980
- const entry = entryAt(state.entries, caret)
981
- return entry === undefined || entry === state.entries[state.entries.length - 1]
982
- }
983
-
984
- /** Case-insensitive exact lookup in a suggestion space. */
985
- function findExact(names: readonly string[], needle: string): string | undefined {
986
- const lower = needle.toLowerCase()
987
- return names.find((name) => name.toLowerCase() === lower)
988
- }
989
-
990
- /** The face style names of a family, matched without regard to case. */
991
- function lookupStyles(
992
- styles: Readonly<Record<string, readonly string[]>>,
993
- family: string,
994
- ): readonly string[] | undefined {
995
- if (Object.hasOwn(styles, family)) return styles[family]
996
- const lower = family.toLowerCase()
997
- for (const [name, faces] of Object.entries(styles)) {
998
- if (name.toLowerCase() === lower) return faces
999
- }
1000
- return undefined
1001
- }
1002
-
1003
- /** A diagnostic the grammar rates as informational rather than as a mistake. */
1004
- export const DSH_FONT_INFO_CODES: readonly string[] = ['no-generic-fallback']