@markii/stdlib 0.17.0-next.4 → 0.17.0-next.6

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.
@@ -33,17 +33,21 @@
33
33
  * the fallback box, labeled as a missing view rather than an unknown
34
34
  * component. Only reported when the host passed `unavailableComponents`.
35
35
  *
36
- * `'unknown-directive'` — a directive names no registered component and
37
- * rendered as the unknown-component fallback box. Reported by the terminal
38
- * engine only, so a host that wants a finding for every unknown directive
39
- * (the language server) can place it; the other engines draw the same
40
- * fallback and stay quiet.
36
+ * `'unknown-directive'` — a directive names no known component. Reported
37
+ * only by `validateNote`, for a host that places findings in the note's
38
+ * source (the language server). The renderers draw the unknown-component
39
+ * fallback box and report nothing for it.
41
40
  *
42
41
  * `'code-renderer-failed'` — a host's `renderCode` callback threw for an
43
42
  * ordinary fenced code block; the block rendered as usual. Carries no
44
43
  * directive or attribute.
44
+ *
45
+ * `'empty-inline'`: an inline component registered as inline received
46
+ * neither content nor attributes (`:badge[]`); it rendered as registered,
47
+ * with a quiet marker. A component given attributes, such as
48
+ * `:badge{variant=info}`, draws from them and is not flagged.
45
49
  */
46
- export type DiagnosticKind = 'invalid-attribute-value' | 'unsafe-image-src' | 'form-mismatch' | 'no-engine-view' | 'unknown-directive' | 'code-renderer-failed';
50
+ export type DiagnosticKind = 'invalid-attribute-value' | 'unsafe-image-src' | 'form-mismatch' | 'no-engine-view' | 'unknown-directive' | 'code-renderer-failed' | 'empty-inline';
47
51
  /**
48
52
  * Where in the note's source an event happened: the directive's range, in
49
53
  * unist coordinates (1-based line, 1-based column, end exclusive). Present
@@ -21,12 +21,9 @@
21
21
  */
22
22
  export type { CompletionContext, CompletionContextKind, CompletionItem, CompletionItemKind, ComponentDocumentation, HoverInfo, } from './complete/types.js';
23
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
24
  export type { EditorComponentAttribute, InsertableComponent, } from './insert/component-catalog.js';
27
25
  export { LAYOUT_WRAPPER_NAMES, standardComponentCatalog, } from './insert/component-catalog.js';
28
26
  export type { ComponentSkeleton, LineColumn, } from './insert/component-skeleton.js';
29
27
  export { componentSkeleton, offsetToLineColumn, } from './insert/component-skeleton.js';
30
- export { firstSentence } from './insert/first-sentence.js';
31
28
  export type { EnclosingContainerFence, FenceLineEdit, } from './fences/container-fences.js';
32
29
  export { closesOpenContainerFence, enclosingContainerFences, fenceExtensionEdits, insertedContainerColonCount, } from './fences/container-fences.js';
@@ -1,6 +1,4 @@
1
1
  export { completionAt, componentDocumentation, formatComponentDocumentation, hoverAt, } from './complete/completion.js';
2
- export { clampColumn, findDirectiveNameTokenAt, parseCompletionContext, } from './complete/directive-context.js';
3
2
  export { LAYOUT_WRAPPER_NAMES, standardComponentCatalog, } from './insert/component-catalog.js';
4
3
  export { componentSkeleton, offsetToLineColumn, } from './insert/component-skeleton.js';
5
- export { firstSentence } from './insert/first-sentence.js';
6
4
  export { closesOpenContainerFence, enclosingContainerFences, fenceExtensionEdits, insertedContainerColonCount, } from './fences/container-fences.js';
