@markii/stdlib 0.12.1 → 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.
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Fence auto-extension on insert (editor quality of life, format
3
+ * untouched): the pure logic behind "the outer fence pair grows when a
4
+ * container is inserted inside another container".
5
+ *
6
+ * docs/format.md gives container directives a fence rule borrowed from
7
+ * fenced code: nesting works only when the OUTER pair carries more colons
8
+ * than the inner one, e.g. `::::center` around `:::narrow`. Written by
9
+ * hand that is a chore, and getting it wrong renders the note wrong. Both
10
+ * hosts therefore lengthen the enclosing fences for the author, in exactly
11
+ * two places: the Insert Component command, and accepting a container
12
+ * component from the completion popup. Never while typing.
13
+ *
14
+ * The posture here is conservative on purpose. This module rewrites text
15
+ * the author did not ask it to touch, so it only ever acts on a document
16
+ * whose container fences pair cleanly from the top of the file: every
17
+ * opener strictly longer than the opener it nests inside, every closer
18
+ * matching its opener's colon count exactly, nothing left dangling at the
19
+ * end. Anything else, including an unterminated code fence, returns "do
20
+ * not touch" and the insertion proceeds exactly as it would have before.
21
+ * A missed extension is a small annoyance; a wrong one silently changes
22
+ * how a note renders.
23
+ *
24
+ * Pure and host-neutral: no editor API, no filesystem, no `node:*`. It is
25
+ * re-exported from `@markii/host/browser`, and
26
+ * `apps/vscode/src/browser-entry.probe.test.ts` is the gate for that.
27
+ */
28
+ /** A container-directive fence pair enclosing an insertion point. */
29
+ export interface EnclosingContainerFence {
30
+ /** Zero-based line of the opening fence, always strictly above the insertion line. */
31
+ readonly openLine: number;
32
+ /** Zero-based line of the closing fence, always strictly below the insertion line. */
33
+ readonly closeLine: number;
34
+ /** How many colons the pair is written with today (the same count on both lines). */
35
+ readonly colonCount: number;
36
+ /** Zero-based column of the first colon on the opening line. */
37
+ readonly openColumn: number;
38
+ /** Zero-based column of the first colon on the closing line. */
39
+ readonly closeColumn: number;
40
+ /** The directive name on the opening fence, e.g. `center`. */
41
+ readonly directiveName: string;
42
+ }
43
+ /**
44
+ * One fence line to rewrite, as data rather than an editor edit: a host
45
+ * turns it into whatever its own API takes, and applies the whole set in
46
+ * ONE undoable edit together with the insertion itself.
47
+ *
48
+ * The span is deliberately just the colon run, not the whole line: fence
49
+ * extension only ever ADDS colons at the start of an existing run, so the
50
+ * narrowest possible range keeps the edit clear of a completion item's own
51
+ * replace range and stays indifferent to line endings and to whatever
52
+ * follows on the line (`{...}` attributes, trailing whitespace).
53
+ */
54
+ export interface FenceLineEdit {
55
+ /** Zero-based line to edit. */
56
+ readonly line: number;
57
+ /** Zero-based column where the colon run starts. */
58
+ readonly column: number;
59
+ /** The colon run as it stands today, e.g. `:::`. */
60
+ readonly oldText: string;
61
+ /** The lengthened colon run to put in its place, e.g. `::::`. */
62
+ readonly newText: string;
63
+ }
64
+ /**
65
+ * The container-directive fence pairs enclosing `insertionLine`, outermost
66
+ * first, or `undefined` for "do not touch".
67
+ *
68
+ * `undefined` is returned whenever pairing is anything but textbook: a
69
+ * closer with no opener, an opener left dangling at the end of the
70
+ * document, a closer whose colon count does not match its opener exactly,
71
+ * an opener that is not strictly shorter than the one it nests inside (the
72
+ * same-count case that does not actually nest), a `:::` run with no
73
+ * directive name, or an unterminated code fence.
74
+ *
75
+ * `insertionLine` itself is never read as a fence line: it is the line
76
+ * being edited, so mid-keystroke text on it (`:::car` on the way to
77
+ * `:::card`) would otherwise look like a dangling opener and suppress the
78
+ * very extension the author is about to need. It still advances the
79
+ * fenced-code state, so a cursor parked on a ``` line cannot desynchronize
80
+ * the scan.
81
+ */
82
+ export declare function enclosingContainerFences(documentText: string, insertionLine: number): readonly EnclosingContainerFence[] | undefined;
83
+ /**
84
+ * How many colons a piece of about-to-be-inserted text opens a container
85
+ * with, or `undefined` when it is not a container skeleton at all (a leaf
86
+ * or inline directive, or a bare directive name completing into an opener
87
+ * the author already typed). Both hosts hand this module the exact text
88
+ * they are about to insert, so nothing has to be re-derived from a
89
+ * component's kind.
90
+ */
91
+ export declare function insertedContainerColonCount(insertedText: string): number | undefined;
92
+ /**
93
+ * The minimal set of fence lines to lengthen so that inserting
94
+ * `insertedText` at `insertionLine` still nests legally, or an empty array
95
+ * when there is nothing to do (and for every "do not touch" case).
96
+ *
97
+ * Walking outward from the insertion point, each enclosing pair must end
98
+ * up strictly longer than the deepest thing now inside it. A pair that is
99
+ * already long enough is left alone, and stops nothing: the next pair out
100
+ * is measured against the count that pair actually ends up with, so
101
+ * lengthening cascades only as far as it has to.
102
+ *
103
+ * Edits are returned in document order and never overlap each other, and
104
+ * they are always on lines other than `insertionLine`, so a host can apply
105
+ * them alongside its own insertion (including a completion item's replace
106
+ * range) in one edit.
107
+ */
108
+ export declare function fenceExtensionEdits(documentText: string, insertionLine: number, insertedText: string): readonly FenceLineEdit[];
109
+ /**
110
+ * Whether `lineText` (the text of `documentText`'s line at `lineIndex`) is a
111
+ * bare colon run that CLOSES a container currently open above it, using the
112
+ * same textbook-pairing scan `enclosingContainerFences` uses (GitHub issue
113
+ * #57). A directive-name completion popup has no way on its own to tell a
114
+ * just-typed opener (`:::`, offer the catalog) from a pasted or typed
115
+ * CLOSING fence (`:::`, offer nothing) because both are the exact same bare
116
+ * shape; this predicate is the missing piece a completion trigger checks
117
+ * before opening the popup. `completionAt` itself is unaffected: it keeps
118
+ * offering the full catalog for a bare colon run, since it has no document
119
+ * context to tell the two apart.
120
+ *
121
+ * `false` for anything but a clean, currently-open match: a bare colon run
122
+ * with nothing open above it (a genuine opener, still worth completing), a
123
+ * colon run that also carries a directive name or other trailing content
124
+ * (`:::card`, never a closer), one whose count does not match the nearest
125
+ * open fence exactly, or a document whose fences do not pair cleanly above
126
+ * this line (an unterminated code fence, a dangling opener, an ambiguous
127
+ * fence) — the same "do not touch" conservatism `enclosingContainerFences`
128
+ * applies elsewhere in this module.
129
+ */
130
+ export declare function closesOpenContainerFence(documentText: string, lineIndex: number, lineText: string): boolean;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Fence auto-extension on insert (editor quality of life, format
3
+ * untouched): the pure logic behind "the outer fence pair grows when a
4
+ * container is inserted inside another container".
5
+ *
6
+ * docs/format.md gives container directives a fence rule borrowed from
7
+ * fenced code: nesting works only when the OUTER pair carries more colons
8
+ * than the inner one, e.g. `::::center` around `:::narrow`. Written by
9
+ * hand that is a chore, and getting it wrong renders the note wrong. Both
10
+ * hosts therefore lengthen the enclosing fences for the author, in exactly
11
+ * two places: the Insert Component command, and accepting a container
12
+ * component from the completion popup. Never while typing.
13
+ *
14
+ * The posture here is conservative on purpose. This module rewrites text
15
+ * the author did not ask it to touch, so it only ever acts on a document
16
+ * whose container fences pair cleanly from the top of the file: every
17
+ * opener strictly longer than the opener it nests inside, every closer
18
+ * matching its opener's colon count exactly, nothing left dangling at the
19
+ * end. Anything else, including an unterminated code fence, returns "do
20
+ * not touch" and the insertion proceeds exactly as it would have before.
21
+ * A missed extension is a small annoyance; a wrong one silently changes
22
+ * how a note renders.
23
+ *
24
+ * Pure and host-neutral: no editor API, no filesystem, no `node:*`. It is
25
+ * re-exported from `@markii/host/browser`, and
26
+ * `apps/vscode/src/browser-entry.probe.test.ts` is the gate for that.
27
+ */
28
+ /**
29
+ * A block directive's colon run may be indented, but four spaces make the
30
+ * line an indented code block in CommonMark, so it is not a fence at all.
31
+ * Matching only spaces (never a tab) keeps a tab-indented line out of the
32
+ * fence set entirely, which is the conservative side of that ambiguity.
33
+ */
34
+ const DIRECTIVE_FENCE_RE = /^( {0,3})(:{3,})(.*)$/;
35
+ /** The character class a directive name is written in, matching `@markii/pack`'s local-name rules and `./complete/directive-context.ts`'s `NAME_CHAR`. */
36
+ const DIRECTIVE_NAME_RE = /^[A-Za-z0-9_-]+/;
37
+ const CODE_FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
38
+ /**
39
+ * Classifies one line, already known to be outside a code fence, as a
40
+ * container-directive opener, a closer, something ambiguous, or (via
41
+ * `undefined`) not a fence line at all. Inline (`:name[...]`) and leaf
42
+ * (`::name`) directives never reach three colons, so they are simply not
43
+ * fence lines here.
44
+ */
45
+ function classifyFenceLine(text, line) {
46
+ const match = DIRECTIVE_FENCE_RE.exec(text);
47
+ if (!match)
48
+ return undefined;
49
+ const indent = match[1] ?? '';
50
+ const colons = match[2] ?? '';
51
+ const rest = match[3] ?? '';
52
+ const column = indent.length;
53
+ if (rest.trim() === '') {
54
+ return {
55
+ kind: 'close',
56
+ fence: { line, column, colonCount: colons.length },
57
+ };
58
+ }
59
+ const nameMatch = DIRECTIVE_NAME_RE.exec(rest);
60
+ if (!nameMatch)
61
+ return { kind: 'ambiguous' };
62
+ return {
63
+ kind: 'open',
64
+ fence: {
65
+ line,
66
+ column,
67
+ colonCount: colons.length,
68
+ directiveName: nameMatch[0],
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * Advances the fenced-code state by one line, and reports whether that
74
+ * line is code (or a fence delimiter) rather than something the directive
75
+ * scanner should look at. A ``` or ~~~ block containing `:::` lines is
76
+ * documentation ABOUT the format, not the format, and counting those
77
+ * fences would rewrite an example the author wrote deliberately.
78
+ */
79
+ function stepCodeFence(state, text) {
80
+ const match = CODE_FENCE_RE.exec(text);
81
+ if (state === undefined) {
82
+ if (!match)
83
+ return { state: undefined, isCode: false };
84
+ const run = match[2] ?? '';
85
+ const info = match[3] ?? '';
86
+ // A backtick fence's info string may not contain a backtick (CommonMark).
87
+ if (run.startsWith('`') && info.includes('`')) {
88
+ return { state: undefined, isCode: false };
89
+ }
90
+ return { state: { char: run[0] ?? '`', length: run.length }, isCode: true };
91
+ }
92
+ if (match) {
93
+ const run = match[2] ?? '';
94
+ const info = match[3] ?? '';
95
+ const closes = run[0] === state.char && run.length >= state.length && info.trim() === '';
96
+ if (closes)
97
+ return { state: undefined, isCode: true };
98
+ }
99
+ return { state, isCode: true };
100
+ }
101
+ /** Strips one trailing `\r`, so a CRLF document classifies (and reports columns) the same as an LF one. */
102
+ function stripCarriageReturn(line) {
103
+ return line.endsWith('\r') ? line.slice(0, -1) : line;
104
+ }
105
+ /**
106
+ * The container-directive fence pairs enclosing `insertionLine`, outermost
107
+ * first, or `undefined` for "do not touch".
108
+ *
109
+ * `undefined` is returned whenever pairing is anything but textbook: a
110
+ * closer with no opener, an opener left dangling at the end of the
111
+ * document, a closer whose colon count does not match its opener exactly,
112
+ * an opener that is not strictly shorter than the one it nests inside (the
113
+ * same-count case that does not actually nest), a `:::` run with no
114
+ * directive name, or an unterminated code fence.
115
+ *
116
+ * `insertionLine` itself is never read as a fence line: it is the line
117
+ * being edited, so mid-keystroke text on it (`:::car` on the way to
118
+ * `:::card`) would otherwise look like a dangling opener and suppress the
119
+ * very extension the author is about to need. It still advances the
120
+ * fenced-code state, so a cursor parked on a ``` line cannot desynchronize
121
+ * the scan.
122
+ */
123
+ export function enclosingContainerFences(documentText, insertionLine) {
124
+ if (typeof documentText !== 'string')
125
+ return undefined;
126
+ if (!Number.isInteger(insertionLine) || insertionLine < 0)
127
+ return undefined;
128
+ const lines = documentText.split('\n');
129
+ const stack = [];
130
+ const pairs = [];
131
+ let codeFence;
132
+ for (let line = 0; line < lines.length; line++) {
133
+ const text = stripCarriageReturn(lines[line] ?? '');
134
+ const stepped = stepCodeFence(codeFence, text);
135
+ codeFence = stepped.state;
136
+ if (stepped.isCode)
137
+ continue;
138
+ if (line === insertionLine)
139
+ continue;
140
+ const classified = classifyFenceLine(text, line);
141
+ if (classified === undefined)
142
+ continue;
143
+ if (classified.kind === 'ambiguous')
144
+ return undefined;
145
+ if (classified.kind === 'open') {
146
+ const parent = stack[stack.length - 1];
147
+ if (parent && classified.fence.colonCount >= parent.colonCount) {
148
+ return undefined;
149
+ }
150
+ stack.push(classified.fence);
151
+ continue;
152
+ }
153
+ const opener = stack.pop();
154
+ if (!opener)
155
+ return undefined;
156
+ if (opener.colonCount !== classified.fence.colonCount)
157
+ return undefined;
158
+ pairs.push({
159
+ openLine: opener.line,
160
+ closeLine: classified.fence.line,
161
+ colonCount: opener.colonCount,
162
+ openColumn: opener.column,
163
+ closeColumn: classified.fence.column,
164
+ directiveName: opener.directiveName,
165
+ });
166
+ }
167
+ if (stack.length > 0)
168
+ return undefined;
169
+ if (codeFence !== undefined)
170
+ return undefined;
171
+ return pairs
172
+ .filter((pair) => pair.openLine < insertionLine && pair.closeLine > insertionLine)
173
+ .sort((a, b) => a.openLine - b.openLine);
174
+ }
175
+ /**
176
+ * How many colons a piece of about-to-be-inserted text opens a container
177
+ * with, or `undefined` when it is not a container skeleton at all (a leaf
178
+ * or inline directive, or a bare directive name completing into an opener
179
+ * the author already typed). Both hosts hand this module the exact text
180
+ * they are about to insert, so nothing has to be re-derived from a
181
+ * component's kind.
182
+ */
183
+ export function insertedContainerColonCount(insertedText) {
184
+ if (typeof insertedText !== 'string')
185
+ return undefined;
186
+ const match = /^(:{3,})[A-Za-z0-9_-]/.exec(insertedText);
187
+ if (!match)
188
+ return undefined;
189
+ const run = match[1] ?? '';
190
+ // `componentSkeleton`'s container form closes with the same run on its
191
+ // own last line. Anything else is not a skeleton this module built.
192
+ return insertedText.endsWith(`\n${run}`) ? run.length : undefined;
193
+ }
194
+ /**
195
+ * The minimal set of fence lines to lengthen so that inserting
196
+ * `insertedText` at `insertionLine` still nests legally, or an empty array
197
+ * when there is nothing to do (and for every "do not touch" case).
198
+ *
199
+ * Walking outward from the insertion point, each enclosing pair must end
200
+ * up strictly longer than the deepest thing now inside it. A pair that is
201
+ * already long enough is left alone, and stops nothing: the next pair out
202
+ * is measured against the count that pair actually ends up with, so
203
+ * lengthening cascades only as far as it has to.
204
+ *
205
+ * Edits are returned in document order and never overlap each other, and
206
+ * they are always on lines other than `insertionLine`, so a host can apply
207
+ * them alongside its own insertion (including a completion item's replace
208
+ * range) in one edit.
209
+ */
210
+ export function fenceExtensionEdits(documentText, insertionLine, insertedText) {
211
+ const insertedColons = insertedContainerColonCount(insertedText);
212
+ if (insertedColons === undefined)
213
+ return [];
214
+ const enclosing = enclosingContainerFences(documentText, insertionLine);
215
+ if (enclosing === undefined || enclosing.length === 0)
216
+ return [];
217
+ const edits = [];
218
+ let deepestInside = insertedColons;
219
+ for (let i = enclosing.length - 1; i >= 0; i--) {
220
+ const pair = enclosing[i];
221
+ if (!pair)
222
+ continue;
223
+ const nextCount = Math.max(pair.colonCount, deepestInside + 1);
224
+ if (nextCount !== pair.colonCount) {
225
+ const oldText = ':'.repeat(pair.colonCount);
226
+ const newText = ':'.repeat(nextCount);
227
+ edits.push({
228
+ line: pair.openLine,
229
+ column: pair.openColumn,
230
+ oldText,
231
+ newText,
232
+ });
233
+ edits.push({
234
+ line: pair.closeLine,
235
+ column: pair.closeColumn,
236
+ oldText,
237
+ newText,
238
+ });
239
+ }
240
+ deepestInside = nextCount;
241
+ }
242
+ return edits.sort((a, b) => a.line - b.line);
243
+ }
244
+ /**
245
+ * Whether `lineText` (the text of `documentText`'s line at `lineIndex`) is a
246
+ * bare colon run that CLOSES a container currently open above it, using the
247
+ * same textbook-pairing scan `enclosingContainerFences` uses (GitHub issue
248
+ * #57). A directive-name completion popup has no way on its own to tell a
249
+ * just-typed opener (`:::`, offer the catalog) from a pasted or typed
250
+ * CLOSING fence (`:::`, offer nothing) because both are the exact same bare
251
+ * shape; this predicate is the missing piece a completion trigger checks
252
+ * before opening the popup. `completionAt` itself is unaffected: it keeps
253
+ * offering the full catalog for a bare colon run, since it has no document
254
+ * context to tell the two apart.
255
+ *
256
+ * `false` for anything but a clean, currently-open match: a bare colon run
257
+ * with nothing open above it (a genuine opener, still worth completing), a
258
+ * colon run that also carries a directive name or other trailing content
259
+ * (`:::card`, never a closer), one whose count does not match the nearest
260
+ * open fence exactly, or a document whose fences do not pair cleanly above
261
+ * this line (an unterminated code fence, a dangling opener, an ambiguous
262
+ * fence) — the same "do not touch" conservatism `enclosingContainerFences`
263
+ * applies elsewhere in this module.
264
+ */
265
+ export function closesOpenContainerFence(documentText, lineIndex, lineText) {
266
+ if (typeof documentText !== 'string' || typeof lineText !== 'string') {
267
+ return false;
268
+ }
269
+ if (!Number.isInteger(lineIndex) || lineIndex < 0)
270
+ return false;
271
+ const classified = classifyFenceLine(stripCarriageReturn(lineText), lineIndex);
272
+ if (classified === undefined || classified.kind !== 'close')
273
+ return false;
274
+ const lines = documentText.split('\n');
275
+ const stack = [];
276
+ let codeFence;
277
+ for (let line = 0; line < lineIndex && line < lines.length; line++) {
278
+ const text = stripCarriageReturn(lines[line] ?? '');
279
+ const stepped = stepCodeFence(codeFence, text);
280
+ codeFence = stepped.state;
281
+ if (stepped.isCode)
282
+ continue;
283
+ const classifiedAbove = classifyFenceLine(text, line);
284
+ if (classifiedAbove === undefined)
285
+ continue;
286
+ if (classifiedAbove.kind === 'ambiguous')
287
+ return false;
288
+ if (classifiedAbove.kind === 'open') {
289
+ const parent = stack[stack.length - 1];
290
+ if (parent && classifiedAbove.fence.colonCount >= parent.colonCount) {
291
+ return false;
292
+ }
293
+ stack.push(classifiedAbove.fence);
294
+ continue;
295
+ }
296
+ const opener = stack.pop();
297
+ if (!opener)
298
+ return false;
299
+ if (opener.colonCount !== classifiedAbove.fence.colonCount)
300
+ return false;
301
+ }
302
+ if (codeFence !== undefined)
303
+ return false;
304
+ const top = stack[stack.length - 1];
305
+ return top !== undefined && top.colonCount === classified.fence.colonCount;
306
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * `@markii/stdlib/editor`: the pure, host-neutral editor helpers every
3
+ * Markii editor integration (VS Code, Obsidian, or a third-party host) is
4
+ * built on — directive completion and hover, the "Insert Component"
5
+ * skeleton builder and standard-set catalog, and fence auto-extension.
6
+ *
7
+ * This subpath, not `@markii/core`, is the home for these helpers because
8
+ * every one of them reads `@markii/stdlib` contract data (component kinds,
9
+ * required attributes, the layout vocabulary) and none of them touches
10
+ * `@markii/core`'s parser at all. Putting them on `@markii/core` would give
11
+ * the component-agnostic parser a dependency on component contracts, which
12
+ * is against architecture rule 1's intent (AGENTS.md); `@markii/stdlib` is
13
+ * already the zero-dependency contract seam every renderer implements
14
+ * against, so it is the natural home for the matching editor seam too.
15
+ *
16
+ * A pack-aware host composes a full catalog by appending its own pack
17
+ * entries after `standardComponentCatalog()`'s standard-set list (see
18
+ * `@markii/host`'s `insert/component-catalog.ts` for the reference
19
+ * composition); nothing in this subpath imports `@markii/pack`, so a host
20
+ * with no packs at all pays for none of it.
21
+ */
22
+ export type { CompletionContext, CompletionContextKind, CompletionItem, CompletionItemKind, ComponentDocumentation, HoverInfo, } from './complete/types.js';
23
+ export { completionAt, componentDocumentation, formatComponentDocumentation, hoverAt, } from './complete/completion.js';
24
+ export type { AttributeNameParseResult, AttributeValueParseResult, DirectiveForm, DirectiveNameParseResult, DirectiveNameToken, ParsedCompletionContext, } from './complete/directive-context.js';
25
+ export { clampColumn, findDirectiveNameTokenAt, parseCompletionContext, } from './complete/directive-context.js';
26
+ export type { EditorComponentAttribute, InsertableComponent, } from './insert/component-catalog.js';
27
+ export { LAYOUT_WRAPPER_NAMES, standardComponentCatalog, } from './insert/component-catalog.js';
28
+ export type { ComponentSkeleton, LineColumn, } from './insert/component-skeleton.js';
29
+ export { componentSkeleton, offsetToLineColumn, } from './insert/component-skeleton.js';
30
+ export { firstSentence } from './insert/first-sentence.js';
31
+ export type { EnclosingContainerFence, FenceLineEdit, } from './fences/container-fences.js';
32
+ export { closesOpenContainerFence, enclosingContainerFences, fenceExtensionEdits, insertedContainerColonCount, } from './fences/container-fences.js';
@@ -0,0 +1,6 @@
1
+ export { completionAt, componentDocumentation, formatComponentDocumentation, hoverAt, } from './complete/completion.js';
2
+ export { clampColumn, findDirectiveNameTokenAt, parseCompletionContext, } from './complete/directive-context.js';
3
+ export { LAYOUT_WRAPPER_NAMES, standardComponentCatalog, } from './insert/component-catalog.js';
4
+ export { componentSkeleton, offsetToLineColumn, } from './insert/component-skeleton.js';
5
+ export { firstSentence } from './insert/first-sentence.js';
6
+ export { closesOpenContainerFence, enclosingContainerFences, fenceExtensionEdits, insertedContainerColonCount, } from './fences/container-fences.js';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * "Insert Component" (GitHub issue #17, slice 1): the standard-set half of
3
+ * the insert catalog — every `@markii/stdlib` standard component, split
4
+ * into its ordinary and layout-wrapper groups. Host-neutral and pure: no
5
+ * `vscode`, no `obsidian`, no filesystem access. This package has zero
6
+ * dependencies, so a pack's own contribution to the catalog (which needs
7
+ * `@markii/pack`'s manifest types) is composed one layer up, by a host
8
+ * (`@markii/host`'s `insert/component-catalog.ts`), which calls
9
+ * `standardComponentCatalog` below and appends its own pack entries after
10
+ * it.
11
+ */
12
+ import type { ComponentKind } from '../../contracts.js';
13
+ /**
14
+ * The seven layout-wrapper container directive names (docs/format.md):
15
+ * alignment (`center`, `left`, `right`) and width presets (`fit`, `narrow`,
16
+ * `wide`, `full`). Exported so a picker UI can render this group under its
17
+ * own heading without hand-copying the name list, and so the colocated test
18
+ * below can assert it stays in sync with `STANDARD_COMPONENTS`.
19
+ */
20
+ export declare const LAYOUT_WRAPPER_NAMES: readonly string[];
21
+ /**
22
+ * The declared shape of one attribute a discovered component contributes,
23
+ * structurally identical to `@markii/pack`'s `PackComponentAttribute` so
24
+ * that a real `PackComponentAttribute` value remains assignable here
25
+ * without this package depending on `@markii/pack` (this package has zero
26
+ * dependencies by design; only a host composing a pack-aware catalog needs
27
+ * the real pack types).
28
+ */
29
+ export interface EditorComponentAttribute {
30
+ /** The name an author writes inside `{...}`, e.g. `from`. */
31
+ readonly name: string;
32
+ /** Human-readable semantics, shown in a completion row and in hover documentation. */
33
+ readonly description?: string;
34
+ /** `true` when the component is incomplete without this attribute. A required attribute is pre-filled by the insert skeleton. */
35
+ readonly required?: boolean;
36
+ /** The closed set of allowed values, when the attribute is an enum. */
37
+ readonly values?: readonly string[];
38
+ /** The value used when the attribute is absent. */
39
+ readonly default?: string;
40
+ }
41
+ /** One component a picker can offer to insert. */
42
+ export interface InsertableComponent {
43
+ /** The directive name the author types, e.g. `callout`, or `cat_card` for a pack component. */
44
+ readonly directiveName: string;
45
+ readonly kind: ComponentKind;
46
+ readonly source: 'standard' | 'pack';
47
+ /**
48
+ * Which section of a picker this entry belongs in: the ordinary standard
49
+ * set, the layout-wrapper set (also `source: 'standard'`, since a layout
50
+ * wrapper IS a standard component — `group` is the finer split a picker
51
+ * UI wants), or a pack's own contribution.
52
+ */
53
+ readonly group: 'standard' | 'layout' | 'pack';
54
+ /** Set only when `source === 'pack'`: the owning pack's namespace. */
55
+ readonly packName?: string;
56
+ /**
57
+ * A short, one-line detail for a picker row, when one is available. RAW
58
+ * material only: a standard component's contract first sentence, or a
59
+ * pack component's manifest-declared `description` — never a composed
60
+ * filler string like `From pack "x".`. A host that wants a fallback line
61
+ * for a pack component with no declared description writes that string
62
+ * itself, in its own wording module (AGENTS.md: hosts own their
63
+ * user-facing strings).
64
+ */
65
+ readonly description?: string;
66
+ /**
67
+ * Required attribute names, in declaration order: a standard component's
68
+ * contract order, or a pack component's own `attributes` order for the
69
+ * entries it marked `required`. Empty when nothing is required, which is
70
+ * still the case for every pack that declares no attributes at all.
71
+ */
72
+ readonly requiredAttributes: readonly string[];
73
+ /**
74
+ * A pack component's declared attribute metadata (issue #27 slice 4),
75
+ * in the order the manifest declared it. Absent for a standard
76
+ * component, whose attributes come from its `@markii/stdlib` contract
77
+ * instead, and absent for a pack component that declared none.
78
+ */
79
+ readonly attributes?: readonly EditorComponentAttribute[];
80
+ /**
81
+ * True when `kind` came from the component's own declaration (every
82
+ * standard component, and a pack component whose manifest entry
83
+ * declares `kind`); false when `kind` is the pack-aware catalog's
84
+ * `'container'` default for a pack component that declared none.
85
+ */
86
+ readonly kindDeclared: boolean;
87
+ }
88
+ /**
89
+ * The standard-set half of the insert catalog: every non-layout standard
90
+ * component first (declaration order), then the seven layout wrappers
91
+ * (declaration order). A host composing a pack-aware catalog appends its
92
+ * own pack entries after this, using the returned list's directive names
93
+ * as the "already taken" set a pack component must not collide with.
94
+ */
95
+ export declare function standardComponentCatalog(): readonly InsertableComponent[];
@@ -0,0 +1,51 @@
1
+ import { STANDARD_COMPONENTS } from '../../contracts.js';
2
+ import { firstSentence } from './first-sentence.js';
3
+ /**
4
+ * The seven layout-wrapper container directive names (docs/format.md):
5
+ * alignment (`center`, `left`, `right`) and width presets (`fit`, `narrow`,
6
+ * `wide`, `full`). Exported so a picker UI can render this group under its
7
+ * own heading without hand-copying the name list, and so the colocated test
8
+ * below can assert it stays in sync with `STANDARD_COMPONENTS`.
9
+ */
10
+ export const LAYOUT_WRAPPER_NAMES = [
11
+ 'center',
12
+ 'left',
13
+ 'right',
14
+ 'wide',
15
+ 'narrow',
16
+ 'full',
17
+ 'fit',
18
+ ];
19
+ /** Required attribute names off a contract's `attributes` map, in the map's own key order. */
20
+ function requiredAttributeNames(attributes) {
21
+ return Object.keys(attributes).filter((name) => attributes[name]?.required === true);
22
+ }
23
+ const LAYOUT_WRAPPER_NAME_SET = new Set(LAYOUT_WRAPPER_NAMES);
24
+ /**
25
+ * Every standard component (including the layout wrappers), in
26
+ * `STANDARD_COMPONENTS`'s own declaration order.
27
+ */
28
+ function standardCatalogEntries() {
29
+ return Object.entries(STANDARD_COMPONENTS).map(([name, contract]) => ({
30
+ directiveName: name,
31
+ kind: contract.kind,
32
+ source: 'standard',
33
+ group: LAYOUT_WRAPPER_NAME_SET.has(name) ? 'layout' : 'standard',
34
+ description: firstSentence(contract.description),
35
+ requiredAttributes: requiredAttributeNames(contract.attributes),
36
+ kindDeclared: true,
37
+ }));
38
+ }
39
+ /**
40
+ * The standard-set half of the insert catalog: every non-layout standard
41
+ * component first (declaration order), then the seven layout wrappers
42
+ * (declaration order). A host composing a pack-aware catalog appends its
43
+ * own pack entries after this, using the returned list's directive names
44
+ * as the "already taken" set a pack component must not collide with.
45
+ */
46
+ export function standardComponentCatalog() {
47
+ const standardAll = standardCatalogEntries();
48
+ const standard = standardAll.filter((entry) => entry.group === 'standard');
49
+ const layout = standardAll.filter((entry) => entry.group === 'layout');
50
+ return [...standard, ...layout];
51
+ }