@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
package/src/grammars/dshFont.ts
DELETED
|
@@ -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']
|