package/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ export { formatValue, isNumericLike, FORMAT_ATTRIBUTE, DECIMALS_ATTRIBUTE, type
5
5
  export { deriveTableShape, type TableShape } from './table-shape.js';
6
6
  export { reportDiagnostic, createUnavailableComponentLookup, type UnavailableComponent, type UnavailableComponentLookup, type DiagnosticEvent, type DiagnosticPosition, type DiagnosticKind, type OnDiagnostic, } from './diagnostics.js';
7
7
  export { INTERACTIVE_ATTRIBUTE } from './interactive.js';
8
+ export { buildNoteCatalog, formMismatchMessage, invalidAttributeValueMessage, invalidEnumAttribute, standardNoteCatalog, unknownDirectiveMessage, unsafeImageSrcMessage, validateNote, type NoteCatalogAttribute, type NoteCatalogEntry, type NoteTreeNode, type ValidateNoteOptions, } from './validate-note.js';
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ export { formatValue, isNumericLike, FORMAT_ATTRIBUTE, DECIMALS_ATTRIBUTE, } fro
5
5
  export { deriveTableShape } from './table-shape.js';
6
6
  export { reportDiagnostic, createUnavailableComponentLookup, } from './diagnostics.js';
7
7
  export { INTERACTIVE_ATTRIBUTE } from './interactive.js';
8
+ export { buildNoteCatalog, formMismatchMessage, invalidAttributeValueMessage, invalidEnumAttribute, standardNoteCatalog, unknownDirectiveMessage, unsafeImageSrcMessage, validateNote, } from './validate-note.js';
@@ -0,0 +1,96 @@
1
+ import { type ComponentKind } from './contracts.js';
2
+ import type { DiagnosticEvent } from './diagnostics.js';
3
+ /**
4
+ * The part of an mdast node `validateNote` reads. Structural on purpose, so
5
+ * this package does not import `@markii/core` or `mdast`: the tree
6
+ * `parse` returns is assignable to it.
7
+ */
8
+ export interface NoteTreeNode {
9
+ readonly type: string;
10
+ readonly name?: string;
11
+ readonly attributes?: Readonly<Record<string, string | null | undefined>> | null;
12
+ readonly children?: readonly NoteTreeNode[];
13
+ readonly position?: {
14
+ readonly start: {
15
+ readonly line: number;
16
+ readonly column: number;
17
+ };
18
+ readonly end: {
19
+ readonly line: number;
20
+ readonly column: number;
21
+ };
22
+ };
23
+ }
24
+ /** One attribute a catalog entry declares; the same shape as `EditorComponentAttribute`. */
25
+ export interface NoteCatalogAttribute {
26
+ readonly name: string;
27
+ /** The closed set of allowed values, when the attribute is an enum. */
28
+ readonly values?: readonly string[];
29
+ }
30
+ /**
31
+ * One component a note may use. `InsertableComponent` (the editor
32
+ * catalog's entry, which `@markii/host` builds from each pack's
33
+ * `pack.json`) is assignable to it, so a host reuses that catalog.
34
+ */
35
+ export interface NoteCatalogEntry {
36
+ /** The directive name an author writes, e.g. `callout` or `cat_card`. */
37
+ readonly directiveName: string;
38
+ readonly kind: ComponentKind;
39
+ /** False when `kind` is only a default (a pack component that declared none); no form check is made then. Omitted means `true`. */
40
+ readonly kindDeclared?: boolean;
41
+ readonly attributes?: readonly NoteCatalogAttribute[];
42
+ }
43
+ /** The standard set as catalog entries, with each enum attribute's allowed values. */
44
+ export declare function standardNoteCatalog(): readonly NoteCatalogEntry[];
45
+ /**
46
+ * The standard set followed by `extra` entries (a pack's components). An
47
+ * extra entry whose name is already taken is skipped, so the first claim on
48
+ * a name wins, as it does in the editor catalog.
49
+ */
50
+ export declare function buildNoteCatalog(extra: readonly NoteCatalogEntry[]): readonly NoteCatalogEntry[];
51
+ type DirectiveAttributes = Readonly<Record<string, string | null | undefined>>;
52
+ /**
53
+ * Whether one of the standard component `name`'s contract attributes with a
54
+ * closed enum is present in `attributes` with a value outside it, such as
55
+ * `card{text="Hey"}`. Returns the first offending pair, or `undefined` when
56
+ * `name` is not a standard component or nothing is out of range. The
57
+ * renderers call this on the attributes a component received, after the
58
+ * reserved layout keys were removed.
59
+ */
60
+ export declare function invalidEnumAttribute(name: string, attributes: DirectiveAttributes): {
61
+ attribute: string;
62
+ value: string;
63
+ } | undefined;
64
+ /** The diagnostic text for a directive that names no known component. */
65
+ export declare function unknownDirectiveMessage(directive: string): string;
66
+ /** The diagnostic text for a block component written as an inline directive. */
67
+ export declare function formMismatchMessage(directive: string): string;
68
+ /** The diagnostic text for an attribute value outside its closed set. */
69
+ export declare function invalidAttributeValueMessage(directive: string, attribute: string, value: string): string;
70
+ /** The diagnostic text for a component-built image whose source was refused as an unsafe URL. */
71
+ export declare function unsafeImageSrcMessage(directive: string): string;
72
+ /** What a caller may pass to `validateNote`. */
73
+ export interface ValidateNoteOptions {
74
+ /**
75
+ * The URL scheme rule, normally `@markii/core`'s `isSafeUrl`. Given, a
76
+ * `figure` whose `src` it refuses yields an `'unsafe-image-src'`
77
+ * finding. Omitted, no source is checked. Only the scheme is judged: a
78
+ * relative path a host resolves is never a finding here.
79
+ */
80
+ readonly isSafeUrl?: (url: string) => boolean;
81
+ }
82
+ /**
83
+ * The findings in a note that need no renderer, in document order, each
84
+ * carrying the source range of its directive. Never throws: a node of an
85
+ * unexpected shape is skipped.
86
+ *
87
+ * A directive resolves against `catalog` by exact name. `:value[...]` is
88
+ * built into every renderer and is never reported. A form mismatch is
89
+ * reported only for an inline directive naming a catalog entry whose kind
90
+ * is declared and is not `inline`; the reverse is not a finding, since a
91
+ * renderer draws it. Attribute enums are checked on the attributes left
92
+ * after the reserved `width` and `align` keys are removed, as a renderer
93
+ * sees them.
94
+ */
95
+ export declare function validateNote(tree: NoteTreeNode, catalog: readonly NoteCatalogEntry[], options?: ValidateNoteOptions): DiagnosticEvent[];
96
+ export {};
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The one engine-independent check of a parsed note: what is wrong with its
3
+ * directives no matter which renderer draws them. A host that places
4
+ * findings in the note's source (a language server) calls `validateNote`
5
+ * with the tree `@markii/core`'s `parse` returns and a catalog of the
6
+ * components the note may use. Nothing here renders, runs a script, or
7
+ * loads pack code, and the module imports no parser: the tree is input.
8
+ *
9
+ * Three kinds of finding need no engine and are reported here:
10
+ * `'unknown-directive'` (a name no catalog entry matches),
11
+ * `'form-mismatch'` (a block component written inline), and
12
+ * `'invalid-attribute-value'` (a value outside a declared enum). The
13
+ * renderers report the last two themselves when they draw a note, through
14
+ * the same predicates (`invalidEnumAttribute` below). A fourth,
15
+ * `'unsafe-image-src'`, is reported only when the caller passes
16
+ * `isSafeUrl`, because the URL scheme rule belongs to `@markii/core`,
17
+ * which this package never imports. Engine-specific findings
18
+ * (`'no-engine-view'`, `'code-renderer-failed'`) stay in the engines.
19
+ *
20
+ * The messages are diagnostics-surface text. They match the wording each
21
+ * engine's failure-presentation module uses for the same finding, and
22
+ * each engine's drift test pins that.
23
+ */
24
+ import { LAYOUT_ATTRIBUTE_KEYS } from './layout.js';
25
+ import { getContract, STANDARD_COMPONENTS, } from './contracts.js';
26
+ /** The directive name that no catalog needs: `:value[...]` is built into every renderer. */
27
+ const VALUE_DIRECTIVE_NAME = 'value';
28
+ /** The standard component whose `src` is a URL the renderers check. */
29
+ const FIGURE_DIRECTIVE_NAME = 'figure';
30
+ const DIRECTIVE_NODE_TYPES = new Set([
31
+ 'textDirective',
32
+ 'leafDirective',
33
+ 'containerDirective',
34
+ ]);
35
+ /** The standard set as catalog entries, with each enum attribute's allowed values. */
36
+ export function standardNoteCatalog() {
37
+ return Object.values(STANDARD_COMPONENTS).map((contract) => ({
38
+ directiveName: contract.name,
39
+ kind: contract.kind,
40
+ kindDeclared: true,
41
+ attributes: Object.entries(contract.attributes).map(([name, schema]) => ({
42
+ name,
43
+ ...(schema.enum !== undefined ? { values: schema.enum } : {}),
44
+ })),
45
+ }));
46
+ }
47
+ /**
48
+ * The standard set followed by `extra` entries (a pack's components). An
49
+ * extra entry whose name is already taken is skipped, so the first claim on
50
+ * a name wins, as it does in the editor catalog.
51
+ */
52
+ export function buildNoteCatalog(extra) {
53
+ const catalog = [...standardNoteCatalog()];
54
+ const taken = new Set(catalog.map((entry) => entry.directiveName));
55
+ for (const entry of extra) {
56
+ if (taken.has(entry.directiveName))
57
+ continue;
58
+ taken.add(entry.directiveName);
59
+ catalog.push(entry);
60
+ }
61
+ return catalog;
62
+ }
63
+ /**
64
+ * The first attribute in `declared` that is written with a value outside its
65
+ * closed set. An absent, bare, or empty value is never a violation. Shared
66
+ * by `invalidEnumAttribute` and `validateNote`, so the predicate has one
67
+ * home.
68
+ */
69
+ function firstInvalidEnum(declared, attributes) {
70
+ for (const [attribute, values] of declared) {
71
+ if (!values)
72
+ continue;
73
+ if (!Object.hasOwn(attributes, attribute))
74
+ continue;
75
+ const value = attributes[attribute];
76
+ if (value === undefined || value === null || value === '')
77
+ continue;
78
+ if (!values.includes(value))
79
+ return { attribute, value };
80
+ }
81
+ return undefined;
82
+ }
83
+ /**
84
+ * Whether one of the standard component `name`'s contract attributes with a
85
+ * closed enum is present in `attributes` with a value outside it, such as
86
+ * `card{text="Hey"}`. Returns the first offending pair, or `undefined` when
87
+ * `name` is not a standard component or nothing is out of range. The
88
+ * renderers call this on the attributes a component received, after the
89
+ * reserved layout keys were removed.
90
+ */
91
+ export function invalidEnumAttribute(name, attributes) {
92
+ const contract = getContract(name);
93
+ if (!contract)
94
+ return undefined;
95
+ return firstInvalidEnum(Object.entries(contract.attributes).map(([attribute, schema]) => [attribute, schema.enum]), attributes);
96
+ }
97
+ /** The diagnostic text for a directive that names no known component. */
98
+ export function unknownDirectiveMessage(directive) {
99
+ return `${directive}: unknown component, shown as a placeholder`;
100
+ }
101
+ /** The diagnostic text for a block component written as an inline directive. */
102
+ export function formMismatchMessage(directive) {
103
+ return `${directive}: block component was written inline and was not rendered`;
104
+ }
105
+ /** The diagnostic text for an attribute value outside its closed set. */
106
+ export function invalidAttributeValueMessage(directive, attribute, value) {
107
+ return `${directive}: "${value}" is not a valid ${attribute} value (ignored)`;
108
+ }
109
+ /** The diagnostic text for a component-built image whose source was refused as an unsafe URL. */
110
+ export function unsafeImageSrcMessage(directive) {
111
+ return `${directive}: image source was refused as unsafe and was not shown`;
112
+ }
113
+ function positionOf(node) {
114
+ const range = node.position;
115
+ if (range === undefined)
116
+ return undefined;
117
+ return {
118
+ startLine: range.start.line,
119
+ startColumn: range.start.column,
120
+ endLine: range.end.line,
121
+ endColumn: range.end.column,
122
+ };
123
+ }
124
+ function stripLayoutKeys(attributes) {
125
+ const rest = {};
126
+ for (const [key, value] of Object.entries(attributes)) {
127
+ if (LAYOUT_ATTRIBUTE_KEYS.includes(key))
128
+ continue;
129
+ rest[key] = value;
130
+ }
131
+ return rest;
132
+ }
133
+ /**
134
+ * The findings in a note that need no renderer, in document order, each
135
+ * carrying the source range of its directive. Never throws: a node of an
136
+ * unexpected shape is skipped.
137
+ *
138
+ * A directive resolves against `catalog` by exact name. `:value[...]` is
139
+ * built into every renderer and is never reported. A form mismatch is
140
+ * reported only for an inline directive naming a catalog entry whose kind
141
+ * is declared and is not `inline`; the reverse is not a finding, since a
142
+ * renderer draws it. Attribute enums are checked on the attributes left
143
+ * after the reserved `width` and `align` keys are removed, as a renderer
144
+ * sees them.
145
+ */
146
+ export function validateNote(tree, catalog, options = {}) {
147
+ const isSafeUrl = options.isSafeUrl;
148
+ const byName = new Map();
149
+ for (const entry of catalog) {
150
+ if (!byName.has(entry.directiveName))
151
+ byName.set(entry.directiveName, entry);
152
+ }
153
+ const events = [];
154
+ const visit = (node) => {
155
+ if (node === null || typeof node !== 'object')
156
+ return;
157
+ if (typeof node.type === 'string' &&
158
+ DIRECTIVE_NODE_TYPES.has(node.type) &&
159
+ node.name !== VALUE_DIRECTIVE_NAME) {
160
+ check(node);
161
+ }
162
+ if (Array.isArray(node.children)) {
163
+ for (const child of node.children)
164
+ visit(child);
165
+ }
166
+ };
167
+ const check = (node) => {
168
+ const name = typeof node.name === 'string' && node.name !== '' ? node.name : '';
169
+ const position = positionOf(node);
170
+ const at = position === undefined ? {} : { position };
171
+ const entry = name === '' ? undefined : byName.get(name);
172
+ if (entry === undefined) {
173
+ const shown = name || '(unnamed)';
174
+ events.push({
175
+ kind: 'unknown-directive',
176
+ directive: shown,
177
+ message: unknownDirectiveMessage(shown),
178
+ ...at,
179
+ });
180
+ return;
181
+ }
182
+ if (node.type === 'textDirective' &&
183
+ entry.kindDeclared !== false &&
184
+ entry.kind !== 'inline') {
185
+ events.push({
186
+ kind: 'form-mismatch',
187
+ directive: name,
188
+ message: formMismatchMessage(name),
189
+ ...at,
190
+ });
191
+ return;
192
+ }
193
+ const attributes = stripLayoutKeys(node.attributes ?? {});
194
+ const src = Object.hasOwn(attributes, 'src')
195
+ ? attributes['src']
196
+ : undefined;
197
+ if (isSafeUrl !== undefined &&
198
+ name === FIGURE_DIRECTIVE_NAME &&
199
+ typeof src === 'string' &&
200
+ src !== '' &&
201
+ !isSafeUrl(src)) {
202
+ events.push({
203
+ kind: 'unsafe-image-src',
204
+ directive: name,
205
+ message: unsafeImageSrcMessage(name),
206
+ ...at,
207
+ });
208
+ }
209
+ const invalid = firstInvalidEnum((entry.attributes ?? []).map((a) => [a.name, a.values]), attributes);
210
+ if (invalid !== undefined) {
211
+ events.push({
212
+ kind: 'invalid-attribute-value',
213
+ directive: name,
214
+ attribute: invalid.attribute,
215
+ message: invalidAttributeValueMessage(name, invalid.attribute, invalid.value),
216
+ ...at,
217
+ });
218
+ }
219
+ };
220
+ visit(tree);
221
+ return events;
222
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/stdlib",
3
- "version": "0.17.0-next.4",
3
+ "version": "0.17.0-next.6",
4
4
  "description": "Neutral, framework-agnostic standard component contracts for Mark (.mk.md) — the kind/attribute-schema seam every renderer (e.g. @markii/react) implements against. No React, zero dependencies.",
5
5
  "keywords": [
6
6
  "markdown",