@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.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/README.zh.md +362 -0
- package/dist/grammars.cjs +1228 -0
- package/dist/grammars.cjs.map +1 -0
- package/dist/grammars.js +1213 -0
- package/dist/grammars.js.map +1 -0
- package/dist/index.cjs +3103 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +3040 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +3032 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.js +3010 -0
- package/dist/react.js.map +1 -0
- package/dist/styles.cjs +453 -0
- package/dist/styles.cjs.map +1 -0
- package/dist/styles.css +432 -0
- package/dist/styles.js +447 -0
- package/dist/styles.js.map +1 -0
- package/dist/types/core/complete.d.ts +70 -0
- package/dist/types/core/complete.d.ts.map +1 -0
- package/dist/types/core/format.d.ts +34 -0
- package/dist/types/core/format.d.ts.map +1 -0
- package/dist/types/core/grammar.d.ts +14 -0
- package/dist/types/core/grammar.d.ts.map +1 -0
- package/dist/types/core/hover.d.ts +23 -0
- package/dist/types/core/hover.d.ts.map +1 -0
- package/dist/types/core/index.d.ts +12 -0
- package/dist/types/core/index.d.ts.map +1 -0
- package/dist/types/core/inspect.d.ts +34 -0
- package/dist/types/core/inspect.d.ts.map +1 -0
- package/dist/types/core/rank.d.ts +82 -0
- package/dist/types/core/rank.d.ts.map +1 -0
- package/dist/types/core/scan.d.ts +51 -0
- package/dist/types/core/scan.d.ts.map +1 -0
- package/dist/types/core/segments.d.ts +44 -0
- package/dist/types/core/segments.d.ts.map +1 -0
- package/dist/types/core/text.d.ts +113 -0
- package/dist/types/core/text.d.ts.map +1 -0
- package/dist/types/core/types.d.ts +604 -0
- package/dist/types/core/types.d.ts.map +1 -0
- package/dist/types/core/vocabulary.d.ts +82 -0
- package/dist/types/core/vocabulary.d.ts.map +1 -0
- package/dist/types/dom/create.d.ts +17 -0
- package/dist/types/dom/create.d.ts.map +1 -0
- package/dist/types/dom/editing.d.ts +89 -0
- package/dist/types/dom/editing.d.ts.map +1 -0
- package/dist/types/dom/editor.d.ts +366 -0
- package/dist/types/dom/editor.d.ts.map +1 -0
- package/dist/types/dom/index.d.ts +9 -0
- package/dist/types/dom/index.d.ts.map +1 -0
- package/dist/types/dom/mirror.d.ts +107 -0
- package/dist/types/dom/mirror.d.ts.map +1 -0
- package/dist/types/dom/overlay.d.ts +52 -0
- package/dist/types/dom/overlay.d.ts.map +1 -0
- package/dist/types/dom/popup.d.ts +95 -0
- package/dist/types/dom/popup.d.ts.map +1 -0
- package/dist/types/dom/support.d.ts +41 -0
- package/dist/types/dom/support.d.ts.map +1 -0
- package/dist/types/dom/tooltip.d.ts +39 -0
- package/dist/types/dom/tooltip.d.ts.map +1 -0
- package/dist/types/grammars/dshFont.d.ts +127 -0
- package/dist/types/grammars/dshFont.d.ts.map +1 -0
- package/dist/types/grammars/dshSentry.d.ts +84 -0
- package/dist/types/grammars/dshSentry.d.ts.map +1 -0
- package/dist/types/grammars/index.d.ts +3 -0
- package/dist/types/grammars/index.d.ts.map +1 -0
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/react/index.d.ts +91 -0
- package/dist/types/react/index.d.ts.map +1 -0
- package/dist/types/styles.d.ts +29 -0
- package/dist/types/styles.d.ts.map +1 -0
- package/docs/architecture.md +316 -0
- package/docs/completion.md +320 -0
- package/docs/grammar.md +823 -0
- package/package.json +105 -0
- package/scripts/browser-check.mjs +838 -0
- package/scripts/build-css.mjs +35 -0
- package/scripts/release.mjs +91 -0
- package/scripts/verify-package.mjs +253 -0
- package/src/core/complete.ts +286 -0
- package/src/core/format.ts +71 -0
- package/src/core/grammar.ts +40 -0
- package/src/core/hover.ts +129 -0
- package/src/core/index.ts +98 -0
- package/src/core/inspect.ts +198 -0
- package/src/core/rank.ts +317 -0
- package/src/core/scan.ts +720 -0
- package/src/core/segments.ts +185 -0
- package/src/core/text.ts +238 -0
- package/src/core/types.ts +681 -0
- package/src/core/vocabulary.ts +196 -0
- package/src/dom/create.ts +31 -0
- package/src/dom/editing.ts +213 -0
- package/src/dom/editor.ts +1143 -0
- package/src/dom/index.ts +46 -0
- package/src/dom/mirror.ts +305 -0
- package/src/dom/overlay.ts +106 -0
- package/src/dom/popup.ts +323 -0
- package/src/dom/support.ts +88 -0
- package/src/dom/tooltip.ts +112 -0
- package/src/grammars/dshFont.ts +1004 -0
- package/src/grammars/dshSentry.ts +742 -0
- package/src/grammars/index.ts +57 -0
- package/src/index.ts +122 -0
- package/src/react/index.tsx +248 -0
- package/src/styles.ts +529 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write the stylesheet out as a plain CSS file.
|
|
3
|
+
*
|
|
4
|
+
* The CSS is authored once, as a string in `src/styles.ts`, so that an editor can
|
|
5
|
+
* inject it with no CSS loader and no build step. A host that would rather link a
|
|
6
|
+
* file needs it as a file, and the alternative — a second copy maintained by hand —
|
|
7
|
+
* is a stylesheet that drifts from the one that is actually used. So the built ESM
|
|
8
|
+
* module is imported and its string is written here.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/build-css.mjs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
17
|
+
|
|
18
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
19
|
+
const built = join(root, 'dist', 'styles.js')
|
|
20
|
+
|
|
21
|
+
if (!existsSync(built)) {
|
|
22
|
+
console.error('build-css: dist/styles.js is missing; run `npm run build` first')
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { LITEAREA_STYLES } = await import(pathToFileURL(built).href)
|
|
27
|
+
|
|
28
|
+
if (typeof LITEAREA_STYLES !== 'string' || LITEAREA_STYLES.trim() === '') {
|
|
29
|
+
console.error('build-css: dist/styles.js did not export a stylesheet')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const target = join(root, 'dist', 'styles.css')
|
|
34
|
+
writeFileSync(target, `${LITEAREA_STYLES.trimEnd()}\n`, 'utf8')
|
|
35
|
+
console.log(`build-css: wrote ${String(LITEAREA_STYLES.length)} characters to dist/styles.css`)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maintainer-side release helper.
|
|
3
|
+
*
|
|
4
|
+
* Publishing is split in two on purpose: CI stages a tarball (see
|
|
5
|
+
* `.github/workflows/stage.yml`), and a human approves it here, where npm
|
|
6
|
+
* demands that human's 2FA. A compromised runner or a malicious commit can
|
|
7
|
+
* therefore stage but never publish.
|
|
8
|
+
*
|
|
9
|
+
* `npm stage` needs npm >= 11.6 (12 for the current subcommands) and Node 22.22
|
|
10
|
+
* / 24.15 / 26+, which the local Node may not satisfy. So rather than requiring
|
|
11
|
+
* a global npm upgrade, this delegates to `npx npm@12`, and borrows the system
|
|
12
|
+
* `git` for the OTP flow npm uses for a 2FA-gated web approval.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scripts/release.mjs list [package]
|
|
16
|
+
* node scripts/release.mjs view <stage-id>
|
|
17
|
+
* node scripts/release.mjs approve <stage-id>
|
|
18
|
+
* node scripts/release.mjs reject <stage-id>
|
|
19
|
+
* node scripts/release.mjs download <stage-id> [--dry-run]
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from 'node:child_process'
|
|
23
|
+
import { readFileSync } from 'node:fs'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
|
|
27
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
28
|
+
const [, , subcommand, ...rest] = process.argv
|
|
29
|
+
|
|
30
|
+
const PACKAGE_NAME = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name
|
|
31
|
+
const VERSION = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version
|
|
32
|
+
|
|
33
|
+
/** npm CLI major to run; `npm stage` needs 12 for `list`/`approve`. */
|
|
34
|
+
const NPM_SPEC = 'npm@12'
|
|
35
|
+
|
|
36
|
+
const USAGE = `usage: node scripts/release.mjs <command> [args]
|
|
37
|
+
|
|
38
|
+
list [package] staged versions (default: ${PACKAGE_NAME})
|
|
39
|
+
view <stage-id> details of one staged version
|
|
40
|
+
approve <stage-id> publish it — REQUIRES your 2FA
|
|
41
|
+
reject <stage-id> discard it
|
|
42
|
+
download <stage-id> fetch the staged tarball for inspection
|
|
43
|
+
|
|
44
|
+
Nothing here publishes without an approval, which is the point: CI can only
|
|
45
|
+
stage.`
|
|
46
|
+
|
|
47
|
+
const KNOWN = new Set(['list', 'view', 'approve', 'reject', 'download'])
|
|
48
|
+
|
|
49
|
+
if (subcommand === undefined || subcommand === '--help' || subcommand === '-h') {
|
|
50
|
+
console.log(USAGE)
|
|
51
|
+
process.exit(0)
|
|
52
|
+
}
|
|
53
|
+
if (!KNOWN.has(subcommand)) {
|
|
54
|
+
console.error(`release: unknown command "${subcommand}"\n\n${USAGE}`)
|
|
55
|
+
process.exit(2)
|
|
56
|
+
}
|
|
57
|
+
if (subcommand !== 'list' && rest.length === 0) {
|
|
58
|
+
console.error(`release: "${subcommand}" needs a stage-id\n\n${USAGE}`)
|
|
59
|
+
process.exit(2)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const args = [NPM_SPEC, 'stage', subcommand]
|
|
63
|
+
if (subcommand === 'list') args.push(rest[0] ?? PACKAGE_NAME)
|
|
64
|
+
else args.push(...rest)
|
|
65
|
+
|
|
66
|
+
console.log(`release: npx ${args.join(' ')}`)
|
|
67
|
+
if (subcommand === 'approve') {
|
|
68
|
+
console.log('release: approving publishes to the registry and requires your 2FA')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = spawnSync('npx', ['--yes', ...args], {
|
|
72
|
+
cwd: root,
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
shell: process.platform === 'win32',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
if (result.error !== undefined) {
|
|
78
|
+
if (result.error.code === 'ENOENT') {
|
|
79
|
+
console.error('release: npx not found on PATH — install Node.js')
|
|
80
|
+
process.exit(127)
|
|
81
|
+
}
|
|
82
|
+
throw result.error
|
|
83
|
+
}
|
|
84
|
+
const exitCode = result.status ?? 1
|
|
85
|
+
|
|
86
|
+
if (exitCode === 0 && subcommand === 'list') {
|
|
87
|
+
console.log(`\nrelease: nothing is installable until a stage is approved.`)
|
|
88
|
+
console.log(`release: a local release runs \`npm run release -- approve <stage-id>\` from v${VERSION}.`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
process.exit(exitCode)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exercise the BUILT package rather than the working tree.
|
|
3
|
+
*
|
|
4
|
+
* `npm run typecheck` and `npm run test` both read `src/`, so neither of them can
|
|
5
|
+
* notice a build that silently dropped an entry point, produced a declaration file
|
|
6
|
+
* with nothing in it, or failed to write the stylesheet. This is the gate that
|
|
7
|
+
* imports what would actually be installed and asserts it is all there.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node scripts/verify-package.mjs [path/to/dist]
|
|
11
|
+
*
|
|
12
|
+
* The optional path exists so an INSTALLED copy can be checked, which is the only
|
|
13
|
+
* way to be sure the tarball is complete rather than the checkout.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from 'node:assert/strict'
|
|
17
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
22
|
+
const dist = process.argv[2] ?? join(root, 'dist')
|
|
23
|
+
const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every entry point `package.json` promises, and where its declarations landed.
|
|
27
|
+
*
|
|
28
|
+
* The declarations come from `tsc` rather than from the JavaScript bundler, so they
|
|
29
|
+
* mirror the source tree: the root entry is one file, while `react` and `grammars`
|
|
30
|
+
* are directories with an `index.d.ts` in them. That extra level is the price of not
|
|
31
|
+
* depending on a declaration bundler that predates TypeScript 7, and it is encoded
|
|
32
|
+
* here so a change to the layout cannot pass unnoticed.
|
|
33
|
+
*/
|
|
34
|
+
const ENTRIES = [
|
|
35
|
+
{ name: 'index', declarations: 'types/index.d.ts' },
|
|
36
|
+
{ name: 'react', declarations: 'types/react/index.d.ts' },
|
|
37
|
+
{ name: 'grammars', declarations: 'types/grammars/index.d.ts' },
|
|
38
|
+
{ name: 'styles', declarations: 'types/styles.d.ts' },
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** The names the root entry must export. */
|
|
42
|
+
const CORE_EXPORTS = [
|
|
43
|
+
'defineGrammar',
|
|
44
|
+
'defineVocabulary',
|
|
45
|
+
'defineCompletion',
|
|
46
|
+
'resolveGrammar',
|
|
47
|
+
'isResolvedGrammar',
|
|
48
|
+
'scan',
|
|
49
|
+
'inspect',
|
|
50
|
+
'complete',
|
|
51
|
+
'applyCompletion',
|
|
52
|
+
'resolveHover',
|
|
53
|
+
'diagnosticHover',
|
|
54
|
+
'buildSegments',
|
|
55
|
+
'fuzzyMatch',
|
|
56
|
+
'rank',
|
|
57
|
+
'highlightSegments',
|
|
58
|
+
'lineAt',
|
|
59
|
+
'lineStarts',
|
|
60
|
+
'wordInfoAt',
|
|
61
|
+
'clamp',
|
|
62
|
+
'LiteArea',
|
|
63
|
+
'createEditor',
|
|
64
|
+
'TextMirror',
|
|
65
|
+
'injectStyles',
|
|
66
|
+
'scopeClass',
|
|
67
|
+
'LITEAREA_STYLES',
|
|
68
|
+
'readSelection',
|
|
69
|
+
'writeSelection',
|
|
70
|
+
'replaceThroughPipeline',
|
|
71
|
+
'undoField',
|
|
72
|
+
'redoField',
|
|
73
|
+
'writeDocument',
|
|
74
|
+
'canEditThroughPipeline',
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
/** The names the grammars entry must export. */
|
|
78
|
+
const GRAMMAR_EXPORTS = [
|
|
79
|
+
'dshFontQueryGrammar',
|
|
80
|
+
'dshSentryStyleGrammar',
|
|
81
|
+
'fontWeightWord',
|
|
82
|
+
'fontFaceWeights',
|
|
83
|
+
'quoteFontFamily',
|
|
84
|
+
'FONT_WEIGHT_WORDS',
|
|
85
|
+
'FONT_GENERIC_FAMILIES',
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
/** The names the React entry must export. */
|
|
89
|
+
const REACT_EXPORTS = ['LiteAreaEditor']
|
|
90
|
+
|
|
91
|
+
/** Report a failure the way the rest of the house scripts do. */
|
|
92
|
+
function fail(message) {
|
|
93
|
+
console.error(`verify-package: ${message}`)
|
|
94
|
+
process.exit(1)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── every file the package promises is on disk ──────────────────────────────
|
|
98
|
+
|
|
99
|
+
for (const entry of ENTRIES) {
|
|
100
|
+
for (const extension of ['js', 'cjs']) {
|
|
101
|
+
const file = join(dist, `${entry.name}.${extension}`)
|
|
102
|
+
if (!existsSync(file)) fail(`${entry.name}.${extension} is missing — run \`npm run build\``)
|
|
103
|
+
if (statSync(file).size === 0) fail(`${entry.name}.${extension} is empty`)
|
|
104
|
+
}
|
|
105
|
+
const declarations = join(dist, entry.declarations)
|
|
106
|
+
if (!existsSync(declarations)) {
|
|
107
|
+
fail(`${entry.declarations} is missing — run \`npm run build\``)
|
|
108
|
+
}
|
|
109
|
+
if (statSync(declarations).size === 0) fail(`${entry.declarations} is empty`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const css = join(dist, 'styles.css')
|
|
113
|
+
if (!existsSync(css)) fail('styles.css is missing — run `npm run build`')
|
|
114
|
+
const cssText = readFileSync(css, 'utf8')
|
|
115
|
+
/** The stylesheet without its comments, since the prose quotes selectors. */
|
|
116
|
+
const cssRules = cssText.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
117
|
+
// The two classes that make the whole arrangement work. A stylesheet without them
|
|
118
|
+
// would ship, install cleanly, and paint nothing.
|
|
119
|
+
for (const selector of ['.litearea-input', '.litearea-layer', '.litearea-popup']) {
|
|
120
|
+
if (!cssText.includes(selector)) fail(`styles.css does not define ${selector}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── every scope the stylesheet names is actually painted ───────────────────
|
|
124
|
+
//
|
|
125
|
+
// The variables and the rules that spend them must agree, and they once did not: twenty-nine
|
|
126
|
+
// scope variables were declared with no rule that used one, so the tokenizer painted the right
|
|
127
|
+
// classes and every scope rendered in the inherited text colour. The editor had no syntax
|
|
128
|
+
// colouring at all, the stylesheet looked complete, and the DOM was correct — which is also why
|
|
129
|
+
// a browser check cannot catch it, because a colourless token is an ordinary thing to find.
|
|
130
|
+
const declaredScopes = new Set(
|
|
131
|
+
[...cssRules.matchAll(/--litearea-scope-([a-z-]+)\s*:/g)].map((match) => match[1]),
|
|
132
|
+
)
|
|
133
|
+
const paintedScopes = new Set(
|
|
134
|
+
[...cssRules.matchAll(/\.litearea-scope-([a-z-]+)\s*\{/g)].map((match) => match[1]),
|
|
135
|
+
)
|
|
136
|
+
if (paintedScopes.size < 15) {
|
|
137
|
+
fail(`styles.css has only ${String(paintedScopes.size)} scope colour rules; the palette is not wired up`)
|
|
138
|
+
}
|
|
139
|
+
const unpainted = [...declaredScopes].filter((scope) => !paintedScopes.has(scope))
|
|
140
|
+
if (unpainted.length > 0) {
|
|
141
|
+
fail(
|
|
142
|
+
`styles.css declares --litearea-scope-* for ${unpainted.join(', ')} with no rule that uses ` +
|
|
143
|
+
'them, so those scopes would paint in the inherited colour',
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
for (const scope of paintedScopes) {
|
|
147
|
+
if (!cssRules.includes(`.litearea-scope-${scope} { color: var(--litearea-scope-${scope}); }`)) {
|
|
148
|
+
fail(`.litearea-scope-${scope} does not spend its own variable`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A squiggle is only a squiggle if it is drawn by the browser, and only a stylesheet says how.
|
|
152
|
+
for (const severity of ['error', 'warning']) {
|
|
153
|
+
if (!new RegExp(`\\.litearea-diag-${severity} \\{[^}]*text-decoration-style: wavy`).test(cssRules)) {
|
|
154
|
+
fail(`.litearea-diag-${severity} does not draw a wavy underline`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (cssRules.includes('.litearea-paint > span')) {
|
|
158
|
+
fail('.litearea-paint > span would outrank the severity classes and switch every squiggle off')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── the declarations describe the exports ──────────────────────────────────
|
|
162
|
+
|
|
163
|
+
const declarations = readFileSync(join(dist, 'types', 'index.d.ts'), 'utf8')
|
|
164
|
+
if (declarations.length < 500) {
|
|
165
|
+
fail(`types/index.d.ts looks truncated (${String(declarations.length)} bytes)`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── the built modules really export what the declarations claim ────────────
|
|
169
|
+
|
|
170
|
+
const core = await import(pathToFileURL(join(dist, 'index.js')).href)
|
|
171
|
+
const missing = CORE_EXPORTS.filter((name) => core[name] === undefined)
|
|
172
|
+
if (missing.length > 0) fail(`index.js does not export: ${missing.join(', ')}`)
|
|
173
|
+
|
|
174
|
+
const grammars = await import(pathToFileURL(join(dist, 'grammars.js')).href)
|
|
175
|
+
const missingGrammars = GRAMMAR_EXPORTS.filter((name) => grammars[name] === undefined)
|
|
176
|
+
if (missingGrammars.length > 0) fail(`grammars.js does not export: ${missingGrammars.join(', ')}`)
|
|
177
|
+
|
|
178
|
+
const react = await import(pathToFileURL(join(dist, 'react.js')).href)
|
|
179
|
+
const missingReact = REACT_EXPORTS.filter((name) => react[name] === undefined)
|
|
180
|
+
if (missingReact.length > 0) fail(`react.js does not export: ${missingReact.join(', ')}`)
|
|
181
|
+
|
|
182
|
+
// ── the engine works from the built artifact, not just from source ─────────
|
|
183
|
+
|
|
184
|
+
const grammar = grammars.dshSentryStyleGrammar()
|
|
185
|
+
const inspection = core.inspect('running circle blue turn 3\nbogus circle', grammar)
|
|
186
|
+
assert.ok(inspection.tokens.length > 0, 'the built engine produced no tokens')
|
|
187
|
+
assert.ok(
|
|
188
|
+
inspection.diagnostics.some((diagnostic) => diagnostic.code === 'vocabulary:state'),
|
|
189
|
+
'the built engine did not report the unknown state',
|
|
190
|
+
)
|
|
191
|
+
const completion = core.complete(inspection, grammar, {
|
|
192
|
+
text: 'running ',
|
|
193
|
+
caret: 'running '.length,
|
|
194
|
+
trigger: 'explicit',
|
|
195
|
+
})
|
|
196
|
+
assert.ok(completion !== undefined, 'the built engine offered no completion')
|
|
197
|
+
assert.ok(completion.rows.length > 0, 'the built engine offered an empty completion')
|
|
198
|
+
|
|
199
|
+
const segments = core.buildSegments(inspection.text, inspection)
|
|
200
|
+
assert.equal(
|
|
201
|
+
segments.map((segment) => segment.text).join(''),
|
|
202
|
+
inspection.text,
|
|
203
|
+
'the built segmenter did not cover the document exactly',
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
// ── the package metadata points at real files ──────────────────────────────
|
|
207
|
+
|
|
208
|
+
for (const [key, value] of Object.entries(packageJson.exports ?? {})) {
|
|
209
|
+
if (typeof value === 'string') {
|
|
210
|
+
if (!existsSync(join(root, value))) fail(`exports["${key}"] points at ${value}, which does not exist`)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
for (const condition of ['types', 'import', 'require']) {
|
|
214
|
+
const target = value[condition]
|
|
215
|
+
if (target === undefined) fail(`exports["${key}"] has no ${condition} condition`)
|
|
216
|
+
if (!existsSync(join(root, target))) {
|
|
217
|
+
fail(`exports["${key}"].${condition} points at ${target}, which does not exist`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── the links a reader will click resolve, and will be published ───────────
|
|
223
|
+
//
|
|
224
|
+
// A README is rendered on the package page by npm, where a relative link only works if the
|
|
225
|
+
// file it points at is in the tarball. `docs/grammar.md` was linked from the README and left
|
|
226
|
+
// out of `files`, which is a link that resolves in a checkout and 404s for everyone who
|
|
227
|
+
// installed the package — the kind of mistake that is invisible until a stranger hits it.
|
|
228
|
+
|
|
229
|
+
const publishedRoots = new Set((packageJson.files ?? []).map((entry) => entry.split('/')[0]))
|
|
230
|
+
|
|
231
|
+
for (const readme of ['README.md', 'README.zh.md']) {
|
|
232
|
+
const file = join(root, readme)
|
|
233
|
+
if (!existsSync(file)) continue
|
|
234
|
+
const text = readFileSync(file, 'utf8')
|
|
235
|
+
for (const match of text.matchAll(/\]\(([^)\s]+)\)/g)) {
|
|
236
|
+
const target = match[1]
|
|
237
|
+
if (target === undefined) continue
|
|
238
|
+
if (/^(https?:|mailto:|#)/.test(target)) continue
|
|
239
|
+
const [path] = target.split('#')
|
|
240
|
+
if (path === undefined || path === '') continue
|
|
241
|
+
if (!existsSync(join(root, path))) {
|
|
242
|
+
fail(`${readme} links to ${path}, which does not exist`)
|
|
243
|
+
}
|
|
244
|
+
const top = path.split('/')[0] ?? ''
|
|
245
|
+
if (top !== readme && !publishedRoots.has(top)) {
|
|
246
|
+
fail(`${readme} links to ${path}, which \`files\` does not publish — the link would 404 on npm`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
console.log(`verify-package: OK — ${String(ENTRIES.length)} entries, ${String(CORE_EXPORTS.length)} core exports`)
|
|
252
|
+
console.log('verify-package: the built engine tokenizes, diagnoses, completes, and segments')
|
|
253
|
+
console.log('verify-package: every relative README link resolves and is published')
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// ─── complete: turning a caret into a list, and a list into an edit ─────────
|
|
2
|
+
//
|
|
3
|
+
// Two pure functions, deliberately separated, because they fail in different
|
|
4
|
+
// ways and only one of them is interesting to a user.
|
|
5
|
+
//
|
|
6
|
+
// `complete` answers "what could go here?" and returns rows. `applyCompletion`
|
|
7
|
+
// answers "what does the document become?" and returns text plus a caret. Keeping
|
|
8
|
+
// them apart is what makes the hard half — the interaction — testable in Node
|
|
9
|
+
// without a browser, and it is why the two bugs that make a completer feel broken
|
|
10
|
+
// have somewhere to live:
|
|
11
|
+
//
|
|
12
|
+
// A SOURCE that swaps mid-typing loses the thread. The list opens over a family
|
|
13
|
+
// name, the user types `=` and it becomes a value list, and the row they were
|
|
14
|
+
// looking at is gone. So once a source has answered, it keeps answering for as long
|
|
15
|
+
// as it stays eligible, and the priority order only decides which source OPENS the
|
|
16
|
+
// list.
|
|
17
|
+
//
|
|
18
|
+
// A range that does not grow leaves text behind. The range is recomputed from the
|
|
19
|
+
// caret on every filter, never reused: type `ru`, accept `running`, and the
|
|
20
|
+
// replacement must cover both letters. Holding the range resolved when the list
|
|
21
|
+
// opened replaces only the `r` and produces `running u` — a bug worth naming,
|
|
22
|
+
// because it is invisible in a test that only ever types one character before
|
|
23
|
+
// accepting.
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
Completion,
|
|
27
|
+
CompletionContext,
|
|
28
|
+
CompletionSource,
|
|
29
|
+
CompletionTrigger,
|
|
30
|
+
Grammar,
|
|
31
|
+
Range,
|
|
32
|
+
SuggestionItem,
|
|
33
|
+
} from './types.js'
|
|
34
|
+
import type { Inspection } from './inspect.js'
|
|
35
|
+
import { rank, type Ranked } from './rank.js'
|
|
36
|
+
import { clamp, lineAt, lineStarts, scopeAt, tokenBefore, tokensOnLine, wordInfoAt } from './text.js'
|
|
37
|
+
import { isResolvedGrammar, resolveGrammar, type ResolvedGrammar } from './scan.js'
|
|
38
|
+
|
|
39
|
+
/** The most rows a completion will carry, so a huge catalogue cannot stall a list. */
|
|
40
|
+
const DEFAULT_LIMIT = 100
|
|
41
|
+
|
|
42
|
+
/** What the editor knows when it asks for a list. */
|
|
43
|
+
export interface CompletionRequest {
|
|
44
|
+
text: string
|
|
45
|
+
caret: number
|
|
46
|
+
trigger: CompletionTrigger
|
|
47
|
+
/**
|
|
48
|
+
* The source that answered last time, when a list is already open.
|
|
49
|
+
*
|
|
50
|
+
* Passing it is what keeps a list stable while the user types: the same source answers
|
|
51
|
+
* again for as long as it stays eligible, so typing a character that makes a different
|
|
52
|
+
* source eligible does not swap the rows out from under the reader. Only the SOURCE is
|
|
53
|
+
* held — the range is recomputed from the caret every time, so it grows with the word.
|
|
54
|
+
*/
|
|
55
|
+
previousSourceId?: string | undefined
|
|
56
|
+
/** The most rows to return. */
|
|
57
|
+
limit?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a completion for a caret.
|
|
62
|
+
*
|
|
63
|
+
* @param inspection - the current inspection of the document.
|
|
64
|
+
* @param grammar - the language.
|
|
65
|
+
* @param request - the caret, the trigger, and any list already open.
|
|
66
|
+
* @returns the range and the rows, or undefined when nothing applies.
|
|
67
|
+
*/
|
|
68
|
+
export function complete<State>(
|
|
69
|
+
inspection: Inspection<State>,
|
|
70
|
+
grammar: Grammar<State> | ResolvedGrammar<State>,
|
|
71
|
+
request: CompletionRequest,
|
|
72
|
+
): Completion | undefined {
|
|
73
|
+
const resolved = isResolvedGrammar(grammar) ? grammar : resolveGrammar(grammar)
|
|
74
|
+
const sources = resolved.grammar.compose
|
|
75
|
+
if (sources === undefined || sources.length === 0) return undefined
|
|
76
|
+
|
|
77
|
+
const text = request.text
|
|
78
|
+
const caret = clamp(request.caret, 0, text.length)
|
|
79
|
+
const context = completionContext(inspection, resolved, caret, request.trigger)
|
|
80
|
+
|
|
81
|
+
const eligible = sources.filter((source) => source.when === undefined || source.when(context))
|
|
82
|
+
if (eligible.length === 0) return undefined
|
|
83
|
+
|
|
84
|
+
// ── which source, and over what range ────────────────────────────────────
|
|
85
|
+
// Ordered by priority, and the range comes from the winner because two sources
|
|
86
|
+
// disagreeing about the range have no coherent shared answer.
|
|
87
|
+
// ── which source ────────────────────────────────────────────────────────
|
|
88
|
+
// Priority decides which source OPENS the list; after that the source that answered
|
|
89
|
+
// keeps answering while it remains eligible, so the rows do not change identity under
|
|
90
|
+
// the reader's hands. The range is not part of this: it is computed fresh below, from
|
|
91
|
+
// the caret as it is now.
|
|
92
|
+
const ordered = [...eligible].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))
|
|
93
|
+
const reopening =
|
|
94
|
+
request.previousSourceId === undefined
|
|
95
|
+
? undefined
|
|
96
|
+
: ordered.find((source) => source.id === request.previousSourceId)
|
|
97
|
+
const winner = reopening ?? ordered[0]
|
|
98
|
+
if (winner === undefined) return undefined
|
|
99
|
+
const range = resolveRange(winner, context)
|
|
100
|
+
|
|
101
|
+
// ── the rows ─────────────────────────────────────────────────────────────
|
|
102
|
+
const merged: SuggestionItem[] = [...winner.items(context)]
|
|
103
|
+
for (const source of ordered) {
|
|
104
|
+
if (source === winner || source.merge !== true) continue
|
|
105
|
+
merged.push(...source.items(context))
|
|
106
|
+
}
|
|
107
|
+
if (merged.length === 0) return undefined
|
|
108
|
+
|
|
109
|
+
// The needle is what the user has typed INSIDE the range, which is the only reading
|
|
110
|
+
// that stays meaningful when the range is an entry rather than a word. It is clamped
|
|
111
|
+
// to the range at both ends: a caret can sit past the range — after the trailing space
|
|
112
|
+
// of an entry, say — and slicing to the caret there would pull a separator into the
|
|
113
|
+
// needle and filter the list by a character the user did not mean to search for.
|
|
114
|
+
const needleFrom = clamp(range.from, 0, caret)
|
|
115
|
+
const needleTo = clamp(caret, needleFrom, Math.max(range.to, needleFrom))
|
|
116
|
+
const needle = text.slice(needleFrom, needleTo)
|
|
117
|
+
const ranked: Ranked<SuggestionItem>[] = rank(merged, needle, {
|
|
118
|
+
label: (item) => item.label,
|
|
119
|
+
...(merged.some((item) => item.filterText !== undefined)
|
|
120
|
+
? { filterText: (item: SuggestionItem) => item.filterText ?? item.label }
|
|
121
|
+
: {}),
|
|
122
|
+
...(merged.some((item) => item.sortText !== undefined)
|
|
123
|
+
? { sortText: (item: SuggestionItem) => item.sortText }
|
|
124
|
+
: {}),
|
|
125
|
+
})
|
|
126
|
+
const limit = request.limit ?? DEFAULT_LIMIT
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
range,
|
|
130
|
+
rows: ranked.slice(0, limit).map((entry) => ({
|
|
131
|
+
item: entry.item,
|
|
132
|
+
score: entry.score,
|
|
133
|
+
indices: entry.indices,
|
|
134
|
+
})),
|
|
135
|
+
needle,
|
|
136
|
+
sourceId: winner.id,
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Resolve a source's range, whether it declared one or computes it. */
|
|
141
|
+
function resolveRange<State>(
|
|
142
|
+
source: CompletionSource<State>,
|
|
143
|
+
context: CompletionContext<State>,
|
|
144
|
+
): Range {
|
|
145
|
+
if (typeof source.range !== 'function') return source.range
|
|
146
|
+
const range = source.range(context)
|
|
147
|
+
return {
|
|
148
|
+
from: Math.min(range.from, range.to),
|
|
149
|
+
to: Math.max(range.from, range.to),
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Build everything a completion source may look at.
|
|
155
|
+
* @param inspection - the current inspection.
|
|
156
|
+
* @param resolved - the resolved grammar.
|
|
157
|
+
* @param caret - the caret offset.
|
|
158
|
+
* @param trigger - how the list came to be open.
|
|
159
|
+
* @returns the context.
|
|
160
|
+
*/
|
|
161
|
+
function completionContext<State>(
|
|
162
|
+
inspection: Inspection<State>,
|
|
163
|
+
resolved: ResolvedGrammar<State>,
|
|
164
|
+
caret: number,
|
|
165
|
+
trigger: CompletionTrigger,
|
|
166
|
+
): CompletionContext<State> {
|
|
167
|
+
const { text, tokens, diagnostics, state } = inspection
|
|
168
|
+
const line = lineAt(text, caret, lineStarts(text))
|
|
169
|
+
const lineTokens = tokensOnLine(tokens, line.number)
|
|
170
|
+
const word = wordInfoAt(text, caret, resolved.wordChars)
|
|
171
|
+
return {
|
|
172
|
+
text,
|
|
173
|
+
caret,
|
|
174
|
+
word,
|
|
175
|
+
line,
|
|
176
|
+
tokens,
|
|
177
|
+
diagnostics,
|
|
178
|
+
state,
|
|
179
|
+
scope: scopeAt(tokens, caret),
|
|
180
|
+
scopeBefore: tokenBefore(tokens, caret)?.scope,
|
|
181
|
+
// "First on line" means only whitespace precedes the caret, which is a question
|
|
182
|
+
// about the caret and not about the token under it: a caret in the indentation of
|
|
183
|
+
// a line that already has content is not first on that line. `lineTokens` is what
|
|
184
|
+
// tells the difference, so the tokens are read even though the answer looks like a
|
|
185
|
+
// string test.
|
|
186
|
+
firstOnLine: line.before.trim() === '' && !lineTokens.some((token) => token.to <= caret),
|
|
187
|
+
// "First word" additionally allows the rest of the word the caret is in, which is
|
|
188
|
+
// what keeps a line-head completion alive while the head is being typed.
|
|
189
|
+
firstWord: line.text.slice(0, Math.max(0, word.from - line.from)).trim() === '',
|
|
190
|
+
firstToken: lineTokens[0],
|
|
191
|
+
trigger,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** An edit produced by accepting a row. */
|
|
196
|
+
export interface AppliedCompletion {
|
|
197
|
+
/** The document after the edit. */
|
|
198
|
+
text: string
|
|
199
|
+
/** Where the caret belongs in it. */
|
|
200
|
+
caret: number
|
|
201
|
+
/**
|
|
202
|
+
* What changed, in the NEW document. The editor uses it to scroll the result
|
|
203
|
+
* into view and to describe the edit to a host.
|
|
204
|
+
*/
|
|
205
|
+
range: Range
|
|
206
|
+
/**
|
|
207
|
+
* The offset the edit starts at in the ORIGINAL document.
|
|
208
|
+
*
|
|
209
|
+
* Exposed alongside `insert` because an editor must write only the range that
|
|
210
|
+
* changed. Assigning the whole recomputed `text` would be simpler and would throw
|
|
211
|
+
* away the browser's undo history, which is the one thing this library exists to
|
|
212
|
+
* protect.
|
|
213
|
+
*/
|
|
214
|
+
from: number
|
|
215
|
+
/** The offset the edit ends at in the original document. */
|
|
216
|
+
to: number
|
|
217
|
+
/** The text written between those two offsets. */
|
|
218
|
+
insert: string
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Apply a chosen row to a document.
|
|
223
|
+
*
|
|
224
|
+
* The row decides three things: what is written, whether it is written over the
|
|
225
|
+
* range or in front of it, and how much text follows it. `append` is the
|
|
226
|
+
* grammar's business and not the engine's — inviting another entry with a `, `
|
|
227
|
+
* is a fact about font stacks, and an engine that appended one by default would
|
|
228
|
+
* be guessing about every other language.
|
|
229
|
+
*
|
|
230
|
+
* @param text - the document.
|
|
231
|
+
* @param range - the range the list was opened over.
|
|
232
|
+
* @param item - the chosen row.
|
|
233
|
+
* @returns the resulting text, caret, and the edit that produced them.
|
|
234
|
+
*/
|
|
235
|
+
export function applyCompletion(text: string, range: Range, item: SuggestionItem): AppliedCompletion {
|
|
236
|
+
const from = clamp(Math.min(range.from, range.to), 0, text.length)
|
|
237
|
+
const to = clamp(Math.max(range.from, range.to), from, text.length)
|
|
238
|
+
const insert = item.insert ?? item.label
|
|
239
|
+
const append = item.append ?? ''
|
|
240
|
+
const offset = item.caretOffset ?? 0
|
|
241
|
+
|
|
242
|
+
if (item.mode === 'before') {
|
|
243
|
+
const head = text.slice(0, from)
|
|
244
|
+
// The whitespace in front of the existing entry belonged to the comma before
|
|
245
|
+
// it, so it is restored rather than doubled.
|
|
246
|
+
const gap = head !== '' && !/\s$/.test(head) ? ' ' : ''
|
|
247
|
+
const rest = text.slice(from).replace(/^\s+/, '')
|
|
248
|
+
const written = `${gap}${insert}${redundant(append, rest) ? '' : append}`
|
|
249
|
+
return {
|
|
250
|
+
text: `${head}${written}${rest}`,
|
|
251
|
+
caret: head.length + written.length + offset,
|
|
252
|
+
range: { from: head.length, to: head.length + written.length },
|
|
253
|
+
from,
|
|
254
|
+
to: from,
|
|
255
|
+
insert: written,
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const head = text.slice(0, from)
|
|
260
|
+
const rest = text.slice(to)
|
|
261
|
+
const written = `${insert}${redundant(append, rest) ? '' : append}`
|
|
262
|
+
return {
|
|
263
|
+
text: `${head}${written}${rest}`,
|
|
264
|
+
caret: head.length + written.length + offset,
|
|
265
|
+
range: { from: head.length, to: head.length + written.length },
|
|
266
|
+
from,
|
|
267
|
+
to,
|
|
268
|
+
insert: written,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Whether an append would only repeat what the document already says.
|
|
274
|
+
*
|
|
275
|
+
* Accepting `running` immediately before an existing space must not produce two,
|
|
276
|
+
* and accepting a font family immediately before an existing `, ` must not add
|
|
277
|
+
* another comma. Whitespace is compared as whitespace rather than as the exact
|
|
278
|
+
* characters, so a tab counts as the space an append was reaching for.
|
|
279
|
+
* @param append - the row's `append` text.
|
|
280
|
+
* @param rest - the document immediately after the range.
|
|
281
|
+
* @returns whether the append should be left out.
|
|
282
|
+
*/
|
|
283
|
+
function redundant(append: string, rest: string): boolean {
|
|
284
|
+
if (append === '') return true
|
|
285
|
+
return append.trim() === '' ? /^\s/.test(rest) : rest.startsWith(append)
|
|
286
|
+
}
|