@markii/stdlib 0.13.0 → 0.15.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,326 @@
|
|
|
1
|
+
const NAME_CHAR = /[A-Za-z0-9_-]/;
|
|
2
|
+
/**
|
|
3
|
+
* Clamps a possibly-hostile column (negative, non-finite, past the end of
|
|
4
|
+
* the line) into `[0, line.length]`.
|
|
5
|
+
*/
|
|
6
|
+
export function clampColumn(line, column) {
|
|
7
|
+
const safeLine = typeof line === 'string' ? line : '';
|
|
8
|
+
const safeColumn = Number.isFinite(column) ? column : 0;
|
|
9
|
+
return Math.max(0, Math.min(safeColumn, safeLine.length));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The last `{` at an index below `column` with no `}` between it and
|
|
13
|
+
* `column`, or `undefined` if the nearest unmatched-looking brace character
|
|
14
|
+
* going backward is a `}` (the braces are already closed here) or there is
|
|
15
|
+
* no `{` at all.
|
|
16
|
+
*/
|
|
17
|
+
function findOpenBrace(line, column) {
|
|
18
|
+
for (let i = column - 1; i >= 0; i--) {
|
|
19
|
+
const ch = line[i];
|
|
20
|
+
if (ch === '}')
|
|
21
|
+
return undefined;
|
|
22
|
+
if (ch === '{')
|
|
23
|
+
return i;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Extends forward from `from` over identifier characters, stopping at the first non-identifier character or end of line. */
|
|
28
|
+
function extendOverIdentifier(line, from) {
|
|
29
|
+
let end = from;
|
|
30
|
+
while (end < line.length && NAME_CHAR.test(line[end]))
|
|
31
|
+
end++;
|
|
32
|
+
return end;
|
|
33
|
+
}
|
|
34
|
+
const OPENER_RE = /(:{1,})([A-Za-z0-9_-]+)(\[[^\]]*\])?$/;
|
|
35
|
+
/**
|
|
36
|
+
* Finds the directive opener a `{...}` clause belongs to: the text right
|
|
37
|
+
* before the brace must end with `:name`, `::name`, or `:::name` (three or
|
|
38
|
+
* more colons), positioned per docs/format.md's rules (a block form's colon
|
|
39
|
+
* run sits at the line's start, ignoring leading whitespace; the inline
|
|
40
|
+
* form's single colon sits at the line's start or after whitespace).
|
|
41
|
+
* `undefined` when there is no unclosed `{` below `column`, or the text
|
|
42
|
+
* before it does not look like a directive opener.
|
|
43
|
+
*/
|
|
44
|
+
function findAttributeOpener(line, column) {
|
|
45
|
+
const braceIndex = findOpenBrace(line, column);
|
|
46
|
+
if (braceIndex === undefined)
|
|
47
|
+
return undefined;
|
|
48
|
+
const before = line.slice(0, braceIndex);
|
|
49
|
+
const match = OPENER_RE.exec(before);
|
|
50
|
+
if (match === null)
|
|
51
|
+
return undefined;
|
|
52
|
+
const colons = match[1];
|
|
53
|
+
const name = match[2];
|
|
54
|
+
const colonStart = match.index;
|
|
55
|
+
if (colons.length >= 2) {
|
|
56
|
+
if (!/^\s*$/.test(line.slice(0, colonStart)))
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
else if (colonStart > 0 && !/\s/.test(line[colonStart - 1])) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const form = colons.length === 1 ? 'inline' : colons.length === 2 ? 'leaf' : 'container';
|
|
63
|
+
return { form, directiveName: name.toLowerCase(), braceIndex };
|
|
64
|
+
}
|
|
65
|
+
/** The end of a brace clause: the first unquoted `}` after `braceIndex`, or `line.length` when the clause is never closed. */
|
|
66
|
+
function findClauseEnd(line, braceIndex) {
|
|
67
|
+
let quote;
|
|
68
|
+
for (let i = braceIndex + 1; i < line.length; i++) {
|
|
69
|
+
const ch = line[i];
|
|
70
|
+
if (quote !== undefined) {
|
|
71
|
+
if (ch === quote)
|
|
72
|
+
quote = undefined;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === '"' || ch === "'") {
|
|
76
|
+
quote = ch;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === '}')
|
|
80
|
+
return i;
|
|
81
|
+
}
|
|
82
|
+
return line.length;
|
|
83
|
+
}
|
|
84
|
+
/** Splits `text` into whitespace-separated tokens, respecting quoted spans so a quoted value's internal whitespace never splits a token. */
|
|
85
|
+
function splitTopLevelTokens(text) {
|
|
86
|
+
const tokens = [];
|
|
87
|
+
let current = '';
|
|
88
|
+
let quote;
|
|
89
|
+
for (const ch of text) {
|
|
90
|
+
if (quote !== undefined) {
|
|
91
|
+
current += ch;
|
|
92
|
+
if (ch === quote)
|
|
93
|
+
quote = undefined;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === '"' || ch === "'") {
|
|
97
|
+
quote = ch;
|
|
98
|
+
current += ch;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (/\s/.test(ch)) {
|
|
102
|
+
if (current.length > 0) {
|
|
103
|
+
tokens.push(current);
|
|
104
|
+
current = '';
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
current += ch;
|
|
109
|
+
}
|
|
110
|
+
if (current.length > 0)
|
|
111
|
+
tokens.push(current);
|
|
112
|
+
return tokens;
|
|
113
|
+
}
|
|
114
|
+
/** Attribute names already written in a brace clause (`name=` and bare `name` tokens), lowercased. Ignores `#id`/`.class` shorthand tokens. */
|
|
115
|
+
function presentAttributeNames(line, start, end) {
|
|
116
|
+
const tokens = splitTopLevelTokens(line.slice(start, end));
|
|
117
|
+
const names = new Set();
|
|
118
|
+
for (const token of tokens) {
|
|
119
|
+
if (token.startsWith('#') || token.startsWith('.'))
|
|
120
|
+
continue;
|
|
121
|
+
const eq = token.indexOf('=');
|
|
122
|
+
const name = eq === -1 ? token : token.slice(0, eq);
|
|
123
|
+
if (name.length > 0)
|
|
124
|
+
names.add(name.toLowerCase());
|
|
125
|
+
}
|
|
126
|
+
return names;
|
|
127
|
+
}
|
|
128
|
+
/** The attribute name whose `=` sits at `equalsIndex`, or `undefined` if no name characters precede it. Lowercased. */
|
|
129
|
+
function attributeNameBeforeEquals(line, equalsIndex) {
|
|
130
|
+
let i = equalsIndex;
|
|
131
|
+
while (i > 0 && NAME_CHAR.test(line[i - 1]))
|
|
132
|
+
i--;
|
|
133
|
+
if (i === equalsIndex)
|
|
134
|
+
return undefined;
|
|
135
|
+
return line.slice(i, equalsIndex).toLowerCase();
|
|
136
|
+
}
|
|
137
|
+
/** Whether `column` sits inside an open quote started somewhere in `[start, column)`, tracking simple (non-escaping) quote toggles. */
|
|
138
|
+
function quoteStateUpTo(line, start, column) {
|
|
139
|
+
let quote;
|
|
140
|
+
let quoteOpenIndex = -1;
|
|
141
|
+
for (let i = start; i < column; i++) {
|
|
142
|
+
const ch = line[i];
|
|
143
|
+
if (quote !== undefined) {
|
|
144
|
+
if (ch === quote) {
|
|
145
|
+
quote = undefined;
|
|
146
|
+
quoteOpenIndex = -1;
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (ch === '"' || ch === "'") {
|
|
151
|
+
quote = ch;
|
|
152
|
+
quoteOpenIndex = i;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { quote, quoteOpenIndex };
|
|
156
|
+
}
|
|
157
|
+
/** The attribute-name or attribute-value context inside an opened `{...}` clause, or `{ kind: 'none' }` when the cursor sits somewhere that context detection cannot make sense of (e.g. an unterminated quote not opened right after `=`). */
|
|
158
|
+
function parseInsideBraces(line, column, opener) {
|
|
159
|
+
const start = opener.braceIndex + 1;
|
|
160
|
+
const qs = quoteStateUpTo(line, start, column);
|
|
161
|
+
if (qs.quote !== undefined) {
|
|
162
|
+
if (line[qs.quoteOpenIndex - 1] !== '=')
|
|
163
|
+
return { kind: 'none' };
|
|
164
|
+
const attributeName = attributeNameBeforeEquals(line, qs.quoteOpenIndex - 1);
|
|
165
|
+
if (attributeName === undefined)
|
|
166
|
+
return { kind: 'none' };
|
|
167
|
+
const valueStart = qs.quoteOpenIndex + 1;
|
|
168
|
+
let valueEnd = column;
|
|
169
|
+
while (valueEnd < line.length && line[valueEnd] !== qs.quote)
|
|
170
|
+
valueEnd++;
|
|
171
|
+
const hasClosingQuote = line[valueEnd] === qs.quote;
|
|
172
|
+
return {
|
|
173
|
+
kind: 'attribute-value',
|
|
174
|
+
directiveName: opener.directiveName,
|
|
175
|
+
form: opener.form,
|
|
176
|
+
attributeName,
|
|
177
|
+
replaceStart: valueStart,
|
|
178
|
+
replaceEnd: valueEnd,
|
|
179
|
+
partial: line.slice(valueStart, column),
|
|
180
|
+
quoteChar: qs.quote,
|
|
181
|
+
hasClosingQuote,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const beforeCursor = line.slice(start, column);
|
|
185
|
+
const tokenMatch = /[A-Za-z0-9_-]*$/.exec(beforeCursor);
|
|
186
|
+
const token = tokenMatch[0];
|
|
187
|
+
const tokenStart = column - token.length;
|
|
188
|
+
const prevChar = line[tokenStart - 1];
|
|
189
|
+
if (prevChar === '=') {
|
|
190
|
+
const attributeName = attributeNameBeforeEquals(line, tokenStart - 1);
|
|
191
|
+
if (attributeName === undefined)
|
|
192
|
+
return { kind: 'none' };
|
|
193
|
+
const valueEnd = extendOverIdentifier(line, column);
|
|
194
|
+
return {
|
|
195
|
+
kind: 'attribute-value',
|
|
196
|
+
directiveName: opener.directiveName,
|
|
197
|
+
form: opener.form,
|
|
198
|
+
attributeName,
|
|
199
|
+
replaceStart: tokenStart,
|
|
200
|
+
replaceEnd: valueEnd,
|
|
201
|
+
partial: token,
|
|
202
|
+
quoteChar: undefined,
|
|
203
|
+
hasClosingQuote: false,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (prevChar === '{' || prevChar === ' ' || prevChar === '\t') {
|
|
207
|
+
const clauseEnd = findClauseEnd(line, opener.braceIndex);
|
|
208
|
+
const presentNames = presentAttributeNames(line, start, clauseEnd);
|
|
209
|
+
presentNames.delete(token.toLowerCase());
|
|
210
|
+
const nameEnd = extendOverIdentifier(line, column);
|
|
211
|
+
return {
|
|
212
|
+
kind: 'attribute-name',
|
|
213
|
+
directiveName: opener.directiveName,
|
|
214
|
+
form: opener.form,
|
|
215
|
+
replaceStart: tokenStart,
|
|
216
|
+
replaceEnd: nameEnd,
|
|
217
|
+
partial: token,
|
|
218
|
+
presentNames,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { kind: 'none' };
|
|
222
|
+
}
|
|
223
|
+
/** Parses the block-form (`::`/`:::`+) directive-name context: `^(\s*)(:{2,})([A-Za-z0-9_-]*)$` against the text up to `column`. May fire with zero name characters typed. */
|
|
224
|
+
function parseBlockDirectiveName(line, column) {
|
|
225
|
+
const prefix = line.slice(0, column);
|
|
226
|
+
const match = /^(\s*)(:{2,})([A-Za-z0-9_-]*)$/.exec(prefix);
|
|
227
|
+
if (match === null)
|
|
228
|
+
return undefined;
|
|
229
|
+
const ws = match[1];
|
|
230
|
+
const colons = match[2];
|
|
231
|
+
const name = match[3];
|
|
232
|
+
const colonStart = ws.length;
|
|
233
|
+
const form = colons.length === 2 ? 'leaf' : 'container';
|
|
234
|
+
const nameEnd = extendOverIdentifier(line, column);
|
|
235
|
+
return {
|
|
236
|
+
kind: 'directive-name',
|
|
237
|
+
form,
|
|
238
|
+
colonRun: colons,
|
|
239
|
+
replaceStart: colonStart,
|
|
240
|
+
replaceEnd: nameEnd,
|
|
241
|
+
tokenStart: colonStart + colons.length,
|
|
242
|
+
partial: name,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** Parses the inline-form (single `:`) directive-name context. Requires at least one typed name character, and the colon must sit at the line start or after whitespace (and must not itself be part of a longer colon run). */
|
|
246
|
+
function parseInlineDirectiveName(line, column) {
|
|
247
|
+
let i = column;
|
|
248
|
+
while (i > 0 && NAME_CHAR.test(line[i - 1]))
|
|
249
|
+
i--;
|
|
250
|
+
const nameStart = i;
|
|
251
|
+
if (nameStart === column)
|
|
252
|
+
return undefined; // no typed name characters
|
|
253
|
+
if (nameStart === 0)
|
|
254
|
+
return undefined; // no colon can precede
|
|
255
|
+
if (line[nameStart - 1] !== ':')
|
|
256
|
+
return undefined;
|
|
257
|
+
const colonIndex = nameStart - 1;
|
|
258
|
+
if (!(colonIndex === 0 || /\s/.test(line[colonIndex - 1])))
|
|
259
|
+
return undefined;
|
|
260
|
+
const nameEnd = extendOverIdentifier(line, column);
|
|
261
|
+
return {
|
|
262
|
+
kind: 'directive-name',
|
|
263
|
+
form: 'inline',
|
|
264
|
+
colonRun: ':',
|
|
265
|
+
replaceStart: colonIndex,
|
|
266
|
+
replaceEnd: nameEnd,
|
|
267
|
+
tokenStart: nameStart,
|
|
268
|
+
partial: line.slice(nameStart, column),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The full context-detection algorithm: attribute contexts are checked
|
|
273
|
+
* first, then the directive-name contexts, then `'none'`. `column` is
|
|
274
|
+
* clamped defensively; this function never throws.
|
|
275
|
+
*/
|
|
276
|
+
export function parseCompletionContext(line, column) {
|
|
277
|
+
const safeLine = typeof line === 'string' ? line : '';
|
|
278
|
+
const clampedColumn = clampColumn(safeLine, column);
|
|
279
|
+
const opener = findAttributeOpener(safeLine, clampedColumn);
|
|
280
|
+
if (opener !== undefined) {
|
|
281
|
+
return parseInsideBraces(safeLine, clampedColumn, opener);
|
|
282
|
+
}
|
|
283
|
+
const block = parseBlockDirectiveName(safeLine, clampedColumn);
|
|
284
|
+
if (block !== undefined)
|
|
285
|
+
return block;
|
|
286
|
+
const inline = parseInlineDirectiveName(safeLine, clampedColumn);
|
|
287
|
+
if (inline !== undefined)
|
|
288
|
+
return inline;
|
|
289
|
+
return { kind: 'none' };
|
|
290
|
+
}
|
|
291
|
+
const OPENER_SCAN_RE = /(:{1,})([A-Za-z0-9_-]+)/g;
|
|
292
|
+
/**
|
|
293
|
+
* Finds the directive-name token whose range contains or abuts `column`,
|
|
294
|
+
* anywhere on the line (not only at the end of the cursor's prefix, since
|
|
295
|
+
* hover targets an already-written directive). `undefined` when no valid
|
|
296
|
+
* opener's name range covers `column`.
|
|
297
|
+
*/
|
|
298
|
+
export function findDirectiveNameTokenAt(line, column) {
|
|
299
|
+
const safeLine = typeof line === 'string' ? line : '';
|
|
300
|
+
const clampedColumn = clampColumn(safeLine, column);
|
|
301
|
+
OPENER_SCAN_RE.lastIndex = 0;
|
|
302
|
+
let match;
|
|
303
|
+
while ((match = OPENER_SCAN_RE.exec(safeLine)) !== null) {
|
|
304
|
+
const colons = match[1];
|
|
305
|
+
const name = match[2];
|
|
306
|
+
const colonStart = match.index;
|
|
307
|
+
const nameStart = colonStart + colons.length;
|
|
308
|
+
const nameEnd = nameStart + name.length;
|
|
309
|
+
const valid = colons.length === 1
|
|
310
|
+
? colonStart === 0 || /\s/.test(safeLine[colonStart - 1])
|
|
311
|
+
: /^\s*$/.test(safeLine.slice(0, colonStart));
|
|
312
|
+
if (valid && nameStart <= clampedColumn && clampedColumn <= nameEnd) {
|
|
313
|
+
return {
|
|
314
|
+
name: name.toLowerCase(),
|
|
315
|
+
form: colons.length === 1
|
|
316
|
+
? 'inline'
|
|
317
|
+
: colons.length === 2
|
|
318
|
+
? 'leaf'
|
|
319
|
+
: 'container',
|
|
320
|
+
start: nameStart,
|
|
321
|
+
end: nameEnd,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { InsertableComponent } from '../insert/component-catalog.js';
|
|
2
|
+
import type { ComponentDocumentation } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Builds the structured documentation for one catalog entry: a standard
|
|
5
|
+
* component's contract description and attribute list (its full prose, not
|
|
6
|
+
* the picker row's first-sentence truncation), or a pack component's own
|
|
7
|
+
* declared description and declared attribute list (issue #27 slice 4).
|
|
8
|
+
* Never composed filler text: a pack that declares neither gets empty
|
|
9
|
+
* strings and an empty list, and the host decides what, if anything, to
|
|
10
|
+
* show in their place.
|
|
11
|
+
*/
|
|
12
|
+
export declare function componentDocumentation(component: InsertableComponent): ComponentDocumentation;
|
|
13
|
+
/**
|
|
14
|
+
* Renders `ComponentDocumentation` as plain text for a host with no
|
|
15
|
+
* rich-text popup:
|
|
16
|
+
*
|
|
17
|
+
* ```
|
|
18
|
+
* <summary>
|
|
19
|
+
*
|
|
20
|
+
* Attributes:
|
|
21
|
+
* - type: info | warning | danger
|
|
22
|
+
* - title
|
|
23
|
+
*
|
|
24
|
+
* Example: :::callout{}
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Any section with nothing to show is omitted entirely, and the result
|
|
28
|
+
* never ends with a trailing blank line. All-empty documentation formats to
|
|
29
|
+
* the empty string.
|
|
30
|
+
*/
|
|
31
|
+
export declare function formatComponentDocumentation(doc: ComponentDocumentation): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { getContract } from '../../contracts.js';
|
|
2
|
+
import { componentSkeleton } from '../insert/component-skeleton.js';
|
|
3
|
+
/** `name`, `name (required)`, `name: a | b`, or `name (required): a | b`. */
|
|
4
|
+
function attributeDocLine(name, schema) {
|
|
5
|
+
const requiredPart = schema.required === true ? ' (required)' : '';
|
|
6
|
+
const enumPart = schema.enum !== undefined ? `: ${schema.enum.join(' | ')}` : '';
|
|
7
|
+
return `${name}${requiredPart}${enumPart}`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The same line for a pack's declared attribute, plus the one thing a
|
|
11
|
+
* standard contract has no field for: a declared `default`, appended as
|
|
12
|
+
* `(default: x)`. Everything else lines up with `attributeDocLine` above
|
|
13
|
+
* so a reader cannot tell from the shape of the line whether the
|
|
14
|
+
* attributes came from a contract or from a `pack.json`.
|
|
15
|
+
*/
|
|
16
|
+
function packAttributeDocLine(attribute) {
|
|
17
|
+
const requiredPart = attribute.required === true ? ' (required)' : '';
|
|
18
|
+
const valuesPart = attribute.values !== undefined ? `: ${attribute.values.join(' | ')}` : '';
|
|
19
|
+
const defaultPart = attribute.default !== undefined ? ` (default: ${attribute.default})` : '';
|
|
20
|
+
return `${attribute.name}${requiredPart}${valuesPart}${defaultPart}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The one-line usage example built from `componentSkeleton`. For a
|
|
24
|
+
* container, only the opening fence line is shown (`:::name{}`, with its
|
|
25
|
+
* brace clause and required attributes if any) rather than the multi-line
|
|
26
|
+
* `\n\n:::` body form — that is the line an author actually recognizes as
|
|
27
|
+
* "how do I write this".
|
|
28
|
+
*/
|
|
29
|
+
function exampleFor(component) {
|
|
30
|
+
const skeleton = componentSkeleton(component.directiveName, component.kind, component.requiredAttributes);
|
|
31
|
+
if (component.kind !== 'container')
|
|
32
|
+
return skeleton.text;
|
|
33
|
+
const newline = skeleton.text.indexOf('\n');
|
|
34
|
+
return newline === -1 ? skeleton.text : skeleton.text.slice(0, newline);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Builds the structured documentation for one catalog entry: a standard
|
|
38
|
+
* component's contract description and attribute list (its full prose, not
|
|
39
|
+
* the picker row's first-sentence truncation), or a pack component's own
|
|
40
|
+
* declared description and declared attribute list (issue #27 slice 4).
|
|
41
|
+
* Never composed filler text: a pack that declares neither gets empty
|
|
42
|
+
* strings and an empty list, and the host decides what, if anything, to
|
|
43
|
+
* show in their place.
|
|
44
|
+
*/
|
|
45
|
+
export function componentDocumentation(component) {
|
|
46
|
+
const example = exampleFor(component);
|
|
47
|
+
if (component.source === 'standard') {
|
|
48
|
+
const contract = getContract(component.directiveName);
|
|
49
|
+
if (contract === undefined) {
|
|
50
|
+
return { summary: component.description ?? '', attributes: [], example };
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
summary: contract.description,
|
|
54
|
+
attributes: Object.entries(contract.attributes).map(([name, schema]) => attributeDocLine(name, schema)),
|
|
55
|
+
example,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
summary: component.description ?? '',
|
|
60
|
+
attributes: (component.attributes ?? []).map((attribute) => packAttributeDocLine(attribute)),
|
|
61
|
+
example,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Renders `ComponentDocumentation` as plain text for a host with no
|
|
66
|
+
* rich-text popup:
|
|
67
|
+
*
|
|
68
|
+
* ```
|
|
69
|
+
* <summary>
|
|
70
|
+
*
|
|
71
|
+
* Attributes:
|
|
72
|
+
* - type: info | warning | danger
|
|
73
|
+
* - title
|
|
74
|
+
*
|
|
75
|
+
* Example: :::callout{}
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* Any section with nothing to show is omitted entirely, and the result
|
|
79
|
+
* never ends with a trailing blank line. All-empty documentation formats to
|
|
80
|
+
* the empty string.
|
|
81
|
+
*/
|
|
82
|
+
export function formatComponentDocumentation(doc) {
|
|
83
|
+
const sections = [];
|
|
84
|
+
if (doc.summary.length > 0)
|
|
85
|
+
sections.push(doc.summary);
|
|
86
|
+
if (doc.attributes.length > 0) {
|
|
87
|
+
sections.push(['Attributes:', ...doc.attributes.map((line) => `- ${line}`)].join('\n'));
|
|
88
|
+
}
|
|
89
|
+
if (doc.example.length > 0)
|
|
90
|
+
sections.push(`Example: ${doc.example}`);
|
|
91
|
+
return sections.join('\n\n');
|
|
92
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive autocompletion (GitHub issue #27, slice 1): the public surface
|
|
3
|
+
* both hosts' completion providers import. See `./completion.ts` for
|
|
4
|
+
* `completionAt`/`hoverAt`, `./documentation.ts` for
|
|
5
|
+
* `componentDocumentation`/`formatComponentDocumentation`, and `./types.ts`
|
|
6
|
+
* for the shared contract types.
|
|
7
|
+
*/
|
|
8
|
+
export type { CompletionContext, CompletionContextKind, CompletionItem, CompletionItemKind, ComponentDocumentation, HoverInfo, } from './types.js';
|
|
9
|
+
export { completionAt, componentDocumentation, formatComponentDocumentation, hoverAt, } from './completion.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { completionAt, componentDocumentation, formatComponentDocumentation, hoverAt, } from './completion.js';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive autocompletion (GitHub issue #27, slice 1): the public types
|
|
3
|
+
* both `completionAt`/`hoverAt` (`./completion.ts`) and
|
|
4
|
+
* `componentDocumentation`/`formatComponentDocumentation`
|
|
5
|
+
* (`./documentation.ts`) are built against. Kept in their own module so
|
|
6
|
+
* neither of those implementation files has to import the other just to
|
|
7
|
+
* share a type — this is the one seam every editor host (a `@markii/*`
|
|
8
|
+
* host in this repo, or a third-party one importing `@markii/stdlib/editor`
|
|
9
|
+
* directly) codes against, so its names and shapes are a contract, not an
|
|
10
|
+
* implementation detail.
|
|
11
|
+
*/
|
|
12
|
+
/** What a completion item inserts and where it came from. */
|
|
13
|
+
export type CompletionItemKind = 'component' | 'attribute' | 'value';
|
|
14
|
+
/** Structured documentation for a component or attribute, so a host can render it its own way. */
|
|
15
|
+
export interface ComponentDocumentation {
|
|
16
|
+
/** Full prose: a standard component's contract description, a pack component's declared description, or an attribute's description. Empty string when none is available. */
|
|
17
|
+
readonly summary: string;
|
|
18
|
+
/** One line per attribute, e.g. `type (required): info | warning | danger`. Empty for an attribute's own documentation. */
|
|
19
|
+
readonly attributes: readonly string[];
|
|
20
|
+
/** A one-line usage example built from the skeleton builder, e.g. `:::callout{}`. Empty when there is nothing useful to show. */
|
|
21
|
+
readonly example: string;
|
|
22
|
+
}
|
|
23
|
+
export interface CompletionItem {
|
|
24
|
+
/** What the picker shows and filters on: a directive name, an attribute name, or an attribute value. */
|
|
25
|
+
readonly label: string;
|
|
26
|
+
readonly kind: CompletionItemKind;
|
|
27
|
+
/** One short line for the row's secondary text. May be empty. */
|
|
28
|
+
readonly detail: string;
|
|
29
|
+
/** Structured docs, when there are any. */
|
|
30
|
+
readonly documentation?: ComponentDocumentation;
|
|
31
|
+
/** The exact PLAIN text that replaces the context's range. Never a snippet, never host-specific syntax. */
|
|
32
|
+
readonly insertText: string;
|
|
33
|
+
/** Character offset within `insertText` where the cursor should land afterward. Same convention as `componentSkeleton`'s `cursorOffset`. */
|
|
34
|
+
readonly insertCursorOffset: number;
|
|
35
|
+
/** Present on a `component` item: which catalog group it came from, so a host can render its own origin tag. */
|
|
36
|
+
readonly group?: 'standard' | 'layout' | 'pack';
|
|
37
|
+
/** Present on a `component` item from a pack. */
|
|
38
|
+
readonly packName?: string;
|
|
39
|
+
}
|
|
40
|
+
export type CompletionContextKind = 'none' | 'directive-name' | 'attribute-name' | 'attribute-value';
|
|
41
|
+
export interface CompletionContext {
|
|
42
|
+
readonly kind: CompletionContextKind;
|
|
43
|
+
/**
|
|
44
|
+
* Zero-based column an accepted item's `insertText` replaces FROM. Equals
|
|
45
|
+
* `replaceEnd` when `kind` is `'none'`.
|
|
46
|
+
*
|
|
47
|
+
* `replaceStart` and `tokenStart` answer two different questions and a
|
|
48
|
+
* host that uses one for the other's job gets a working-looking popup
|
|
49
|
+
* that silently filters everything out (GitHub issue #50). `replaceStart`
|
|
50
|
+
* is the REPLACE-RANGE start: for a `'directive-name'` context on an
|
|
51
|
+
* otherwise-empty rest of the line, it sits at the start of the COLON RUN
|
|
52
|
+
* (`:::`), not at the directive name, because accepting an item there
|
|
53
|
+
* replaces the whole fence with the chosen component's skeleton. A host
|
|
54
|
+
* that also uses this value as the text a popup FILTERS candidates
|
|
55
|
+
* against (CodeMirror's `CompletionResult.from` is exactly that) ends up
|
|
56
|
+
* comparing the typed text `":::cal"` against labels like `"callout"`:
|
|
57
|
+
* the leading colons never match, and the popup opens empty. Use
|
|
58
|
+
* `tokenStart` for filtering; use `replaceStart` for the edit.
|
|
59
|
+
*/
|
|
60
|
+
readonly replaceStart: number;
|
|
61
|
+
/** Zero-based column the replacement ends at. */
|
|
62
|
+
readonly replaceEnd: number;
|
|
63
|
+
/**
|
|
64
|
+
* Zero-based column where the NAME token itself starts, i.e. `replaceStart`
|
|
65
|
+
* plus the colon run's length for a `'directive-name'` context (for
|
|
66
|
+
* `:::cal` with `replaceStart` 0, `tokenStart` is 3). This is the value a
|
|
67
|
+
* filtering completion host (one that scores candidates against the
|
|
68
|
+
* text between its own `from` and the cursor) should hand back as that
|
|
69
|
+
* `from`, since candidate labels never carry the leading colons.
|
|
70
|
+
* `undefined` for every other context kind, where `replaceStart` already
|
|
71
|
+
* sits at the token being replaced and there is nothing separate to name.
|
|
72
|
+
*/
|
|
73
|
+
readonly tokenStart?: number;
|
|
74
|
+
readonly items: readonly CompletionItem[];
|
|
75
|
+
}
|
|
76
|
+
export interface HoverInfo {
|
|
77
|
+
readonly directiveName: string;
|
|
78
|
+
readonly documentation: ComponentDocumentation;
|
|
79
|
+
/** Zero-based columns of the directive name token being hovered, for a host that highlights the range. */
|
|
80
|
+
readonly start: number;
|
|
81
|
+
readonly end: number;
|
|
82
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Directive autocompletion (GitHub issue #27, slice 1): the public types
|
|
3
|
+
* both `completionAt`/`hoverAt` (`./completion.ts`) and
|
|
4
|
+
* `componentDocumentation`/`formatComponentDocumentation`
|
|
5
|
+
* (`./documentation.ts`) are built against. Kept in their own module so
|
|
6
|
+
* neither of those implementation files has to import the other just to
|
|
7
|
+
* share a type — this is the one seam every editor host (a `@markii/*`
|
|
8
|
+
* host in this repo, or a third-party one importing `@markii/stdlib/editor`
|
|
9
|
+
* directly) codes against, so its names and shapes are a contract, not an
|
|
10
|
+
* implementation detail.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|