@markii/stdlib 0.13.0 → 0.14.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/dist/diagnostics.d.ts +45 -0
- package/dist/diagnostics.js +34 -0
- package/dist/editor/complete/completion.d.ts +15 -0
- package/dist/editor/complete/completion.js +322 -0
- package/dist/editor/complete/directive-context.d.ts +79 -0
- package/dist/editor/complete/directive-context.js +326 -0
- package/dist/editor/complete/documentation.d.ts +31 -0
- package/dist/editor/complete/documentation.js +92 -0
- package/dist/editor/complete/index.d.ts +9 -0
- package/dist/editor/complete/index.js +1 -0
- package/dist/editor/complete/types.d.ts +82 -0
- package/dist/editor/complete/types.js +12 -0
- package/dist/editor/fences/container-fences.d.ts +130 -0
- package/dist/editor/fences/container-fences.js +306 -0
- package/dist/editor/index.d.ts +32 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/insert/component-catalog.d.ts +95 -0
- package/dist/editor/insert/component-catalog.js +51 -0
- package/dist/editor/insert/component-skeleton.d.ts +58 -0
- package/dist/editor/insert/component-skeleton.js +85 -0
- package/dist/editor/insert/first-sentence.d.ts +19 -0
- package/dist/editor/insert/first-sentence.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/interactive.d.ts +15 -0
- package/dist/interactive.js +15 -0
- package/package.json +6 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The neutral shape of a render-time diagnostic event (docs/integration.md):
|
|
3
|
+
* something a renderer recognized and handled by rendering a quiet marker
|
|
4
|
+
* instead of silently dropping content or failing outright, reported
|
|
5
|
+
* through a render engine's `onDiagnostic` option so a host can route it to
|
|
6
|
+
* its own diagnostics surface (AGENTS.md's "clean is not silent" rule: a
|
|
7
|
+
* quiet in-page marker alone is not enough, the reason must also reach a
|
|
8
|
+
* surface a host actually shows).
|
|
9
|
+
*
|
|
10
|
+
* Both platform renderers (`@markii/react`, `@markii/html`) emit this exact
|
|
11
|
+
* shape from their own independent implementations, so a host wiring up
|
|
12
|
+
* one engine's callback needs no engine-specific branching if it later
|
|
13
|
+
* adds the other. The WORDING in `message` is produced by each engine's own
|
|
14
|
+
* `failure-presentation` module (the one home of failure wording per
|
|
15
|
+
* engine) — this module only defines the envelope, never the words.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* `'invalid-attribute-value'` — a known attribute (one `@markii/stdlib`'s
|
|
19
|
+
* component contracts declare) received a value outside its closed enum;
|
|
20
|
+
* the component still rendered, with a quiet marker.
|
|
21
|
+
*
|
|
22
|
+
* `'unsafe-image-src'` — a component-built `<img>` (the standard `figure`)
|
|
23
|
+
* had its `src` refused as an unsafe URL scheme; the component rendered
|
|
24
|
+
* without the image, with a quiet marker.
|
|
25
|
+
*/
|
|
26
|
+
export type DiagnosticKind = 'invalid-attribute-value' | 'unsafe-image-src';
|
|
27
|
+
export interface DiagnosticEvent {
|
|
28
|
+
readonly kind: DiagnosticKind;
|
|
29
|
+
/** The directive name the event happened on, when there is a single unambiguous one. */
|
|
30
|
+
readonly directive?: string;
|
|
31
|
+
/** The attribute name involved, for `'invalid-attribute-value'`. Absent for a kind with no single attribute to name. */
|
|
32
|
+
readonly attribute?: string;
|
|
33
|
+
/** The same human-readable text the in-page marker's tooltip carries. */
|
|
34
|
+
readonly message: string;
|
|
35
|
+
}
|
|
36
|
+
export type OnDiagnostic = (event: DiagnosticEvent) => void;
|
|
37
|
+
/**
|
|
38
|
+
* Calls `onDiagnostic` with `event`, swallowing any exception it throws.
|
|
39
|
+
* A host-supplied callback must never be able to break rendering — the same
|
|
40
|
+
* never-throw guarantee every other render-time hook in this format carries
|
|
41
|
+
* (Architecture rule 3's spirit, extended to a host's own code). Both
|
|
42
|
+
* engines call this instead of invoking `onDiagnostic` directly, so the
|
|
43
|
+
* guard cannot drift between them.
|
|
44
|
+
*/
|
|
45
|
+
export declare function reportDiagnostic(onDiagnostic: OnDiagnostic | undefined, event: DiagnosticEvent): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The neutral shape of a render-time diagnostic event (docs/integration.md):
|
|
3
|
+
* something a renderer recognized and handled by rendering a quiet marker
|
|
4
|
+
* instead of silently dropping content or failing outright, reported
|
|
5
|
+
* through a render engine's `onDiagnostic` option so a host can route it to
|
|
6
|
+
* its own diagnostics surface (AGENTS.md's "clean is not silent" rule: a
|
|
7
|
+
* quiet in-page marker alone is not enough, the reason must also reach a
|
|
8
|
+
* surface a host actually shows).
|
|
9
|
+
*
|
|
10
|
+
* Both platform renderers (`@markii/react`, `@markii/html`) emit this exact
|
|
11
|
+
* shape from their own independent implementations, so a host wiring up
|
|
12
|
+
* one engine's callback needs no engine-specific branching if it later
|
|
13
|
+
* adds the other. The WORDING in `message` is produced by each engine's own
|
|
14
|
+
* `failure-presentation` module (the one home of failure wording per
|
|
15
|
+
* engine) — this module only defines the envelope, never the words.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Calls `onDiagnostic` with `event`, swallowing any exception it throws.
|
|
19
|
+
* A host-supplied callback must never be able to break rendering — the same
|
|
20
|
+
* never-throw guarantee every other render-time hook in this format carries
|
|
21
|
+
* (Architecture rule 3's spirit, extended to a host's own code). Both
|
|
22
|
+
* engines call this instead of invoking `onDiagnostic` directly, so the
|
|
23
|
+
* guard cannot drift between them.
|
|
24
|
+
*/
|
|
25
|
+
export function reportDiagnostic(onDiagnostic, event) {
|
|
26
|
+
if (!onDiagnostic)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
onDiagnostic(event);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// A throwing callback must never break rendering.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InsertableComponent } from '../insert/component-catalog.js';
|
|
2
|
+
import type { CompletionContext, HoverInfo } from './types.js';
|
|
3
|
+
export type { CompletionContext, CompletionContextKind, CompletionItem, CompletionItemKind, ComponentDocumentation, HoverInfo, } from './types.js';
|
|
4
|
+
export { componentDocumentation, formatComponentDocumentation, } from './documentation.js';
|
|
5
|
+
/**
|
|
6
|
+
* The one entry point a host calls: the current line's text, the zero-based
|
|
7
|
+
* cursor column, and the insert catalog. Never throws; returns a `'none'`
|
|
8
|
+
* context when nothing completes here.
|
|
9
|
+
*/
|
|
10
|
+
export declare function completionAt(line: string, column: number, catalog: readonly InsertableComponent[]): CompletionContext;
|
|
11
|
+
/**
|
|
12
|
+
* The directive under the cursor, for a hover popup. `undefined` when the
|
|
13
|
+
* cursor is not on a known component's directive name.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hoverAt(line: string, column: number, catalog: readonly InsertableComponent[]): HoverInfo | undefined;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { getContract } from '../../contracts.js';
|
|
2
|
+
import { LAYOUT_ATTRIBUTES, LAYOUT_ATTRIBUTE_KEYS, layoutWrapperAxis, otherLayoutAxis, } from '../../layout.js';
|
|
3
|
+
import { componentSkeleton } from '../insert/component-skeleton.js';
|
|
4
|
+
import { firstSentence } from '../insert/first-sentence.js';
|
|
5
|
+
import { clampColumn, findDirectiveNameTokenAt, parseCompletionContext, } from './directive-context.js';
|
|
6
|
+
import { componentDocumentation } from './documentation.js';
|
|
7
|
+
export { componentDocumentation, formatComponentDocumentation, } from './documentation.js';
|
|
8
|
+
const EMPTY_CONTEXT_AT = (column) => ({
|
|
9
|
+
kind: 'none',
|
|
10
|
+
replaceStart: column,
|
|
11
|
+
replaceEnd: column,
|
|
12
|
+
items: [],
|
|
13
|
+
});
|
|
14
|
+
/** Whether `component` is offered when the author is typing directive `form`. A pack component with no declared kind (see `InsertableComponent.kindDeclared`) is offered in every form. */
|
|
15
|
+
function componentOffersForm(component, form) {
|
|
16
|
+
if (!component.kindDeclared)
|
|
17
|
+
return true;
|
|
18
|
+
return component.kind === form;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Adjusts a container skeleton's opening/closing fence to use exactly the
|
|
22
|
+
* colon run the user typed (docs/format.md: an outer fence needs more
|
|
23
|
+
* colons than an inner one, e.g. `::::tabs` when nesting). The skeleton's
|
|
24
|
+
* container form is always `:::name{...}\n\n:::` — a fixed three-colon
|
|
25
|
+
* prefix and a fixed three-colon suffix — so swapping both for `colonRun`
|
|
26
|
+
* is a straight slice-and-splice; `cursorOffset` shifts by the same amount
|
|
27
|
+
* the prefix grew, since the cursor always lands before the closing fence.
|
|
28
|
+
*/
|
|
29
|
+
function adjustContainerFence(skeleton, colonRun) {
|
|
30
|
+
if (colonRun.length === 3)
|
|
31
|
+
return skeleton;
|
|
32
|
+
const shift = colonRun.length - 3;
|
|
33
|
+
const text = colonRun + skeleton.text.slice(3, skeleton.text.length - 3) + colonRun;
|
|
34
|
+
return { text, cursorOffset: skeleton.cursorOffset + shift };
|
|
35
|
+
}
|
|
36
|
+
function buildSkeleton(directiveName, kind, requiredAttributes, colonRun) {
|
|
37
|
+
const skeleton = componentSkeleton(directiveName, kind, requiredAttributes);
|
|
38
|
+
return kind === 'container'
|
|
39
|
+
? adjustContainerFence(skeleton, colonRun)
|
|
40
|
+
: skeleton;
|
|
41
|
+
}
|
|
42
|
+
function directiveNameItem(component, ctx, isRestEmpty) {
|
|
43
|
+
let insertText;
|
|
44
|
+
let insertCursorOffset;
|
|
45
|
+
if (isRestEmpty) {
|
|
46
|
+
// The form the AUTHOR TYPED wins over the catalog's `kind` for a pack
|
|
47
|
+
// component that declared none: `componentOffersForm` above lets such a
|
|
48
|
+
// component through in all three forms, and the catalog's `'container'`
|
|
49
|
+
// is only a default there, not a fact. Building a container skeleton
|
|
50
|
+
// for an author who typed `::` would rewrite their two colons into a
|
|
51
|
+
// broken two-colon fence. For every other component the two agree by
|
|
52
|
+
// construction, since the filter only admits a matching form.
|
|
53
|
+
const kind = component.kindDeclared ? component.kind : ctx.form;
|
|
54
|
+
const skeleton = buildSkeleton(component.directiveName, kind, component.requiredAttributes, ctx.colonRun);
|
|
55
|
+
insertText = skeleton.text;
|
|
56
|
+
insertCursorOffset = skeleton.cursorOffset;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
insertText = component.directiveName;
|
|
60
|
+
insertCursorOffset = component.directiveName.length;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
label: component.directiveName,
|
|
64
|
+
kind: 'component',
|
|
65
|
+
detail: component.description ?? '',
|
|
66
|
+
documentation: componentDocumentation(component),
|
|
67
|
+
insertText,
|
|
68
|
+
insertCursorOffset,
|
|
69
|
+
group: component.group,
|
|
70
|
+
...(component.packName !== undefined
|
|
71
|
+
? { packName: component.packName }
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function directiveNameCompletionContext(ctx, catalog, line) {
|
|
76
|
+
const restOfLine = line.slice(ctx.replaceEnd);
|
|
77
|
+
const isRestEmpty = /^\s*$/.test(restOfLine);
|
|
78
|
+
const replaceStart = isRestEmpty
|
|
79
|
+
? ctx.replaceStart
|
|
80
|
+
: ctx.replaceStart + ctx.colonRun.length;
|
|
81
|
+
const items = catalog
|
|
82
|
+
.filter((component) => componentOffersForm(component, ctx.form))
|
|
83
|
+
.map((component) => directiveNameItem(component, ctx, isRestEmpty));
|
|
84
|
+
return {
|
|
85
|
+
kind: 'directive-name',
|
|
86
|
+
replaceStart,
|
|
87
|
+
replaceEnd: ctx.replaceEnd,
|
|
88
|
+
tokenStart: ctx.tokenStart,
|
|
89
|
+
items,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** The catalog entry for `directiveName`, matched case-insensitively the same way `hoverAt` matches. */
|
|
93
|
+
function findCatalogEntry(catalog, directiveName) {
|
|
94
|
+
const lowered = directiveName.toLowerCase();
|
|
95
|
+
return catalog.find((component) => component.directiveName.toLowerCase() === lowered);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The declared attributes of the pack component named `directiveName`, or
|
|
99
|
+
* an empty list when the name is unknown, is a standard component (its
|
|
100
|
+
* attributes come from its contract instead), or declared none.
|
|
101
|
+
*
|
|
102
|
+
* On a block form the two reserved layout attributes are filtered out:
|
|
103
|
+
* `width` and `align` are offered from `@markii/stdlib`'s one layout
|
|
104
|
+
* source a few lines below, so a pack that also declares one of those
|
|
105
|
+
* names would otherwise produce two rows for the same attribute, with
|
|
106
|
+
* different values behind them. An inline directive is never offered the
|
|
107
|
+
* layout attributes at all, so there a declared `width` is the pack's own
|
|
108
|
+
* and passes through.
|
|
109
|
+
*/
|
|
110
|
+
function packAttributesFor(directiveName, form, catalog) {
|
|
111
|
+
const entry = findCatalogEntry(catalog, directiveName);
|
|
112
|
+
if (entry?.source !== 'pack' || entry.attributes === undefined)
|
|
113
|
+
return [];
|
|
114
|
+
if (form === 'inline')
|
|
115
|
+
return entry.attributes;
|
|
116
|
+
return entry.attributes.filter((attribute) => !LAYOUT_ATTRIBUTE_NAME_SET.has(attribute.name));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Expresses a pack's declared attribute as the same `AttributeSchema` a
|
|
120
|
+
* standard contract carries, so `attributeNameItem` below stays the ONE
|
|
121
|
+
* item builder for both sources rather than growing a near-duplicate for
|
|
122
|
+
* pack attributes. `default` has no place in `AttributeSchema` and is not
|
|
123
|
+
* invented into one here; it reaches the author through the attribute doc
|
|
124
|
+
* lines in `./documentation.ts`.
|
|
125
|
+
*/
|
|
126
|
+
function schemaForPackAttribute(attribute) {
|
|
127
|
+
return {
|
|
128
|
+
type: 'string',
|
|
129
|
+
...(attribute.required === true ? { required: true } : {}),
|
|
130
|
+
...(attribute.values !== undefined ? { enum: attribute.values } : {}),
|
|
131
|
+
description: attribute.description ?? '',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function attributeNameItem(name, schema) {
|
|
135
|
+
const insertText = `${name}=""`;
|
|
136
|
+
const insertCursorOffset = name.length + 2;
|
|
137
|
+
const requiredPrefix = schema.required === true ? 'required. ' : '';
|
|
138
|
+
// A pack may declare an attribute with no description at all, which a
|
|
139
|
+
// standard contract never does. Trimming keeps that case from reading as
|
|
140
|
+
// `required. ` with a dangling separator, and is a no-op for every
|
|
141
|
+
// described attribute.
|
|
142
|
+
const detail = `${requiredPrefix}${firstSentence(schema.description)}`.trim();
|
|
143
|
+
return {
|
|
144
|
+
label: name,
|
|
145
|
+
kind: 'attribute',
|
|
146
|
+
detail,
|
|
147
|
+
documentation: { summary: schema.description, attributes: [], example: '' },
|
|
148
|
+
insertText,
|
|
149
|
+
insertCursorOffset,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The two reserved layout attributes, read from `@markii/stdlib` rather
|
|
154
|
+
* than re-listed here: that package is the one source of the layout
|
|
155
|
+
* vocabulary, and a third hand-copied literal is exactly what it exists to
|
|
156
|
+
* prevent.
|
|
157
|
+
*/
|
|
158
|
+
const LAYOUT_ATTRIBUTE_NAMES = LAYOUT_ATTRIBUTE_KEYS;
|
|
159
|
+
const LAYOUT_ATTRIBUTE_NAME_SET = new Set(LAYOUT_ATTRIBUTE_NAMES);
|
|
160
|
+
/**
|
|
161
|
+
* Which reserved layout attributes to offer on `directiveName`, written in
|
|
162
|
+
* directive `form`.
|
|
163
|
+
*
|
|
164
|
+
* An inline directive gets none: the two keys are stripped there and have
|
|
165
|
+
* no effect, so offering them would be offering a no-op. A layout WRAPPER
|
|
166
|
+
* gets exactly one, the axis its own name did not already decide
|
|
167
|
+
* (docs/spec.md §3): `:::center` is already centered, so it offers `width`
|
|
168
|
+
* and not `align`, and `:::fit` offers `align` and not `width`. Writing the
|
|
169
|
+
* wrapper's own axis is ignored by both renderers, so completing it would
|
|
170
|
+
* be inviting the author to type something that does nothing. Every other
|
|
171
|
+
* block directive gets both.
|
|
172
|
+
*/
|
|
173
|
+
function layoutAttributesFor(directiveName, form) {
|
|
174
|
+
if (form === 'inline')
|
|
175
|
+
return [];
|
|
176
|
+
const ownAxis = layoutWrapperAxis(directiveName);
|
|
177
|
+
return ownAxis === undefined
|
|
178
|
+
? LAYOUT_ATTRIBUTE_NAMES
|
|
179
|
+
: [otherLayoutAxis(ownAxis)];
|
|
180
|
+
}
|
|
181
|
+
function attributeNameCompletionContext(ctx, catalog) {
|
|
182
|
+
const contract = getContract(ctx.directiveName);
|
|
183
|
+
const items = [];
|
|
184
|
+
const offered = new Set();
|
|
185
|
+
if (contract !== undefined) {
|
|
186
|
+
for (const [name, schema] of Object.entries(contract.attributes)) {
|
|
187
|
+
if (ctx.presentNames.has(name.toLowerCase()))
|
|
188
|
+
continue;
|
|
189
|
+
items.push(attributeNameItem(name, schema));
|
|
190
|
+
offered.add(name.toLowerCase());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
// No contract means this is not a standard component, so the only
|
|
195
|
+
// attribute metadata that can exist for it is what its pack declared
|
|
196
|
+
// (issue #27 slice 4). A pack that declared none simply contributes no
|
|
197
|
+
// rows, and the layout attributes below still apply.
|
|
198
|
+
for (const attribute of packAttributesFor(ctx.directiveName, ctx.form, catalog)) {
|
|
199
|
+
if (ctx.presentNames.has(attribute.name.toLowerCase()))
|
|
200
|
+
continue;
|
|
201
|
+
items.push(attributeNameItem(attribute.name, schemaForPackAttribute(attribute)));
|
|
202
|
+
offered.add(attribute.name.toLowerCase());
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
for (const name of layoutAttributesFor(ctx.directiveName, ctx.form)) {
|
|
206
|
+
if (ctx.presentNames.has(name) || offered.has(name))
|
|
207
|
+
continue;
|
|
208
|
+
items.push(attributeNameItem(name, LAYOUT_ATTRIBUTES[name]));
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
kind: 'attribute-name',
|
|
212
|
+
replaceStart: ctx.replaceStart,
|
|
213
|
+
replaceEnd: ctx.replaceEnd,
|
|
214
|
+
items,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function resolveValueEnum(ctx, catalog) {
|
|
218
|
+
// The same axis filter the attribute-NAME context applies, so the two can
|
|
219
|
+
// never disagree: a name that is not offered here never gets values
|
|
220
|
+
// either, and `:::center{align=` completes nothing rather than offering
|
|
221
|
+
// three values the renderer will ignore.
|
|
222
|
+
if ((ctx.attributeName === 'width' || ctx.attributeName === 'align') &&
|
|
223
|
+
layoutAttributesFor(ctx.directiveName, ctx.form).includes(ctx.attributeName)) {
|
|
224
|
+
return LAYOUT_ATTRIBUTES[ctx.attributeName].enum;
|
|
225
|
+
}
|
|
226
|
+
const contract = getContract(ctx.directiveName);
|
|
227
|
+
if (contract === undefined) {
|
|
228
|
+
// A pack component's declared `values`, read through the same filtered
|
|
229
|
+
// accessor the attribute-name context uses, so a name offered from the
|
|
230
|
+
// layout source can never take its values from a pack declaration.
|
|
231
|
+
return packAttributesFor(ctx.directiveName, ctx.form, catalog).find((attribute) => attribute.name === ctx.attributeName)?.values;
|
|
232
|
+
}
|
|
233
|
+
// `Object.hasOwn`, not bare indexing: a directive attribute literally
|
|
234
|
+
// named `constructor` or `__proto__` must miss this lookup rather than
|
|
235
|
+
// resolve through the prototype chain to an inherited `Object.prototype`
|
|
236
|
+
// member. Same defense `getContract` itself applies one level up.
|
|
237
|
+
if (!Object.hasOwn(contract.attributes, ctx.attributeName))
|
|
238
|
+
return undefined;
|
|
239
|
+
return contract.attributes[ctx.attributeName]?.enum;
|
|
240
|
+
}
|
|
241
|
+
function attributeValueItem(value, ctx) {
|
|
242
|
+
let insertText;
|
|
243
|
+
if (ctx.quoteChar === undefined) {
|
|
244
|
+
insertText = value;
|
|
245
|
+
}
|
|
246
|
+
else if (ctx.hasClosingQuote) {
|
|
247
|
+
insertText = value;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
insertText = `${value}${ctx.quoteChar}`;
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
label: value,
|
|
254
|
+
kind: 'value',
|
|
255
|
+
detail: '',
|
|
256
|
+
insertText,
|
|
257
|
+
insertCursorOffset: insertText.length,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function attributeValueCompletionContext(ctx, catalog) {
|
|
261
|
+
const enumValues = resolveValueEnum(ctx, catalog);
|
|
262
|
+
if (enumValues === undefined || enumValues.length === 0) {
|
|
263
|
+
return EMPTY_CONTEXT_AT(ctx.replaceEnd);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
kind: 'attribute-value',
|
|
267
|
+
replaceStart: ctx.replaceStart,
|
|
268
|
+
replaceEnd: ctx.replaceEnd,
|
|
269
|
+
items: enumValues.map((value) => attributeValueItem(value, ctx)),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The one entry point a host calls: the current line's text, the zero-based
|
|
274
|
+
* cursor column, and the insert catalog. Never throws; returns a `'none'`
|
|
275
|
+
* context when nothing completes here.
|
|
276
|
+
*/
|
|
277
|
+
export function completionAt(line, column, catalog) {
|
|
278
|
+
try {
|
|
279
|
+
const safeLine = typeof line === 'string' ? line : '';
|
|
280
|
+
const safeCatalog = Array.isArray(catalog) ? catalog : [];
|
|
281
|
+
const clampedColumn = clampColumn(safeLine, column);
|
|
282
|
+
const ctx = parseCompletionContext(safeLine, clampedColumn);
|
|
283
|
+
switch (ctx.kind) {
|
|
284
|
+
case 'attribute-value':
|
|
285
|
+
return attributeValueCompletionContext(ctx, safeCatalog);
|
|
286
|
+
case 'attribute-name':
|
|
287
|
+
return attributeNameCompletionContext(ctx, safeCatalog);
|
|
288
|
+
case 'directive-name':
|
|
289
|
+
return directiveNameCompletionContext(ctx, safeCatalog, safeLine);
|
|
290
|
+
default:
|
|
291
|
+
return EMPTY_CONTEXT_AT(clampedColumn);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return EMPTY_CONTEXT_AT(clampColumn(line, column));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The directive under the cursor, for a hover popup. `undefined` when the
|
|
300
|
+
* cursor is not on a known component's directive name.
|
|
301
|
+
*/
|
|
302
|
+
export function hoverAt(line, column, catalog) {
|
|
303
|
+
try {
|
|
304
|
+
const safeLine = typeof line === 'string' ? line : '';
|
|
305
|
+
const safeCatalog = Array.isArray(catalog) ? catalog : [];
|
|
306
|
+
const token = findDirectiveNameTokenAt(safeLine, column);
|
|
307
|
+
if (token === undefined)
|
|
308
|
+
return undefined;
|
|
309
|
+
const entry = findCatalogEntry(safeCatalog, token.name);
|
|
310
|
+
if (entry === undefined)
|
|
311
|
+
return undefined;
|
|
312
|
+
return {
|
|
313
|
+
directiveName: entry.directiveName,
|
|
314
|
+
documentation: componentDocumentation(entry),
|
|
315
|
+
start: token.start,
|
|
316
|
+
end: token.end,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive autocompletion (GitHub issue #27, slice 1): the pure line/column
|
|
3
|
+
* parser that decides WHAT kind of thing sits at the cursor — a directive
|
|
4
|
+
* name being typed, an attribute name, or an attribute value — and WHERE
|
|
5
|
+
* the accepted completion's text would replace. No knowledge of the insert
|
|
6
|
+
* catalog or `@markii/stdlib` contracts lives here; this module only reads
|
|
7
|
+
* characters. `./completion.ts` turns what this module finds into the
|
|
8
|
+
* actual `CompletionItem[]`.
|
|
9
|
+
*
|
|
10
|
+
* Every exported function is defensive: an out-of-range or negative
|
|
11
|
+
* `column`, an empty line, an unterminated brace, and an unterminated quote
|
|
12
|
+
* all produce a sensible "nothing here" result rather than throwing.
|
|
13
|
+
*/
|
|
14
|
+
import type { ComponentKind } from '../../contracts.js';
|
|
15
|
+
/** Which of the three directive forms the text around the cursor is written as. Matches `ComponentKind`'s three values one-to-one. */
|
|
16
|
+
export type DirectiveForm = ComponentKind;
|
|
17
|
+
/** Attribute names already present in the brace clause, lowercased, minus the partial name currently being typed. */
|
|
18
|
+
export interface AttributeNameParseResult {
|
|
19
|
+
readonly kind: 'attribute-name';
|
|
20
|
+
readonly directiveName: string;
|
|
21
|
+
readonly form: DirectiveForm;
|
|
22
|
+
readonly replaceStart: number;
|
|
23
|
+
readonly replaceEnd: number;
|
|
24
|
+
readonly partial: string;
|
|
25
|
+
readonly presentNames: ReadonlySet<string>;
|
|
26
|
+
}
|
|
27
|
+
export interface AttributeValueParseResult {
|
|
28
|
+
readonly kind: 'attribute-value';
|
|
29
|
+
readonly directiveName: string;
|
|
30
|
+
readonly form: DirectiveForm;
|
|
31
|
+
readonly attributeName: string;
|
|
32
|
+
readonly replaceStart: number;
|
|
33
|
+
readonly replaceEnd: number;
|
|
34
|
+
readonly partial: string;
|
|
35
|
+
/** The quote character the value was opened with, or `undefined` for an unquoted value. */
|
|
36
|
+
readonly quoteChar: '"' | "'" | undefined;
|
|
37
|
+
/** True when a closing quote matching `quoteChar` already sits after the replace range. Always `false` when `quoteChar` is `undefined`. */
|
|
38
|
+
readonly hasClosingQuote: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface DirectiveNameParseResult {
|
|
41
|
+
readonly kind: 'directive-name';
|
|
42
|
+
readonly form: DirectiveForm;
|
|
43
|
+
/** The colon run exactly as typed (e.g. `':'`, `'::'`, `'::::'`). */
|
|
44
|
+
readonly colonRun: string;
|
|
45
|
+
/** Start of the colon run. */
|
|
46
|
+
readonly replaceStart: number;
|
|
47
|
+
readonly replaceEnd: number;
|
|
48
|
+
/** Start of the NAME token, i.e. `replaceStart + colonRun.length` — after the colon run, where the partial name itself begins. See `CompletionContext.tokenStart`'s doc comment for why this is a separate field from `replaceStart`. */
|
|
49
|
+
readonly tokenStart: number;
|
|
50
|
+
readonly partial: string;
|
|
51
|
+
}
|
|
52
|
+
export type ParsedCompletionContext = AttributeNameParseResult | AttributeValueParseResult | DirectiveNameParseResult | {
|
|
53
|
+
readonly kind: 'none';
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Clamps a possibly-hostile column (negative, non-finite, past the end of
|
|
57
|
+
* the line) into `[0, line.length]`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function clampColumn(line: string, column: number): number;
|
|
60
|
+
/**
|
|
61
|
+
* The full context-detection algorithm: attribute contexts are checked
|
|
62
|
+
* first, then the directive-name contexts, then `'none'`. `column` is
|
|
63
|
+
* clamped defensively; this function never throws.
|
|
64
|
+
*/
|
|
65
|
+
export declare function parseCompletionContext(line: string, column: number): ParsedCompletionContext;
|
|
66
|
+
/** A directive name token found anywhere on the line, for `hoverAt`. */
|
|
67
|
+
export interface DirectiveNameToken {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
readonly form: DirectiveForm;
|
|
70
|
+
readonly start: number;
|
|
71
|
+
readonly end: number;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Finds the directive-name token whose range contains or abuts `column`,
|
|
75
|
+
* anywhere on the line (not only at the end of the cursor's prefix, since
|
|
76
|
+
* hover targets an already-written directive). `undefined` when no valid
|
|
77
|
+
* opener's name range covers `column`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function findDirectiveNameTokenAt(line: string, column: number): DirectiveNameToken | undefined;
|