@citisen/litearea 0.1.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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/README.zh.md +362 -0
  4. package/dist/grammars.cjs +1228 -0
  5. package/dist/grammars.cjs.map +1 -0
  6. package/dist/grammars.js +1213 -0
  7. package/dist/grammars.js.map +1 -0
  8. package/dist/index.cjs +3103 -0
  9. package/dist/index.cjs.map +1 -0
  10. package/dist/index.js +3040 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/react.cjs +3032 -0
  13. package/dist/react.cjs.map +1 -0
  14. package/dist/react.js +3010 -0
  15. package/dist/react.js.map +1 -0
  16. package/dist/styles.cjs +453 -0
  17. package/dist/styles.cjs.map +1 -0
  18. package/dist/styles.css +432 -0
  19. package/dist/styles.js +447 -0
  20. package/dist/styles.js.map +1 -0
  21. package/dist/types/core/complete.d.ts +70 -0
  22. package/dist/types/core/complete.d.ts.map +1 -0
  23. package/dist/types/core/format.d.ts +34 -0
  24. package/dist/types/core/format.d.ts.map +1 -0
  25. package/dist/types/core/grammar.d.ts +14 -0
  26. package/dist/types/core/grammar.d.ts.map +1 -0
  27. package/dist/types/core/hover.d.ts +23 -0
  28. package/dist/types/core/hover.d.ts.map +1 -0
  29. package/dist/types/core/index.d.ts +12 -0
  30. package/dist/types/core/index.d.ts.map +1 -0
  31. package/dist/types/core/inspect.d.ts +34 -0
  32. package/dist/types/core/inspect.d.ts.map +1 -0
  33. package/dist/types/core/rank.d.ts +82 -0
  34. package/dist/types/core/rank.d.ts.map +1 -0
  35. package/dist/types/core/scan.d.ts +51 -0
  36. package/dist/types/core/scan.d.ts.map +1 -0
  37. package/dist/types/core/segments.d.ts +44 -0
  38. package/dist/types/core/segments.d.ts.map +1 -0
  39. package/dist/types/core/text.d.ts +113 -0
  40. package/dist/types/core/text.d.ts.map +1 -0
  41. package/dist/types/core/types.d.ts +604 -0
  42. package/dist/types/core/types.d.ts.map +1 -0
  43. package/dist/types/core/vocabulary.d.ts +82 -0
  44. package/dist/types/core/vocabulary.d.ts.map +1 -0
  45. package/dist/types/dom/create.d.ts +17 -0
  46. package/dist/types/dom/create.d.ts.map +1 -0
  47. package/dist/types/dom/editing.d.ts +89 -0
  48. package/dist/types/dom/editing.d.ts.map +1 -0
  49. package/dist/types/dom/editor.d.ts +366 -0
  50. package/dist/types/dom/editor.d.ts.map +1 -0
  51. package/dist/types/dom/index.d.ts +9 -0
  52. package/dist/types/dom/index.d.ts.map +1 -0
  53. package/dist/types/dom/mirror.d.ts +107 -0
  54. package/dist/types/dom/mirror.d.ts.map +1 -0
  55. package/dist/types/dom/overlay.d.ts +52 -0
  56. package/dist/types/dom/overlay.d.ts.map +1 -0
  57. package/dist/types/dom/popup.d.ts +95 -0
  58. package/dist/types/dom/popup.d.ts.map +1 -0
  59. package/dist/types/dom/support.d.ts +41 -0
  60. package/dist/types/dom/support.d.ts.map +1 -0
  61. package/dist/types/dom/tooltip.d.ts +39 -0
  62. package/dist/types/dom/tooltip.d.ts.map +1 -0
  63. package/dist/types/grammars/dshFont.d.ts +127 -0
  64. package/dist/types/grammars/dshFont.d.ts.map +1 -0
  65. package/dist/types/grammars/dshSentry.d.ts +84 -0
  66. package/dist/types/grammars/dshSentry.d.ts.map +1 -0
  67. package/dist/types/grammars/index.d.ts +3 -0
  68. package/dist/types/grammars/index.d.ts.map +1 -0
  69. package/dist/types/index.d.ts +15 -0
  70. package/dist/types/index.d.ts.map +1 -0
  71. package/dist/types/react/index.d.ts +91 -0
  72. package/dist/types/react/index.d.ts.map +1 -0
  73. package/dist/types/styles.d.ts +29 -0
  74. package/dist/types/styles.d.ts.map +1 -0
  75. package/docs/architecture.md +316 -0
  76. package/docs/completion.md +320 -0
  77. package/docs/grammar.md +823 -0
  78. package/package.json +105 -0
  79. package/scripts/browser-check.mjs +838 -0
  80. package/scripts/build-css.mjs +35 -0
  81. package/scripts/release.mjs +91 -0
  82. package/scripts/verify-package.mjs +253 -0
  83. package/src/core/complete.ts +286 -0
  84. package/src/core/format.ts +71 -0
  85. package/src/core/grammar.ts +40 -0
  86. package/src/core/hover.ts +129 -0
  87. package/src/core/index.ts +98 -0
  88. package/src/core/inspect.ts +198 -0
  89. package/src/core/rank.ts +317 -0
  90. package/src/core/scan.ts +720 -0
  91. package/src/core/segments.ts +185 -0
  92. package/src/core/text.ts +238 -0
  93. package/src/core/types.ts +681 -0
  94. package/src/core/vocabulary.ts +196 -0
  95. package/src/dom/create.ts +31 -0
  96. package/src/dom/editing.ts +213 -0
  97. package/src/dom/editor.ts +1143 -0
  98. package/src/dom/index.ts +46 -0
  99. package/src/dom/mirror.ts +305 -0
  100. package/src/dom/overlay.ts +106 -0
  101. package/src/dom/popup.ts +323 -0
  102. package/src/dom/support.ts +88 -0
  103. package/src/dom/tooltip.ts +112 -0
  104. package/src/grammars/dshFont.ts +1004 -0
  105. package/src/grammars/dshSentry.ts +742 -0
  106. package/src/grammars/index.ts +57 -0
  107. package/src/index.ts +122 -0
  108. package/src/react/index.tsx +248 -0
  109. package/src/styles.ts +529 -0
