@surfaice/format 0.0.1
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/LICENSE +21 -0
- package/README.md +55 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/parser.d.ts +5 -0
- package/dist/parser.js +200 -0
- package/dist/serializer.d.ts +6 -0
- package/dist/serializer.js +83 -0
- package/dist/types.d.ts +79 -0
- package/dist/types.js +3 -0
- package/dist/validator.d.ts +6 -0
- package/dist/validator.js +102 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 surfaiceai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @surfaice/format
|
|
2
|
+
|
|
3
|
+
Parser, serializer, and validator for the `.surfaice.md` format — the machine-readable UI description used by [Surfaice](https://github.com/surfaiceai/surfaice).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @surfaice/format
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { parse, serialize, validate } from '@surfaice/format'
|
|
15
|
+
|
|
16
|
+
// Parse a .surfaice.md file
|
|
17
|
+
const page = parse(markdownString) // → SurfaicePage AST
|
|
18
|
+
|
|
19
|
+
// Validate structure
|
|
20
|
+
const errors = validate(page) // → ValidationError[]
|
|
21
|
+
|
|
22
|
+
// Serialize back to markdown
|
|
23
|
+
const md = serialize(page) // → string (roundtrip stable)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What Is `.surfaice.md`?
|
|
27
|
+
|
|
28
|
+
A markdown-based format that describes your UI elements for AI agents and CI tools:
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
---
|
|
32
|
+
surfaice: v1
|
|
33
|
+
route: /settings
|
|
34
|
+
states: [auth-required]
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
# /settings
|
|
38
|
+
|
|
39
|
+
## Profile
|
|
40
|
+
- [name] textbox "Display Name" → current: {user.name} → accepts: string
|
|
41
|
+
- [save] button "Save Changes" (destructive) → PUT /api/profile → toast 'Saved!'
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## API
|
|
45
|
+
|
|
46
|
+
### `parse(markdown: string): SurfaicePage`
|
|
47
|
+
### `serialize(page: SurfaicePage): string`
|
|
48
|
+
### `validate(page: SurfaicePage): ValidationError[]`
|
|
49
|
+
|
|
50
|
+
## Part of Surfaice
|
|
51
|
+
|
|
52
|
+
- [`@surfaice/react`](../react) — React annotations
|
|
53
|
+
- [`@surfaice/differ`](../differ) — Structural diff engine
|
|
54
|
+
- [`@surfaice/cli`](../cli) — CLI tools
|
|
55
|
+
- [`@surfaice/next`](../next) — Next.js middleware
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/parser.d.ts
ADDED
package/dist/parser.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkFrontmatter from 'remark-frontmatter';
|
|
4
|
+
import { parse as parseYaml } from 'yaml';
|
|
5
|
+
// Regex to match element lines: - [id] type "Label" (attrs) → ...
|
|
6
|
+
const ELEMENT_LINE_RE = /^-\s+\[(\w[\w-]*)\]\s+(\w[\w-]*)\s+"([^"]+)"(?:\s+\(([^)]+)\))?(.*)?$/;
|
|
7
|
+
/**
|
|
8
|
+
* Parse a .surfaice.md string into a SurfaicePage AST.
|
|
9
|
+
*/
|
|
10
|
+
export function parse(input) {
|
|
11
|
+
const processor = unified()
|
|
12
|
+
.use(remarkParse)
|
|
13
|
+
.use(remarkFrontmatter, ['yaml']);
|
|
14
|
+
const mdast = processor.parse(input);
|
|
15
|
+
// Extract frontmatter
|
|
16
|
+
const frontmatterNode = mdast.children.find(n => n.type === 'yaml');
|
|
17
|
+
const frontmatter = frontmatterNode ? parseYaml(frontmatterNode.value) : {};
|
|
18
|
+
const version = String(frontmatter['surfaice'] ?? 'v1');
|
|
19
|
+
const route = String(frontmatter['route'] ?? '/');
|
|
20
|
+
const name = frontmatter['name'] ? String(frontmatter['name']) : undefined;
|
|
21
|
+
const states = Array.isArray(frontmatter['states']) ? frontmatter['states'].map(String) : undefined;
|
|
22
|
+
const capabilities = parseCapabilities(frontmatter['capabilities']);
|
|
23
|
+
// Walk the AST and collect sections + page states
|
|
24
|
+
const sections = [];
|
|
25
|
+
let pageStates;
|
|
26
|
+
let currentSection = null;
|
|
27
|
+
let inStatesBlock = false;
|
|
28
|
+
for (const node of mdast.children) {
|
|
29
|
+
if (node.type === 'yaml')
|
|
30
|
+
continue;
|
|
31
|
+
if (node.type === 'heading') {
|
|
32
|
+
const heading = node;
|
|
33
|
+
const headingText = extractText(heading);
|
|
34
|
+
if (heading.depth === 1) {
|
|
35
|
+
// Page heading — skip
|
|
36
|
+
currentSection = null;
|
|
37
|
+
inStatesBlock = false;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (heading.depth === 2) {
|
|
41
|
+
if (headingText === 'States') {
|
|
42
|
+
inStatesBlock = true;
|
|
43
|
+
currentSection = null;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
inStatesBlock = false;
|
|
47
|
+
currentSection = { name: headingText, elements: [] };
|
|
48
|
+
sections.push(currentSection);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (node.type === 'list') {
|
|
54
|
+
const list = node;
|
|
55
|
+
if (inStatesBlock) {
|
|
56
|
+
pageStates = parseStatesBlock(list);
|
|
57
|
+
}
|
|
58
|
+
else if (currentSection) {
|
|
59
|
+
const elements = parseElementList(list);
|
|
60
|
+
currentSection.elements.push(...elements);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
version,
|
|
66
|
+
route,
|
|
67
|
+
...(name !== undefined && { name }),
|
|
68
|
+
...(states !== undefined && { states }),
|
|
69
|
+
...(capabilities !== undefined && { capabilities }),
|
|
70
|
+
sections,
|
|
71
|
+
...(pageStates !== undefined && { pageStates }),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function parseCapabilities(raw) {
|
|
75
|
+
if (!Array.isArray(raw))
|
|
76
|
+
return undefined;
|
|
77
|
+
return raw.map((cap) => {
|
|
78
|
+
const c = cap;
|
|
79
|
+
return {
|
|
80
|
+
id: String(c['id'] ?? ''),
|
|
81
|
+
description: String(c['description'] ?? ''),
|
|
82
|
+
elements: Array.isArray(c['elements']) ? c['elements'].map(String) : [],
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
function extractText(node) {
|
|
87
|
+
return node.children
|
|
88
|
+
.filter((c) => c.type === 'text')
|
|
89
|
+
.map(c => c.value)
|
|
90
|
+
.join('');
|
|
91
|
+
}
|
|
92
|
+
function extractListItemText(item) {
|
|
93
|
+
const para = item.children.find((c) => c.type === 'paragraph');
|
|
94
|
+
if (!para)
|
|
95
|
+
return '';
|
|
96
|
+
return para.children
|
|
97
|
+
.filter((c) => c.type === 'text')
|
|
98
|
+
.map(c => c.value)
|
|
99
|
+
.join('');
|
|
100
|
+
}
|
|
101
|
+
function parseElementList(list) {
|
|
102
|
+
const elements = [];
|
|
103
|
+
for (const item of list.children) {
|
|
104
|
+
const listItem = item;
|
|
105
|
+
const text = extractListItemText(listItem);
|
|
106
|
+
const el = parseElementLine(text);
|
|
107
|
+
if (!el)
|
|
108
|
+
continue;
|
|
109
|
+
// Check for nested list (reveals)
|
|
110
|
+
const nestedList = listItem.children.find((c) => c.type === 'list');
|
|
111
|
+
if (nestedList) {
|
|
112
|
+
el.reveals = parseElementList(nestedList);
|
|
113
|
+
}
|
|
114
|
+
elements.push(el);
|
|
115
|
+
}
|
|
116
|
+
return elements;
|
|
117
|
+
}
|
|
118
|
+
function parseElementLine(line) {
|
|
119
|
+
const trimmed = line.trim().startsWith('- ') ? line.trim() : `- ${line.trim()}`;
|
|
120
|
+
const match = trimmed.match(ELEMENT_LINE_RE);
|
|
121
|
+
if (!match)
|
|
122
|
+
return null;
|
|
123
|
+
const [, id, type, label, attrs, rest] = match;
|
|
124
|
+
const element = {
|
|
125
|
+
id,
|
|
126
|
+
type: type,
|
|
127
|
+
label,
|
|
128
|
+
};
|
|
129
|
+
if (attrs) {
|
|
130
|
+
element.attributes = attrs.split(',').map(a => a.trim()).filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
if (rest && rest.trim()) {
|
|
133
|
+
parseActions(rest.trim(), element);
|
|
134
|
+
}
|
|
135
|
+
return element;
|
|
136
|
+
}
|
|
137
|
+
function parseActions(text, element) {
|
|
138
|
+
// Split on → (unicode arrow) or ASCII →
|
|
139
|
+
const parts = text.split(/\s*→\s*/).map(p => p.trim()).filter(Boolean);
|
|
140
|
+
for (const part of parts) {
|
|
141
|
+
if (part.startsWith('navigates:')) {
|
|
142
|
+
element.navigates = part.slice('navigates:'.length).trim();
|
|
143
|
+
}
|
|
144
|
+
else if (part.startsWith('current:')) {
|
|
145
|
+
element.current = part.slice('current:'.length).trim();
|
|
146
|
+
}
|
|
147
|
+
else if (part.startsWith('accepts:')) {
|
|
148
|
+
element.accepts = part.slice('accepts:'.length).trim();
|
|
149
|
+
}
|
|
150
|
+
else if (part.startsWith('shows:')) {
|
|
151
|
+
element.shows = part.slice('shows:'.length).trim();
|
|
152
|
+
}
|
|
153
|
+
else if (part.startsWith('options:')) {
|
|
154
|
+
element.options = part.slice('options:'.length).trim().split(',').map(o => o.trim()).filter(Boolean);
|
|
155
|
+
}
|
|
156
|
+
else if (part.startsWith('reveals:')) {
|
|
157
|
+
// reveals children are parsed from nested list, not inline
|
|
158
|
+
}
|
|
159
|
+
else if (/^(GET|POST|PUT|PATCH|DELETE)\s/.test(part)) {
|
|
160
|
+
element.action = part;
|
|
161
|
+
}
|
|
162
|
+
else if (part.startsWith('toast') || part.startsWith('confirms:') || part.startsWith('inline-error')) {
|
|
163
|
+
element.result = part;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Fallback: first unknown part → action, second → result
|
|
167
|
+
if (!element.action) {
|
|
168
|
+
element.action = part;
|
|
169
|
+
}
|
|
170
|
+
else if (!element.result) {
|
|
171
|
+
element.result = part;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function parseStatesBlock(list) {
|
|
177
|
+
const states = [];
|
|
178
|
+
for (const item of list.children) {
|
|
179
|
+
const text = extractListItemText(item).trim();
|
|
180
|
+
// Format: [state-name]: modifier elementId description
|
|
181
|
+
const stateMatch = text.match(/^\[([^\]]+)\]:\s*([+\-~])\s+(\w[\w-]*)\s+(.*)$/);
|
|
182
|
+
if (!stateMatch)
|
|
183
|
+
continue;
|
|
184
|
+
const [, stateName, modifier, elementId, description] = stateMatch;
|
|
185
|
+
const change = {
|
|
186
|
+
elementId,
|
|
187
|
+
modifier: modifier,
|
|
188
|
+
description: description.trim(),
|
|
189
|
+
};
|
|
190
|
+
// Check if we already have this state
|
|
191
|
+
const existing = states.find(s => s.name === stateName);
|
|
192
|
+
if (existing) {
|
|
193
|
+
existing.changes.push(change);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
states.push({ name: stateName, changes: [change] });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return states.length > 0 ? states : [];
|
|
200
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a SurfaicePage AST back to a .surfaice.md string.
|
|
3
|
+
* Guarantees roundtrip stability: parse(serialize(page)) deepEquals page.
|
|
4
|
+
*/
|
|
5
|
+
export function serialize(page) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
// Frontmatter
|
|
8
|
+
lines.push('---');
|
|
9
|
+
lines.push(`surfaice: ${page.version}`);
|
|
10
|
+
lines.push(`route: ${page.route}`);
|
|
11
|
+
if (page.name !== undefined) {
|
|
12
|
+
lines.push(`name: ${page.name}`);
|
|
13
|
+
}
|
|
14
|
+
if (page.states?.length) {
|
|
15
|
+
lines.push(`states: [${page.states.join(', ')}]`);
|
|
16
|
+
}
|
|
17
|
+
if (page.capabilities?.length) {
|
|
18
|
+
lines.push('capabilities:');
|
|
19
|
+
for (const cap of page.capabilities) {
|
|
20
|
+
lines.push(` - id: ${cap.id}`);
|
|
21
|
+
lines.push(` description: "${cap.description}"`);
|
|
22
|
+
lines.push(` elements: [${cap.elements.join(', ')}]`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
lines.push('---');
|
|
26
|
+
lines.push('');
|
|
27
|
+
// Page heading
|
|
28
|
+
lines.push(`# ${page.route}`);
|
|
29
|
+
// Sections
|
|
30
|
+
for (const section of page.sections) {
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push(`## ${section.name}`);
|
|
33
|
+
for (const el of section.elements) {
|
|
34
|
+
lines.push(serializeElement(el, 0));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Page states
|
|
38
|
+
if (page.pageStates?.length) {
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push('## States');
|
|
41
|
+
for (const state of page.pageStates) {
|
|
42
|
+
for (const change of state.changes) {
|
|
43
|
+
lines.push(`- [${state.name}]: ${change.modifier} ${change.elementId} ${change.description}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return lines.join('\n') + '\n';
|
|
48
|
+
}
|
|
49
|
+
function serializeElement(el, indent) {
|
|
50
|
+
const prefix = ' '.repeat(indent) + '- ';
|
|
51
|
+
let line = `${prefix}[${el.id}] ${el.type} "${el.label}"`;
|
|
52
|
+
if (el.attributes?.length) {
|
|
53
|
+
line += ` (${el.attributes.join(', ')})`;
|
|
54
|
+
}
|
|
55
|
+
const parts = [];
|
|
56
|
+
// Order matters for roundtrip stability — must match parser's parseActions order
|
|
57
|
+
if (el.current !== undefined)
|
|
58
|
+
parts.push(`current: ${el.current}`);
|
|
59
|
+
if (el.accepts !== undefined)
|
|
60
|
+
parts.push(`accepts: ${el.accepts}`);
|
|
61
|
+
if (el.options?.length)
|
|
62
|
+
parts.push(`options: ${el.options.join(', ')}`);
|
|
63
|
+
if (el.shows !== undefined)
|
|
64
|
+
parts.push(`shows: ${el.shows}`);
|
|
65
|
+
if (el.action !== undefined)
|
|
66
|
+
parts.push(el.action);
|
|
67
|
+
if (el.result !== undefined)
|
|
68
|
+
parts.push(el.result);
|
|
69
|
+
if (el.navigates !== undefined)
|
|
70
|
+
parts.push(`navigates: ${el.navigates}`);
|
|
71
|
+
if (el.reveals?.length) {
|
|
72
|
+
parts.push('reveals:');
|
|
73
|
+
line += parts.length > 0 ? ' → ' + parts.join(' → ') : '';
|
|
74
|
+
for (const child of el.reveals) {
|
|
75
|
+
line += '\n' + serializeElement(child, indent + 1);
|
|
76
|
+
}
|
|
77
|
+
return line;
|
|
78
|
+
}
|
|
79
|
+
if (parts.length > 0) {
|
|
80
|
+
line += ' → ' + parts.join(' → ');
|
|
81
|
+
}
|
|
82
|
+
return line;
|
|
83
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export type ElementType = 'button' | 'textbox' | 'textarea' | 'link' | 'select' | 'checkbox' | 'radio' | 'toggle' | 'slider' | 'image' | 'image-upload' | 'badge' | 'heading' | 'text' | 'list';
|
|
2
|
+
export interface Capability {
|
|
3
|
+
/** Unique identifier for this capability, e.g. "update-profile" */
|
|
4
|
+
id: string;
|
|
5
|
+
/** Human-readable description, e.g. "User updates their display name" */
|
|
6
|
+
description: string;
|
|
7
|
+
/** Element IDs involved in this capability */
|
|
8
|
+
elements: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface StateChange {
|
|
11
|
+
/** The element ID this change applies to */
|
|
12
|
+
elementId: string;
|
|
13
|
+
/** '+' = add/show, '-' = remove/hide, '~' = modify */
|
|
14
|
+
modifier: '+' | '-' | '~';
|
|
15
|
+
/** Description of what changes, e.g. "disabled, shows spinner" */
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
export interface PageState {
|
|
19
|
+
/** State name, e.g. "loading", "error", "success" */
|
|
20
|
+
name: string;
|
|
21
|
+
changes: StateChange[];
|
|
22
|
+
}
|
|
23
|
+
export interface Element {
|
|
24
|
+
/** Unique identifier within this page, e.g. "save" */
|
|
25
|
+
id: string;
|
|
26
|
+
/** Element type */
|
|
27
|
+
type: ElementType;
|
|
28
|
+
/** Human-readable label, e.g. "Save Changes" */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Optional modifiers: "readonly", "required", "destructive" */
|
|
31
|
+
attributes?: string[];
|
|
32
|
+
/** HTTP action, e.g. "PUT /api/profile" */
|
|
33
|
+
action?: string;
|
|
34
|
+
/** Side-effect after action, e.g. "toast 'Saved!'" */
|
|
35
|
+
result?: string;
|
|
36
|
+
/** Route this element navigates to */
|
|
37
|
+
navigates?: string;
|
|
38
|
+
/** Elements revealed after interaction with this element */
|
|
39
|
+
reveals?: Element[];
|
|
40
|
+
/** Runtime live value (injected at render time), e.g. "Haosu Wu" */
|
|
41
|
+
value?: string;
|
|
42
|
+
/** Template binding for static export, e.g. "{user.name}" */
|
|
43
|
+
current?: string;
|
|
44
|
+
/** Input type hint for textboxes, e.g. "email", "string" */
|
|
45
|
+
accepts?: string;
|
|
46
|
+
/** Options for select elements */
|
|
47
|
+
options?: string[];
|
|
48
|
+
/** Value displayed by display elements (e.g. badge count), e.g. "{notifications.count}" */
|
|
49
|
+
shows?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface Section {
|
|
52
|
+
/** Section name, e.g. "Profile Section" */
|
|
53
|
+
name: string;
|
|
54
|
+
elements: Element[];
|
|
55
|
+
}
|
|
56
|
+
export interface SurfaicePage {
|
|
57
|
+
/** Format version, always "v1" for now */
|
|
58
|
+
version: string;
|
|
59
|
+
/** Route this page corresponds to, e.g. "/settings" */
|
|
60
|
+
route: string;
|
|
61
|
+
/** Optional human-readable page name */
|
|
62
|
+
name?: string;
|
|
63
|
+
/** Page-level states that gate access, e.g. ["auth-required"] */
|
|
64
|
+
states?: string[];
|
|
65
|
+
/** Named capabilities grouping elements into user flows */
|
|
66
|
+
capabilities?: Capability[];
|
|
67
|
+
/** Ordered list of UI sections */
|
|
68
|
+
sections: Section[];
|
|
69
|
+
/** Optional state machine describing UI state transitions */
|
|
70
|
+
pageStates?: PageState[];
|
|
71
|
+
}
|
|
72
|
+
export interface ValidationError {
|
|
73
|
+
/** Machine-readable error code */
|
|
74
|
+
code: string;
|
|
75
|
+
/** Human-readable message */
|
|
76
|
+
message: string;
|
|
77
|
+
/** Optional path to the offending node, e.g. "sections[0].elements[2].id" */
|
|
78
|
+
path?: string;
|
|
79
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SurfaicePage, ValidationError } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Validate a SurfaicePage AST for structural correctness.
|
|
4
|
+
* Returns an array of ValidationErrors — empty array means valid.
|
|
5
|
+
*/
|
|
6
|
+
export declare function validate(page: SurfaicePage): ValidationError[];
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const VALID_ELEMENT_TYPES = new Set([
|
|
2
|
+
'button', 'textbox', 'textarea', 'link', 'select', 'checkbox',
|
|
3
|
+
'radio', 'toggle', 'slider', 'image', 'image-upload',
|
|
4
|
+
'badge', 'heading', 'text', 'list',
|
|
5
|
+
]);
|
|
6
|
+
/**
|
|
7
|
+
* Validate a SurfaicePage AST for structural correctness.
|
|
8
|
+
* Returns an array of ValidationErrors — empty array means valid.
|
|
9
|
+
*/
|
|
10
|
+
export function validate(page) {
|
|
11
|
+
const errors = [];
|
|
12
|
+
// Collect all element IDs for duplicate and reference checking
|
|
13
|
+
const allIds = new Set();
|
|
14
|
+
const seenIds = new Set();
|
|
15
|
+
// First pass: collect all element IDs (including in reveals)
|
|
16
|
+
for (const section of page.sections) {
|
|
17
|
+
collectIds(section.elements, allIds);
|
|
18
|
+
}
|
|
19
|
+
// Second pass: validate each element and check for duplicates
|
|
20
|
+
for (let si = 0; si < page.sections.length; si++) {
|
|
21
|
+
const section = page.sections[si];
|
|
22
|
+
validateElements(section.elements, seenIds, errors, `sections[${si}]`);
|
|
23
|
+
}
|
|
24
|
+
// Validate capabilities reference existing element IDs
|
|
25
|
+
if (page.capabilities) {
|
|
26
|
+
for (let ci = 0; ci < page.capabilities.length; ci++) {
|
|
27
|
+
const cap = page.capabilities[ci];
|
|
28
|
+
for (const elementId of cap.elements) {
|
|
29
|
+
if (!allIds.has(elementId)) {
|
|
30
|
+
errors.push({
|
|
31
|
+
code: 'UNKNOWN_ELEMENT_REF',
|
|
32
|
+
message: `Capability "${cap.id}" references unknown element id "${elementId}"`,
|
|
33
|
+
path: `capabilities[${ci}].elements`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Validate page state changes reference existing element IDs
|
|
40
|
+
if (page.pageStates) {
|
|
41
|
+
for (let psi = 0; psi < page.pageStates.length; psi++) {
|
|
42
|
+
const state = page.pageStates[psi];
|
|
43
|
+
for (let chi = 0; chi < state.changes.length; chi++) {
|
|
44
|
+
const change = state.changes[chi];
|
|
45
|
+
if (!allIds.has(change.elementId)) {
|
|
46
|
+
errors.push({
|
|
47
|
+
code: 'UNKNOWN_ELEMENT_REF',
|
|
48
|
+
message: `State "${state.name}" references unknown element id "${change.elementId}"`,
|
|
49
|
+
path: `pageStates[${psi}].changes[${chi}].elementId`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return errors;
|
|
56
|
+
}
|
|
57
|
+
function collectIds(elements, ids) {
|
|
58
|
+
for (const el of elements) {
|
|
59
|
+
ids.add(el.id);
|
|
60
|
+
if (el.reveals) {
|
|
61
|
+
collectIds(el.reveals, ids);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function validateElements(elements, seenIds, errors, path) {
|
|
66
|
+
for (let i = 0; i < elements.length; i++) {
|
|
67
|
+
const el = elements[i];
|
|
68
|
+
const elPath = `${path}.elements[${i}]`;
|
|
69
|
+
// Required fields
|
|
70
|
+
if (!el.id) {
|
|
71
|
+
errors.push({ code: 'MISSING_REQUIRED', message: 'Element is missing required field "id"', path: `${elPath}.id` });
|
|
72
|
+
}
|
|
73
|
+
if (!el.label) {
|
|
74
|
+
errors.push({ code: 'MISSING_REQUIRED', message: 'Element is missing required field "label"', path: `${elPath}.label` });
|
|
75
|
+
}
|
|
76
|
+
// Valid type
|
|
77
|
+
if (!VALID_ELEMENT_TYPES.has(el.type)) {
|
|
78
|
+
errors.push({
|
|
79
|
+
code: 'INVALID_TYPE',
|
|
80
|
+
message: `Element "${el.id}" has invalid type "${el.type}". Valid types: ${[...VALID_ELEMENT_TYPES].join(', ')}`,
|
|
81
|
+
path: `${elPath}.type`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
// Unique ID
|
|
85
|
+
if (el.id) {
|
|
86
|
+
if (seenIds.has(el.id)) {
|
|
87
|
+
errors.push({
|
|
88
|
+
code: 'DUPLICATE_ID',
|
|
89
|
+
message: `Duplicate element id "${el.id}" found at ${elPath}`,
|
|
90
|
+
path: `${elPath}.id`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
seenIds.add(el.id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Recurse into reveals
|
|
98
|
+
if (el.reveals) {
|
|
99
|
+
validateElements(el.reveals, seenIds, errors, `${elPath}.reveals`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@surfaice/format",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Surfaice format package",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/surfaiceai/surfaice",
|
|
11
|
+
"directory": "packages/format"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/mdast": "^4.0.4",
|
|
15
|
+
"typescript": "^5.5.0",
|
|
16
|
+
"vitest": "^2.0.0"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"remark-frontmatter": "^5.0.0",
|
|
20
|
+
"remark-parse": "^11.0.0",
|
|
21
|
+
"unified": "^11.0.0",
|
|
22
|
+
"yaml": "^2.4.0"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"import": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"dev": "tsc --watch"
|
|
43
|
+
}
|
|
44
|
+
}
|