@rmrdeveloper/sideroom-pi 8.11.0 → 8.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -5
- package/assets/artifacts/GUIDELINES_TEMPLATE.md +3 -3
- package/assets/visual-context/browser/INSTALL.md +69 -0
- package/assets/visual-context/browser/background.js +578 -0
- package/assets/visual-context/browser/content.js +198 -0
- package/assets/visual-context/browser/icons/README.md +9 -0
- package/assets/visual-context/browser/icons/icon-128.png +0 -0
- package/assets/visual-context/browser/icons/icon-16.png +0 -0
- package/assets/visual-context/browser/icons/icon-32.png +0 -0
- package/assets/visual-context/browser/icons/icon-48.png +0 -0
- package/assets/visual-context/browser/manifest.json +20 -0
- package/assets/visual-context/browser/popup.css +19 -0
- package/assets/visual-context/browser/popup.html +27 -0
- package/assets/visual-context/browser/popup.js +141 -0
- package/extensions/done/guard.ts +42 -37
- package/extensions/done/index.ts +3 -2
- package/extensions/done/model.ts +1 -5
- package/extensions/explain/guard.ts +13 -11
- package/extensions/explain/model.ts +2 -25
- package/extensions/guidelines/review.ts +31 -13
- package/extensions/jev/client.ts +22 -1
- package/extensions/jev/context.ts +36 -3
- package/extensions/jev/guard.ts +113 -34
- package/extensions/jev/model.ts +90 -12
- package/extensions/jev/workflow.ts +188 -0
- package/extensions/persona/execute.ts +4 -6
- package/extensions/persona/index.ts +3 -2
- package/extensions/persona/model.ts +1 -31
- package/extensions/persona/prompt.ts +3 -1
- package/extensions/shared/settle.ts +63 -13
- package/extensions/visual-context/bridge.ts +240 -0
- package/extensions/visual-context/broker.ts +292 -0
- package/extensions/visual-context/envelope.ts +34 -0
- package/extensions/visual-context/index.ts +328 -0
- package/extensions/visual-context/literal.ts +34 -0
- package/extensions/visual-context/markup.ts +104 -0
- package/extensions/visual-context/match.ts +222 -0
- package/extensions/visual-context/model.ts +180 -0
- package/extensions/visual-context/pairing.ts +114 -0
- package/extensions/visual-context/parse-astro.ts +166 -0
- package/extensions/visual-context/parse-html.ts +72 -0
- package/extensions/visual-context/parse-jsx.ts +157 -0
- package/extensions/visual-context/parse-svelte.ts +128 -0
- package/extensions/visual-context/parse-vue.ts +133 -0
- package/extensions/visual-context/session.ts +192 -0
- package/extensions/visual-context/source.ts +197 -0
- package/extensions/visual-context/ui.ts +57 -0
- package/package.json +16 -7
- package/skills/sideroom-architecture/SKILL.md +5 -3
- package/skills/sideroom-codebase-design/LICENSE.txt +21 -0
- package/skills/sideroom-codebase-design/SKILL.md +151 -0
- package/skills/sideroom-codebase-design/references/growth.md +62 -0
- package/skills/sideroom-domain-modeling/SKILL.md +1 -1
- package/skills/sideroom-domain-scaffold/SKILL.md +6 -4
- package/skills/sideroom-grill/SKILL.md +6 -4
- package/skills/sideroom-guidelines/SKILL.md +9 -7
- package/skills/sideroom-guidelines/references/languages/go.md +3 -3
- package/skills/sideroom-guidelines/references/languages/java.md +3 -3
- package/skills/sideroom-guidelines/references/languages/javascript.md +25 -3
- package/skills/sideroom-guidelines/references/languages/php-laravel.md +3 -3
- package/skills/sideroom-guidelines/references/languages/python.md +3 -3
- package/skills/sideroom-guidelines/references/languages/rust.md +3 -3
- package/skills/sideroom-guidelines/references/languages/typescript.md +27 -3
- package/skills/sideroom-persona/SKILL.md +15 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { parse } from '@astrojs/compiler/sync';
|
|
2
|
+
import type { Node } from '@astrojs/compiler/types';
|
|
3
|
+
import { literalText } from './literal.ts';
|
|
4
|
+
import {
|
|
5
|
+
InvalidMarkup,
|
|
6
|
+
NON_MARKUP_TAGS,
|
|
7
|
+
type ParsedMarkup,
|
|
8
|
+
type SourceNode,
|
|
9
|
+
verifiedElementRange,
|
|
10
|
+
} from './markup.ts';
|
|
11
|
+
import { decodedHtmlText } from './parse-html.ts';
|
|
12
|
+
|
|
13
|
+
const VOID_TAGS = new Set([
|
|
14
|
+
'area',
|
|
15
|
+
'base',
|
|
16
|
+
'br',
|
|
17
|
+
'col',
|
|
18
|
+
'embed',
|
|
19
|
+
'hr',
|
|
20
|
+
'img',
|
|
21
|
+
'input',
|
|
22
|
+
'link',
|
|
23
|
+
'meta',
|
|
24
|
+
'param',
|
|
25
|
+
'source',
|
|
26
|
+
'track',
|
|
27
|
+
'wbr',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function byteOffsets(source: string): Int32Array {
|
|
31
|
+
const offsets = new Int32Array(Buffer.byteLength(source) + 1).fill(-1);
|
|
32
|
+
let byte = 0;
|
|
33
|
+
let character = 0;
|
|
34
|
+
for (const point of source) {
|
|
35
|
+
offsets[byte] = character;
|
|
36
|
+
byte += Buffer.byteLength(point);
|
|
37
|
+
character += point.length;
|
|
38
|
+
}
|
|
39
|
+
offsets[byte] = character;
|
|
40
|
+
return offsets;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Astro's self-closing node end may precede its attributes. Find only the end
|
|
44
|
+
// of its original opening tag, respecting quoted and braced attribute values.
|
|
45
|
+
function openingTagEnd(source: string, start: number): number {
|
|
46
|
+
let quote = '';
|
|
47
|
+
let braces = 0;
|
|
48
|
+
for (let index = start + 1; index < source.length; index += 1) {
|
|
49
|
+
const character = source[index];
|
|
50
|
+
if (quote) {
|
|
51
|
+
if (character === '\\' && braces > 0) {
|
|
52
|
+
index += 1;
|
|
53
|
+
} else if (character === quote) {
|
|
54
|
+
quote = '';
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (character === '"' || character === "'" || character === '`') {
|
|
59
|
+
quote = character;
|
|
60
|
+
} else if (character === '{') {
|
|
61
|
+
braces += 1;
|
|
62
|
+
} else if (character === '}') {
|
|
63
|
+
braces -= 1;
|
|
64
|
+
} else if (character === '>' && braces === 0) {
|
|
65
|
+
return index + 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return -1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseAstro(source: string): ParsedMarkup {
|
|
72
|
+
const document = parse(source, { position: true });
|
|
73
|
+
if (document.diagnostics.some((diagnostic) => diagnostic.severity === 1)) {
|
|
74
|
+
throw new InvalidMarkup('Invalid Astro source');
|
|
75
|
+
}
|
|
76
|
+
const offsets = byteOffsets(source);
|
|
77
|
+
let incomplete = false;
|
|
78
|
+
function convert(node: Node): readonly SourceNode[] {
|
|
79
|
+
if (node.type === 'text') {
|
|
80
|
+
return [{ kind: 'text', value: decodedHtmlText(node.value) }];
|
|
81
|
+
}
|
|
82
|
+
if (node.type === 'expression') {
|
|
83
|
+
const textNodes = node.children.filter((child) => child.type === 'text');
|
|
84
|
+
const expression =
|
|
85
|
+
textNodes.length === node.children.length
|
|
86
|
+
? textNodes.map((child) => child.value).join('')
|
|
87
|
+
: undefined;
|
|
88
|
+
const value =
|
|
89
|
+
expression === undefined ? undefined : literalText(expression);
|
|
90
|
+
// Expression text is JS source, not rendered text. Visit only real tags.
|
|
91
|
+
return value === undefined
|
|
92
|
+
? [
|
|
93
|
+
{
|
|
94
|
+
kind: 'dynamic',
|
|
95
|
+
children: node.children
|
|
96
|
+
.filter((child) => child.type !== 'text')
|
|
97
|
+
.flatMap(convert),
|
|
98
|
+
},
|
|
99
|
+
]
|
|
100
|
+
: [{ kind: 'text', value }];
|
|
101
|
+
}
|
|
102
|
+
if (node.type === 'root' || node.type === 'fragment') {
|
|
103
|
+
return [{ kind: 'group', children: node.children.flatMap(convert) }];
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
!('name' in node && 'attributes' in node) ||
|
|
107
|
+
NON_MARKUP_TAGS.has(node.name)
|
|
108
|
+
) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
const start = offsets[node.position?.start.offset ?? -1] ?? -1;
|
|
112
|
+
const openingEnd = openingTagEnd(source, start);
|
|
113
|
+
const opening = source.slice(start, openingEnd);
|
|
114
|
+
const reportedEnd = offsets[node.position?.end?.offset ?? -1] ?? -1;
|
|
115
|
+
const isClosed = VOID_TAGS.has(node.name) || opening.endsWith('/>');
|
|
116
|
+
const reportedRange = source.slice(start, reportedEnd);
|
|
117
|
+
const closingTag = reportedRange
|
|
118
|
+
.slice(reportedRange.lastIndexOf('</'))
|
|
119
|
+
.replace(/\s+>$/u, '>');
|
|
120
|
+
// When the compiler's closing position is wrong, the verified opening tag
|
|
121
|
+
// is still a valid source range; never extend it to a guessed closing tag.
|
|
122
|
+
const end =
|
|
123
|
+
isClosed || closingTag !== `</${node.name}>` ? openingEnd : reportedEnd;
|
|
124
|
+
if (!node.position?.end && !isClosed) {
|
|
125
|
+
incomplete = true;
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
if (!verifiedElementRange({ source, start, end, tag: node.name })) {
|
|
129
|
+
incomplete = true;
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
let attributes: Record<string, string | undefined> = {};
|
|
133
|
+
for (const attribute of node.attributes) {
|
|
134
|
+
if (attribute.kind === 'spread') {
|
|
135
|
+
attributes = { hidden: undefined, contenteditable: undefined };
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
switch (attribute.kind) {
|
|
139
|
+
case 'expression':
|
|
140
|
+
attributes[attribute.name] = literalText(attribute.value);
|
|
141
|
+
break;
|
|
142
|
+
case 'template-literal':
|
|
143
|
+
attributes[attribute.name] = literalText(attribute.raw ?? '');
|
|
144
|
+
break;
|
|
145
|
+
case 'shorthand':
|
|
146
|
+
attributes[attribute.name] = undefined;
|
|
147
|
+
break;
|
|
148
|
+
default:
|
|
149
|
+
attributes[attribute.name] = decodedHtmlText(attribute.value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
kind: 'element',
|
|
155
|
+
tag: node.name,
|
|
156
|
+
rendered: node.type !== 'component',
|
|
157
|
+
attributes,
|
|
158
|
+
start,
|
|
159
|
+
end,
|
|
160
|
+
children: node.children.flatMap(convert),
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
const nodes = convert(document.ast);
|
|
165
|
+
return { nodes, incomplete };
|
|
166
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type DefaultTreeAdapterMap, parseFragment } from 'parse5';
|
|
2
|
+
import {
|
|
3
|
+
NON_MARKUP_TAGS,
|
|
4
|
+
type ParsedMarkup,
|
|
5
|
+
type SourceNode,
|
|
6
|
+
verifiedElementRange,
|
|
7
|
+
} from './markup.ts';
|
|
8
|
+
|
|
9
|
+
type HtmlNode = DefaultTreeAdapterMap['node'];
|
|
10
|
+
|
|
11
|
+
export function decodedHtmlText(text: string): string {
|
|
12
|
+
if (!text.includes('&')) {
|
|
13
|
+
return text;
|
|
14
|
+
}
|
|
15
|
+
const document = parseFragment(
|
|
16
|
+
`<textarea>${text.replace(/</gu, '<')}</textarea>`,
|
|
17
|
+
);
|
|
18
|
+
const element = document.childNodes[0];
|
|
19
|
+
if (!element || !('childNodes' in element)) {
|
|
20
|
+
throw new Error('Cannot decode HTML character references');
|
|
21
|
+
}
|
|
22
|
+
return element.childNodes
|
|
23
|
+
.map((node) => ('value' in node ? node.value : ''))
|
|
24
|
+
.join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseHtml(source: string): ParsedMarkup {
|
|
28
|
+
const document = parseFragment(source, { sourceCodeLocationInfo: true });
|
|
29
|
+
let incomplete = false;
|
|
30
|
+
function convert(node: HtmlNode): readonly SourceNode[] {
|
|
31
|
+
if (node.nodeName === '#text' && 'value' in node) {
|
|
32
|
+
return [{ kind: 'text', value: node.value }];
|
|
33
|
+
}
|
|
34
|
+
if (!('tagName' in node)) {
|
|
35
|
+
return 'childNodes' in node ? node.childNodes.flatMap(convert) : [];
|
|
36
|
+
}
|
|
37
|
+
if (NON_MARKUP_TAGS.has(node.tagName)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const children = (
|
|
41
|
+
'content' in node ? node.content.childNodes : node.childNodes
|
|
42
|
+
).flatMap(convert);
|
|
43
|
+
if (node.tagName === 'template') {
|
|
44
|
+
return [{ kind: 'group', children }];
|
|
45
|
+
}
|
|
46
|
+
const location = node.sourceCodeLocation;
|
|
47
|
+
if (!location?.startTag) {
|
|
48
|
+
return children;
|
|
49
|
+
}
|
|
50
|
+
const start = location.startOffset;
|
|
51
|
+
const end = location.endTag?.endOffset ?? location.startTag.endOffset;
|
|
52
|
+
if (!verifiedElementRange({ source, start, end, tag: node.tagName })) {
|
|
53
|
+
incomplete = true;
|
|
54
|
+
return children;
|
|
55
|
+
}
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
kind: 'element',
|
|
59
|
+
tag: node.tagName,
|
|
60
|
+
rendered: true,
|
|
61
|
+
attributes: Object.fromEntries(
|
|
62
|
+
node.attrs.map(({ name, value }) => [name, value]),
|
|
63
|
+
),
|
|
64
|
+
start,
|
|
65
|
+
end,
|
|
66
|
+
children,
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
const nodes = document.childNodes.flatMap(convert);
|
|
71
|
+
return { nodes, incomplete };
|
|
72
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import { isBabelSyntaxError, literalText } from './literal.ts';
|
|
3
|
+
import {
|
|
4
|
+
InvalidMarkup,
|
|
5
|
+
NON_MARKUP_TAGS,
|
|
6
|
+
type ParsedMarkup,
|
|
7
|
+
type SourceNode,
|
|
8
|
+
verifiedElementRange,
|
|
9
|
+
} from './markup.ts';
|
|
10
|
+
|
|
11
|
+
const ATTRIBUTE_NAMES: Readonly<Record<string, string>> = {
|
|
12
|
+
className: 'class',
|
|
13
|
+
contentEditable: 'contenteditable',
|
|
14
|
+
};
|
|
15
|
+
type JsxElement = Extract<
|
|
16
|
+
ReturnType<typeof import('@babel/parser').parseExpression>,
|
|
17
|
+
{ type: 'JSXElement' }
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export function parseJsx(source: string, typescript: boolean): ParsedMarkup {
|
|
21
|
+
let document: ReturnType<typeof parse>;
|
|
22
|
+
try {
|
|
23
|
+
document = parse(source, {
|
|
24
|
+
sourceType: 'unambiguous',
|
|
25
|
+
plugins: typescript ? ['jsx', 'typescript'] : ['jsx'],
|
|
26
|
+
attachComment: false,
|
|
27
|
+
});
|
|
28
|
+
} catch (cause) {
|
|
29
|
+
if (isBabelSyntaxError(cause)) {
|
|
30
|
+
throw new InvalidMarkup('Invalid JSX source', { cause });
|
|
31
|
+
}
|
|
32
|
+
throw new Error('Cannot parse JSX source', { cause });
|
|
33
|
+
}
|
|
34
|
+
let incomplete = false;
|
|
35
|
+
function element(node: JsxElement): readonly SourceNode[] {
|
|
36
|
+
const name = node.openingElement.name;
|
|
37
|
+
const tag =
|
|
38
|
+
name.type === 'JSXIdentifier'
|
|
39
|
+
? name.name
|
|
40
|
+
: source.slice(name.start ?? 0, name.end ?? 0);
|
|
41
|
+
if (NON_MARKUP_TAGS.has(tag)) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const start = node.start ?? -1;
|
|
45
|
+
const end = node.end ?? -1;
|
|
46
|
+
if (!verifiedElementRange({ source, start, end, tag })) {
|
|
47
|
+
incomplete = true;
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
let attributes: Record<string, string | undefined> = {};
|
|
51
|
+
for (const attribute of node.openingElement.attributes) {
|
|
52
|
+
if (attribute.type === 'JSXSpreadAttribute') {
|
|
53
|
+
// A spread may overwrite descriptors or make captured text private.
|
|
54
|
+
attributes = { hidden: undefined, contenteditable: undefined };
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (attribute.name.type !== 'JSXIdentifier') {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const key = ATTRIBUTE_NAMES[attribute.name.name] ?? attribute.name.name;
|
|
61
|
+
const value = attribute.value;
|
|
62
|
+
if (!value) {
|
|
63
|
+
attributes[key] = '';
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (value.type === 'StringLiteral') {
|
|
67
|
+
attributes[key] = value.value;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (value.type !== 'JSXExpressionContainer') {
|
|
71
|
+
attributes[key] = undefined;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (value.expression.type === 'BooleanLiteral' && key === 'hidden') {
|
|
75
|
+
if (value.expression.value) {
|
|
76
|
+
attributes.hidden = '';
|
|
77
|
+
} else {
|
|
78
|
+
delete attributes.hidden;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
value.expression.type === 'BooleanLiteral' &&
|
|
84
|
+
key === 'contenteditable'
|
|
85
|
+
) {
|
|
86
|
+
attributes.contenteditable = String(value.expression.value);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
attributes[key] = literalText(
|
|
90
|
+
source.slice(value.expression.start ?? 0, value.expression.end ?? 0),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const children = node.children.flatMap((child): readonly SourceNode[] => {
|
|
94
|
+
if (child.type === 'JSXText') {
|
|
95
|
+
// JSX trims indentation at line boundaries, unlike HTML textContent.
|
|
96
|
+
const lines = child.value.replace(/\t/gu, ' ').split(/\r\n|\n|\r/u);
|
|
97
|
+
const text = lines
|
|
98
|
+
.map((line, index) => {
|
|
99
|
+
const left = index === 0 ? line : line.trimStart();
|
|
100
|
+
return index === lines.length - 1 ? left : left.trimEnd();
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join(' ');
|
|
104
|
+
return [{ kind: 'text', value: text }];
|
|
105
|
+
}
|
|
106
|
+
if (child.type === 'JSXExpressionContainer') {
|
|
107
|
+
if (child.expression.type === 'JSXEmptyExpression') {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
const text = literalText(
|
|
111
|
+
source.slice(child.expression.start ?? 0, child.expression.end ?? 0),
|
|
112
|
+
);
|
|
113
|
+
return text === undefined
|
|
114
|
+
? [{ kind: 'dynamic', children: collect(child.expression) }]
|
|
115
|
+
: [{ kind: 'text', value: text }];
|
|
116
|
+
}
|
|
117
|
+
if (child.type === 'JSXSpreadChild') {
|
|
118
|
+
return [{ kind: 'dynamic', children: collect(child.expression) }];
|
|
119
|
+
}
|
|
120
|
+
return collect(child);
|
|
121
|
+
});
|
|
122
|
+
return [
|
|
123
|
+
{
|
|
124
|
+
kind: 'element',
|
|
125
|
+
tag,
|
|
126
|
+
rendered: /^[a-z]/u.test(tag) && !tag.includes('.'),
|
|
127
|
+
attributes,
|
|
128
|
+
start,
|
|
129
|
+
end,
|
|
130
|
+
children,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
}
|
|
134
|
+
function collect(value: unknown): readonly SourceNode[] {
|
|
135
|
+
if (!value || typeof value !== 'object') {
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(value)) {
|
|
139
|
+
return value.flatMap(collect);
|
|
140
|
+
}
|
|
141
|
+
if ('type' in value && value.type === 'JSXElement') {
|
|
142
|
+
return element(value as JsxElement);
|
|
143
|
+
}
|
|
144
|
+
if (
|
|
145
|
+
'type' in value &&
|
|
146
|
+
value.type === 'JSXFragment' &&
|
|
147
|
+
'children' in value
|
|
148
|
+
) {
|
|
149
|
+
return [{ kind: 'group', children: collect(value.children) }];
|
|
150
|
+
}
|
|
151
|
+
return Object.entries(value)
|
|
152
|
+
.filter(([key]) => !['loc', 'comments', 'tokens', 'extra'].includes(key))
|
|
153
|
+
.flatMap(([, child]) => collect(child));
|
|
154
|
+
}
|
|
155
|
+
const nodes = document.program.body.flatMap(collect);
|
|
156
|
+
return { nodes, incomplete };
|
|
157
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { type AST, parse } from 'svelte/compiler';
|
|
2
|
+
import { literalText } from './literal.ts';
|
|
3
|
+
import {
|
|
4
|
+
InvalidMarkup,
|
|
5
|
+
NON_MARKUP_TAGS,
|
|
6
|
+
type ParsedMarkup,
|
|
7
|
+
type SourceNode,
|
|
8
|
+
verifiedElementRange,
|
|
9
|
+
} from './markup.ts';
|
|
10
|
+
|
|
11
|
+
export function parseSvelte(source: string): ParsedMarkup {
|
|
12
|
+
let document: AST.Root;
|
|
13
|
+
try {
|
|
14
|
+
document = parse(source, { modern: true });
|
|
15
|
+
} catch (cause) {
|
|
16
|
+
if (
|
|
17
|
+
cause instanceof Error &&
|
|
18
|
+
cause.name === 'CompileError' &&
|
|
19
|
+
'code' in cause
|
|
20
|
+
) {
|
|
21
|
+
throw new InvalidMarkup('Invalid Svelte source', { cause });
|
|
22
|
+
}
|
|
23
|
+
throw new Error('Cannot parse Svelte source', { cause });
|
|
24
|
+
}
|
|
25
|
+
let incomplete = false;
|
|
26
|
+
function fragment(
|
|
27
|
+
node: AST.Fragment | null | undefined,
|
|
28
|
+
): readonly SourceNode[] {
|
|
29
|
+
return node?.nodes.flatMap(convert) ?? [];
|
|
30
|
+
}
|
|
31
|
+
function convert(node: AST.TemplateNode): readonly SourceNode[] {
|
|
32
|
+
if (node.type === 'Text') {
|
|
33
|
+
return [{ kind: 'text', value: node.data }];
|
|
34
|
+
}
|
|
35
|
+
if (node.type === 'ExpressionTag') {
|
|
36
|
+
const value = literalText(source.slice(node.start + 1, node.end - 1));
|
|
37
|
+
return value === undefined
|
|
38
|
+
? [{ kind: 'dynamic', children: [] }]
|
|
39
|
+
: [{ kind: 'text', value }];
|
|
40
|
+
}
|
|
41
|
+
if (node.type === 'IfBlock') {
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
kind: 'dynamic',
|
|
45
|
+
children: [...fragment(node.consequent), ...fragment(node.alternate)],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
if (node.type === 'EachBlock' || node.type === 'SnippetBlock') {
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
kind: 'dynamic',
|
|
53
|
+
children: [
|
|
54
|
+
...fragment(node.body),
|
|
55
|
+
...fragment(node.type === 'EachBlock' ? node.fallback : undefined),
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
if (node.type === 'AwaitBlock') {
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
kind: 'dynamic',
|
|
64
|
+
children: [
|
|
65
|
+
...fragment(node.pending),
|
|
66
|
+
...fragment(node.then),
|
|
67
|
+
...fragment(node.catch),
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
if (node.type === 'KeyBlock') {
|
|
73
|
+
return [{ kind: 'group', children: fragment(node.fragment) }];
|
|
74
|
+
}
|
|
75
|
+
if (!('fragment' in node && 'name' in node && 'attributes' in node)) {
|
|
76
|
+
return ['HtmlTag', 'RenderTag'].includes(node.type)
|
|
77
|
+
? [{ kind: 'dynamic', children: [] }]
|
|
78
|
+
: [];
|
|
79
|
+
}
|
|
80
|
+
if (NON_MARKUP_TAGS.has(node.name)) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const children = fragment(node.fragment);
|
|
84
|
+
const start = node.start;
|
|
85
|
+
const end = node.end;
|
|
86
|
+
if (!verifiedElementRange({ source, start, end, tag: node.name })) {
|
|
87
|
+
incomplete = true;
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
let attributes: Record<string, string | undefined> = {};
|
|
91
|
+
for (const attribute of node.attributes) {
|
|
92
|
+
if (attribute.type === 'SpreadAttribute') {
|
|
93
|
+
attributes = { hidden: undefined, contenteditable: undefined };
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (attribute.type !== 'Attribute') {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (attribute.value === true) {
|
|
100
|
+
attributes[attribute.name] = '';
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const pieces = (
|
|
104
|
+
Array.isArray(attribute.value) ? attribute.value : [attribute.value]
|
|
105
|
+
).map((piece) =>
|
|
106
|
+
piece.type === 'Text'
|
|
107
|
+
? piece.data
|
|
108
|
+
: literalText(source.slice(piece.start + 1, piece.end - 1)),
|
|
109
|
+
);
|
|
110
|
+
attributes[attribute.name] = pieces.some((piece) => piece === undefined)
|
|
111
|
+
? undefined
|
|
112
|
+
: pieces.join('');
|
|
113
|
+
}
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
kind: 'element',
|
|
117
|
+
tag: node.name,
|
|
118
|
+
rendered: ['RegularElement', 'TitleElement'].includes(node.type),
|
|
119
|
+
attributes,
|
|
120
|
+
start,
|
|
121
|
+
end,
|
|
122
|
+
children,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
const nodes = fragment(document.fragment);
|
|
127
|
+
return { nodes, incomplete };
|
|
128
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ElementTypes,
|
|
3
|
+
NodeTypes,
|
|
4
|
+
parse,
|
|
5
|
+
type TemplateChildNode,
|
|
6
|
+
} from '@vue/compiler-dom';
|
|
7
|
+
import { literalText } from './literal.ts';
|
|
8
|
+
import {
|
|
9
|
+
InvalidMarkup,
|
|
10
|
+
NON_MARKUP_TAGS,
|
|
11
|
+
type ParsedMarkup,
|
|
12
|
+
type SourceNode,
|
|
13
|
+
verifiedElementRange,
|
|
14
|
+
} from './markup.ts';
|
|
15
|
+
|
|
16
|
+
export function parseVue(source: string): ParsedMarkup {
|
|
17
|
+
let document: ReturnType<typeof parse>;
|
|
18
|
+
try {
|
|
19
|
+
document = parse(source, { comments: false });
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
if (cause instanceof SyntaxError && 'code' in cause) {
|
|
22
|
+
throw new InvalidMarkup('Invalid Vue template', { cause });
|
|
23
|
+
}
|
|
24
|
+
throw new Error('Cannot parse Vue template', { cause });
|
|
25
|
+
}
|
|
26
|
+
let incomplete = false;
|
|
27
|
+
function convert(node: TemplateChildNode): readonly SourceNode[] {
|
|
28
|
+
if (node.type === NodeTypes.TEXT) {
|
|
29
|
+
return [{ kind: 'text', value: node.content }];
|
|
30
|
+
}
|
|
31
|
+
if (node.type === NodeTypes.INTERPOLATION) {
|
|
32
|
+
const text = literalText(node.content.loc.source);
|
|
33
|
+
return text === undefined
|
|
34
|
+
? [{ kind: 'dynamic', children: [] }]
|
|
35
|
+
: [{ kind: 'text', value: text }];
|
|
36
|
+
}
|
|
37
|
+
if (node.type !== NodeTypes.ELEMENT || NON_MARKUP_TAGS.has(node.tag)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const conditional = node.props.some(
|
|
41
|
+
(prop) =>
|
|
42
|
+
prop.type === NodeTypes.DIRECTIVE &&
|
|
43
|
+
['if', 'else-if', 'else', 'for'].includes(prop.name),
|
|
44
|
+
);
|
|
45
|
+
if (node.tag === 'template') {
|
|
46
|
+
const language = node.props.find(
|
|
47
|
+
(prop) => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'lang',
|
|
48
|
+
);
|
|
49
|
+
if (
|
|
50
|
+
language?.type === NodeTypes.ATTRIBUTE &&
|
|
51
|
+
language.value?.content !== 'html'
|
|
52
|
+
) {
|
|
53
|
+
incomplete = true;
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
node.props.some(
|
|
58
|
+
(prop) => prop.type === NodeTypes.ATTRIBUTE && prop.name === 'src',
|
|
59
|
+
)
|
|
60
|
+
) {
|
|
61
|
+
incomplete = true;
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
kind: conditional ? 'dynamic' : 'group',
|
|
67
|
+
children: node.children.flatMap(convert),
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
const start = node.loc.start.offset;
|
|
72
|
+
const end = node.loc.end.offset;
|
|
73
|
+
if (!verifiedElementRange({ source, start, end, tag: node.tag })) {
|
|
74
|
+
incomplete = true;
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const attributes: Record<string, string | undefined> = {};
|
|
78
|
+
for (const prop of node.props) {
|
|
79
|
+
if (prop.type === NodeTypes.ATTRIBUTE) {
|
|
80
|
+
attributes[prop.name] = prop.value?.content ?? '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let dynamicText = false;
|
|
84
|
+
for (const prop of node.props) {
|
|
85
|
+
if (prop.type !== NodeTypes.DIRECTIVE) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (['html', 'text'].includes(prop.name)) {
|
|
89
|
+
dynamicText = true;
|
|
90
|
+
}
|
|
91
|
+
if (prop.name !== 'bind') {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
!prop.arg ||
|
|
96
|
+
prop.arg.type !== NodeTypes.SIMPLE_EXPRESSION ||
|
|
97
|
+
!prop.arg.isStatic
|
|
98
|
+
) {
|
|
99
|
+
// An unknown key or object binding may replace a static id.
|
|
100
|
+
attributes.id = undefined;
|
|
101
|
+
attributes.hidden = undefined;
|
|
102
|
+
attributes.contenteditable = undefined;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const value = prop.exp ? literalText(prop.exp.loc.source) : undefined;
|
|
106
|
+
const key = prop.arg.content;
|
|
107
|
+
attributes[key] =
|
|
108
|
+
key === 'class'
|
|
109
|
+
? [attributes.class, value].filter(Boolean).join(' ')
|
|
110
|
+
: value;
|
|
111
|
+
}
|
|
112
|
+
const children = node.children.flatMap(convert);
|
|
113
|
+
const elements: readonly SourceNode[] = [
|
|
114
|
+
{
|
|
115
|
+
kind: 'element',
|
|
116
|
+
tag: node.tag,
|
|
117
|
+
rendered: node.tagType === ElementTypes.ELEMENT,
|
|
118
|
+
attributes,
|
|
119
|
+
start,
|
|
120
|
+
end,
|
|
121
|
+
children: dynamicText ? [{ kind: 'dynamic', children }] : children,
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
return conditional ? [{ kind: 'dynamic', children: elements }] : elements;
|
|
125
|
+
}
|
|
126
|
+
const templates = document.children.filter(
|
|
127
|
+
(node) => node.type === NodeTypes.ELEMENT && node.tag === 'template',
|
|
128
|
+
);
|
|
129
|
+
const nodes = (templates.length > 0 ? templates : document.children).flatMap(
|
|
130
|
+
convert,
|
|
131
|
+
);
|
|
132
|
+
return { nodes, incomplete };
|
|
133
|
+
}
|