@@ -0,0 +1,185 @@
1
+ // ─── segments: one pass over the document, for the painter ──────────────────
2
+ //
3
+ // The painted layer has to cover every character exactly once, in order, and it
4
+ // has to do it while three independent things want to say something about those
5
+ // characters: the scope that colours them, the decoration that marks them, and the
6
+ // diagnostic that underlines them.
7
+ //
8
+ // The naive way is three layers, and it does not work. Each would have to position
9
+ // its spans itself, so each would need its own idea of where a range starts, and a
10
+ // span positioned by measuring is a span that drifts as soon as the font, the
11
+ // wrapping, or the padding is even slightly different from what was measured.
12
+ //
13
+ // So they are merged into ONE stream of adjacent segments, and the renderer emits
14
+ // them back to back with no positioning at all: the characters lay out where the
15
+ // characters lay out, and the colours follow. Alignment stops being something the
16
+ // code maintains and becomes something the layout cannot get wrong.
17
+ //
18
+ // This is pure — text and ranges in, segments out — which is why the trickiest
19
+ // part of the painter is testable without a browser.
20
+
21
+ import type { Decoration, Diagnostic, Severity, Token } from './types.js'
22
+
23
+ /** One run of characters that all share the same presentation. */
24
+ export interface PaintSegment {
25
+ from: number
26
+ to: number
27
+ /** The characters themselves, so the painter never re-slices. */
28
+ text: string
29
+ /** The scope that colours them. */
30
+ scope: string
31
+ /** The decoration kinds that mark them, in declaration order. */
32
+ decorations: string[]
33
+ /** The loudest diagnostic covering them, when one does. */
34
+ severity: Severity | undefined
35
+ /** A hover title donated by a decoration, when it has one. */
36
+ title: string | undefined
37
+ }
38
+
39
+ /** What a segment builder needs: the three range lists and nothing else. */
40
+ export interface SegmentInput {
41
+ tokens: readonly Token[]
42
+ decorations: readonly Decoration[]
43
+ diagnostics: readonly Diagnostic[]
44
+ }
45
+
46
+ /**
47
+ * The order severities outrank each other.
48
+ *
49
+ * One segment shows one squiggle, because two underlines on the same characters
50
+ * only make a messier line rather than a more informative one. The loudest wins,
51
+ * which is also the one a reader needs to see first.
52
+ */
53
+ const SEVERITY_RANK: Record<Severity, number> = { error: 4, warning: 3, info: 2, hint: 1 }
54
+
55
+ /**
56
+ * Merge a scope, a decoration, and a diagnostic into adjacent segments.
57
+ *
58
+ * @param text - the document the ranges refer to.
59
+ * @param input - the three range lists.
60
+ * @param fallbackScope - the scope for characters no token covers.
61
+ * @returns the segments, in order, covering the whole document.
62
+ */
63
+ export function buildSegments(
64
+ text: string,
65
+ input: SegmentInput,
66
+ fallbackScope = 'text',
67
+ ): PaintSegment[] {
68
+ const length = text.length
69
+ if (length === 0) return []
70
+
71
+ // ── the cut points ────────────────────────────────────────────────────────
72
+ // Every range boundary is a potential change of presentation, so they are the
73
+ // only places a segment can begin or end.
74
+ const cuts = new Set<number>([0, length])
75
+ for (const token of input.tokens) {
76
+ cuts.add(Math.max(0, Math.min(token.from, length)))
77
+ cuts.add(Math.max(0, Math.min(token.to, length)))
78
+ }
79
+ for (const decoration of input.decorations) {
80
+ cuts.add(Math.max(0, Math.min(decoration.from, length)))
81
+ cuts.add(Math.max(0, Math.min(decoration.to, length)))
82
+ }
83
+ for (const diagnostic of input.diagnostics) {
84
+ cuts.add(Math.max(0, Math.min(diagnostic.from, length)))
85
+ cuts.add(Math.max(0, Math.min(diagnostic.to, length)))
86
+ }
87
+ const bounds = [...cuts].sort((left, right) => left - right)
88
+
89
+ const segments: PaintSegment[] = []
90
+ for (let index = 0; index < bounds.length - 1; index += 1) {
91
+ const from = bounds[index] ?? 0
92
+ const to = bounds[index + 1] ?? 0
93
+ if (to <= from) continue
94
+
95
+ const scope = coverScope(input.tokens, from, fallbackScope)
96
+ const decorations = coverDecorations(input.decorations, from, to)
97
+ const severity = coverSeverity(input.diagnostics, from, to)
98
+ const title = input.decorations.find(
99
+ (decoration) => decoration.title !== undefined && from >= decoration.from && to <= decoration.to,
100
+ )?.title
101
+
102
+ const last = segments[segments.length - 1]
103
+ if (
104
+ last !== undefined &&
105
+ last.scope === scope &&
106
+ last.severity === severity &&
107
+ last.title === title &&
108
+ sameList(last.decorations, decorations)
109
+ ) {
110
+ // Extending the previous segment keeps the DOM small without changing what
111
+ // is drawn, which is the only reason the merge exists.
112
+ last.to = to
113
+ last.text = text.slice(last.from, to)
114
+ continue
115
+ }
116
+ segments.push({ from, to, text: text.slice(from, to), scope, decorations, severity, title })
117
+ }
118
+ return segments
119
+ }
120
+
121
+ /** The scope of the token covering a position, or the fallback. */
122
+ function coverScope(tokens: readonly Token[], offset: number, fallback: string): string {
123
+ for (const token of tokens) {
124
+ if (token.from > offset) break
125
+ if (offset >= token.from && offset < token.to) return token.scope
126
+ }
127
+ return fallback
128
+ }
129
+
130
+ /** The decoration kinds covering a range, in the order they were declared. */
131
+ function coverDecorations(decorations: readonly Decoration[], from: number, to: number): string[] {
132
+ const kinds: string[] = []
133
+ for (const decoration of decorations) {
134
+ if (decoration.from <= from && to <= decoration.to) kinds.push(decoration.kind)
135
+ }
136
+ return kinds
137
+ }
138
+
139
+ /** The loudest diagnostic severity covering a range. */
140
+ function coverSeverity(
141
+ diagnostics: readonly Diagnostic[],
142
+ from: number,
143
+ to: number,
144
+ ): Severity | undefined {
145
+ let loudest: Severity | undefined
146
+ for (const diagnostic of diagnostics) {
147
+ if (diagnostic.from > from || diagnostic.to < to) continue
148
+ if (loudest === undefined || SEVERITY_RANK[diagnostic.severity] > SEVERITY_RANK[loudest]) {
149
+ loudest = diagnostic.severity
150
+ }
151
+ }
152
+ return loudest
153
+ }
154
+
155
+ /** Whether two short lists say the same thing, in the same order. */
156
+ function sameList(left: readonly string[], right: readonly string[]): boolean {
157
+ if (left.length !== right.length) return false
158
+ for (let index = 0; index < left.length; index += 1) {
159
+ if (left[index] !== right[index]) return false
160
+ }
161
+ return true
162
+ }
163
+
164
+ /**
165
+ * The class attribute for a segment.
166
+ *
167
+ * Order matters for a stylesheet author's sanity rather than for the cascade: the
168
+ * scope comes first so a rule can target `scope + mark` if it ever needs to.
169
+ * @param segment - the segment.
170
+ * @param scopeClass - how a scope becomes a class.
171
+ * @param decorationClass - how a decoration kind becomes a class.
172
+ * @param severityClass - how a severity becomes a class.
173
+ * @returns the class list.
174
+ */
175
+ export function segmentClasses(
176
+ segment: PaintSegment,
177
+ scopeClass: (scope: string) => string,
178
+ decorationClass: (kind: string) => string,
179
+ severityClass: (severity: string) => string,
180
+ ): string[] {
181
+ const classes = [scopeClass(segment.scope)]
182
+ for (const kind of segment.decorations) classes.push(decorationClass(kind))
183
+ if (segment.severity !== undefined) classes.push(severityClass(segment.severity))
184
+ return classes
185
+ }
@@ -0,0 +1,238 @@
1
+ // ─── text: offsets, lines, and words ────────────────────────────────────────
2
+ //
3
+ // The engine counts in character offsets and nothing else. Not rows, not
4
+ // columns, not pixels: every position that crosses a module boundary in litearea
5
+ // is an offset into the document string, so there is exactly one coordinate
6
+ // system to get wrong instead of three that must be kept in step.
7
+ //
8
+ // Pixels enter only in `src/dom/`, only to place the popup, and never travel
9
+ // back inward. That boundary is the fix for a whole family of bugs in the
10
+ // editors this library replaces, where a caret was tracked as a character index
11
+ // in one place and read back as a screen position in another.
12
+
13
+ import type { LineInfo, Range, Token, WordInfo } from './types.js'
14
+
15
+ /** Keeps a number inside a range, which every caller here needs and none should repeat. */
16
+ export function clamp(value: number, low: number, high: number): number {
17
+ return value < low ? low : value > high ? high : value
18
+ }
19
+
20
+ /** Whether a value is a usable offset: a finite, non-negative integer. */
21
+ export function isOffset(value: unknown): value is number {
22
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0
23
+ }
24
+
25
+ /**
26
+ * The offset each line starts at, in order. The first entry is always `0`.
27
+ *
28
+ * `\r\n`, `\n`, and a lone `\r` all end a line, because this text is typed by
29
+ * hand into a textarea and a paste from a Windows editor must not read as one
30
+ * long line. The terminator itself belongs to no line: a line's text excludes it
31
+ * so that column arithmetic never has to reason about a stray carriage return.
32
+ * @param source - the document.
33
+ * @returns one offset per line, ascending.
34
+ */
35
+ export function lineStarts(source: string): number[] {
36
+ const starts = [0]
37
+ for (let index = 0; index < source.length; index += 1) {
38
+ const code = source.charCodeAt(index)
39
+ if (code === 10) {
40
+ starts.push(index + 1)
41
+ } else if (code === 13) {
42
+ if (source.charCodeAt(index + 1) === 10) index += 1
43
+ starts.push(index + 1)
44
+ }
45
+ }
46
+ return starts
47
+ }
48
+
49
+ /**
50
+ * The index of the line an offset falls on.
51
+ * @param starts - the result of {@link lineStarts}.
52
+ * @param offset - a character offset.
53
+ * @returns a zero-based line index.
54
+ */
55
+ export function lineIndexAt(starts: readonly number[], offset: number): number {
56
+ let low = 0
57
+ let high = starts.length - 1
58
+ while (low < high) {
59
+ const middle = Math.ceil((low + high) / 2)
60
+ if ((starts[middle] ?? 0) <= offset) low = middle
61
+ else high = middle - 1
62
+ }
63
+ return low
64
+ }
65
+
66
+ /**
67
+ * The line an offset falls on, with the offset expressed inside it.
68
+ *
69
+ * `before` and `after` are the line's own text split at the caret, which is what
70
+ * completion and the indentation rules actually want; handing them out here
71
+ * means neither has to re-derive the split and get it subtly different.
72
+ * @param source - the document.
73
+ * @param offset - a character offset; clamped into the document.
74
+ * @param starts - the result of {@link lineStarts}, when the caller already has it.
75
+ * @returns the line, its bounds, and the caret's place in it.
76
+ */
77
+ export function lineAt(source: string, offset: number, starts?: readonly number[]): LineInfo {
78
+ const position = clamp(offset, 0, source.length)
79
+ const boundaries = starts ?? lineStarts(source)
80
+ const index = lineIndexAt(boundaries, position)
81
+ const from = boundaries[index] ?? 0
82
+ const rawTo = boundaries[index + 1] ?? source.length
83
+ // The next line's start sits after the terminator, so the last character of
84
+ // the raw slice is the terminator itself — one character, or two for `\r\n`.
85
+ let to = rawTo
86
+ while (to > from) {
87
+ const code = source.charCodeAt(to - 1)
88
+ if (code === 10 || code === 13) to -= 1
89
+ else break
90
+ }
91
+ const text = source.slice(from, to)
92
+ const column = clamp(position - from, 0, text.length)
93
+ return {
94
+ from,
95
+ to,
96
+ text,
97
+ number: index,
98
+ column,
99
+ before: text.slice(0, column),
100
+ after: text.slice(column),
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Whether a character is part of a word.
106
+ *
107
+ * The predicate is supplied by the grammar because it decides three things at
108
+ * once — what a completion replaces, what a diagnostic underlines, and where a
109
+ * double click puts the selection — and a language whose names contain a hyphen
110
+ * must say so or every completion will replace one segment of a name instead of
111
+ * the name.
112
+ * @param char - one character, or the empty string at the end of the document.
113
+ * @param wordChars - a single-character test.
114
+ * @returns whether the character continues a word.
115
+ */
116
+ export function isWordChar(char: string, wordChars: RegExp): boolean {
117
+ return char !== '' && wordChars.test(char)
118
+ }
119
+
120
+ /**
121
+ * The word at an offset.
122
+ *
123
+ * The word is found by expanding in BOTH directions from the offset rather than
124
+ * by requiring the offset to be strictly inside a word. Completion needs the
125
+ * second reading: a caret sitting at `Geist|` is at the end of the word, and the
126
+ * word it is completing is still `Geist` — a lookup that insisted the offset be
127
+ * interior would find nothing at exactly the moment the user wants a list.
128
+ * @param source - the document.
129
+ * @param offset - a character offset; clamped into the document.
130
+ * @param wordChars - a single-character test.
131
+ * @returns the word, its range, and the text either side of the caret.
132
+ */
133
+ export function wordInfoAt(source: string, offset: number, wordChars: RegExp): WordInfo {
134
+ const position = clamp(offset, 0, source.length)
135
+ let from = position
136
+ let to = position
137
+ while (from > 0 && isWordChar(source.charAt(from - 1), wordChars)) from -= 1
138
+ while (to < source.length && isWordChar(source.charAt(to), wordChars)) to += 1
139
+ const text = source.slice(from, to)
140
+ const column = position - from
141
+ return {
142
+ from,
143
+ to,
144
+ text,
145
+ prefix: text.slice(0, column),
146
+ suffix: text.slice(column),
147
+ }
148
+ }
149
+
150
+ /** Whether a range is empty. */
151
+ export function isEmptyRange(range: Range): boolean {
152
+ return range.to <= range.from
153
+ }
154
+
155
+ /** Whether an offset lies inside a range, with `to` exclusive. */
156
+ export function containsOffset(range: Range, offset: number): boolean {
157
+ return offset >= range.from && offset < range.to
158
+ }
159
+
160
+ // ─── reading the token stream ───────────────────────────────────────────────
161
+
162
+ /**
163
+ * The token covering an offset.
164
+ *
165
+ * Zero-length tokens are skipped, so a caret at a boundary resolves to the token
166
+ * it is *inside* rather than to an empty marker sitting on the seam.
167
+ * @param tokens - the scanned tokens, ascending.
168
+ * @param offset - a character offset.
169
+ * @returns the covering token, or undefined in whitespace.
170
+ */
171
+ export function tokenAt(tokens: readonly Token[], offset: number): Token | undefined {
172
+ for (const token of tokens) {
173
+ if (token.from <= offset && offset < token.to) return token
174
+ if (token.from > offset) break
175
+ }
176
+ return undefined
177
+ }
178
+
179
+ /**
180
+ * The scope painted at an offset, when there is one.
181
+ * @param tokens - the scanned tokens, ascending.
182
+ * @param offset - a character offset.
183
+ * @returns the scope, or undefined in whitespace.
184
+ */
185
+ export function scopeAt(tokens: readonly Token[], offset: number): string | undefined {
186
+ return tokenAt(tokens, offset)?.scope
187
+ }
188
+
189
+ /**
190
+ * The nearest token that ends at or before an offset, skipping whitespace.
191
+ *
192
+ * Whitespace is skipped because every caller is asking a question about
193
+ * structure — "which slot is this word in?" — and a space is never an answer.
194
+ * @param tokens - the scanned tokens, ascending.
195
+ * @param offset - a character offset.
196
+ * @returns the token, or undefined at the start of the document.
197
+ */
198
+ export function tokenBefore(tokens: readonly Token[], offset: number): Token | undefined {
199
+ let found
200
+ for (const token of tokens) {
201
+ if (token.to > offset) break
202
+ if (token.text.trim() !== '') found = token
203
+ }
204
+ return found
205
+ }
206
+
207
+ /**
208
+ * The nearest token that starts at or after an offset, skipping whitespace.
209
+ * @param tokens - the scanned tokens, ascending.
210
+ * @param offset - a character offset.
211
+ * @returns the token, or undefined at the end of the document.
212
+ */
213
+ export function tokenAfter(tokens: readonly Token[], offset: number): Token | undefined {
214
+ for (const token of tokens) {
215
+ if (token.to <= offset) continue
216
+ if (token.text.trim() !== '') return token
217
+ }
218
+ return undefined
219
+ }
220
+
221
+ /**
222
+ * The non-whitespace tokens on one line, in order.
223
+ *
224
+ * Completion and diagnostics both ask "what is on this line so far", and both
225
+ * must ignore the indentation and the gaps between words. Returning the tokens
226
+ * rather than the words means a grammar can ask what they *were* (their scopes)
227
+ * and not only what they said.
228
+ * @param tokens - the scanned tokens, ascending.
229
+ * @param line - a line number, or a line record.
230
+ * @returns the tokens on that line.
231
+ */
232
+ export function tokensOnLine(
233
+ tokens: readonly Token[],
234
+ line: number | LineInfo,
235
+ ): Token[] {
236
+ const number = typeof line === 'number' ? line : line.number
237
+ return tokens.filter((token) => token.line === number && token.text.trim() !== '')
238
+ }