@markii/stdlib 0.17.0-next.4 → 0.17.0-next.5
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 +9 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/validate-note.d.ts +96 -0
- package/dist/validate-note.js +222 -0
- package/package.json +1 -1
package/dist/diagnostics.d.ts
CHANGED
|
@@ -33,17 +33,20 @@
|
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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 no
|
|
46
|
+
* content (`:badge{variant=info}` with no text); it rendered as registered,
|
|
47
|
+
* with a quiet marker.
|
|
45
48
|
*/
|
|
46
|
-
export type DiagnosticKind = 'invalid-attribute-value' | 'unsafe-image-src' | 'form-mismatch' | 'no-engine-view' | 'unknown-directive' | 'code-renderer-failed';
|
|
49
|
+
export type DiagnosticKind = 'invalid-attribute-value' | 'unsafe-image-src' | 'form-mismatch' | 'no-engine-view' | 'unknown-directive' | 'code-renderer-failed' | 'empty-inline';
|
|
47
50
|
/**
|
|
48
51
|
* Where in the note's source an event happened: the directive's range, in
|
|
49
52
|
* unist coordinates (1-based line, 1-based column, end exclusive). Present
|
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. */
|
|
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 &&
|
|
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.
|
|
3
|
+
"version": "0.17.0-next.5",
|
|
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",
|