@reqvire-org/ads 0.3.0 → 0.3.2
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 +1 -1
- package/dist/bootstrap-ds/ADS.md +47 -7
- package/dist/bootstrap-ds/README.md +12 -10
- package/dist/bootstrap-ds/design-system/index.ts +1 -1
- package/dist/bootstrap-ds/design-system/patterns/token-preview/Scenario.tsx +4 -0
- package/dist/bootstrap-ds/design-system/patterns/token-preview/TokenPreview.tsx +31 -0
- package/dist/bootstrap-ds/design-system/patterns/token-preview/fixture.ts +8 -0
- package/dist/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +44 -43
- package/dist/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +3 -13
- package/dist/bootstrap-ds/design-system/tokens/base.css +4 -1
- package/dist/bootstrap-ds/design-system/tokens/colors.css +2 -2
- package/dist/bootstrap-ds/ds.config.json +10 -0
- package/dist/check.mjs +29 -13
- package/dist/contracts/foundation.json +211 -0
- package/dist/foundation.mjs +114 -0
- package/dist/init.mjs +22 -5
- package/dist/mcp/apps.mjs +2 -2
- package/dist/mcp/build-status.mjs +22 -9
- package/dist/mcp/context.mjs +1 -1
- package/dist/mcp/preview-watch.mjs +35 -12
- package/dist/mcp/preview-widget.bundle.js +40 -38
- package/dist/mcp/project-widget.bundle.js +42 -38
- package/dist/mcp/prompts.mjs +1 -1
- package/dist/mcp/server.mjs +5 -5
- package/dist/preview-resources.mjs +1 -1
- package/dist/scenarios-compiler.mjs +86 -43
- package/dist/scenarios.mjs +16 -5
- package/dist/skills/ads-assimilate/SKILL.md +47 -12
- package/dist/skills/ads-assimilate/references/cli.md +3 -2
- package/dist/skills/ads-break-ui/SKILL.md +47 -12
- package/dist/skills/ads-break-ui/references/verification.md +4 -0
- package/dist/skills/ads-designer/SKILL.md +49 -14
- package/dist/skills/ads-designer/assets/bootstrap-ds/ADS.md +47 -7
- package/dist/skills/ads-designer/assets/bootstrap-ds/README.md +12 -10
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/index.ts +1 -1
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/Scenario.tsx +4 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/TokenPreview.tsx +31 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/patterns/token-preview/fixture.ts +8 -0
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.fixture.ts +44 -43
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/Foundation.scenario.tsx +3 -13
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/base.css +4 -1
- package/dist/skills/ads-designer/assets/bootstrap-ds/design-system/tokens/colors.css +2 -2
- package/dist/skills/ads-designer/assets/bootstrap-ds/ds.config.json +10 -0
- package/dist/skills/ads-designer/references/cli.md +3 -2
- package/dist/skills/ads-import/SKILL.md +47 -12
- package/dist/skills/ads-import/references/cli.md +3 -2
- package/dist/skills/ads-motion/SKILL.md +47 -12
- package/dist/token-policy.mjs +12 -0
- package/dist/token-samples.mjs +8 -0
- package/dist/units.mjs +34 -10
- package/docs/ADS.md +52 -9
- package/docs/ARCHITECTURE.md +44 -3
- package/docs/CHANGELOG.md +32 -1
- package/docs/GUIDE.md +17 -6
- package/docs/MCP.md +62 -9
- package/package.json +3 -2
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
{
|
|
2
|
+
"document": [
|
|
3
|
+
"color",
|
|
4
|
+
"background",
|
|
5
|
+
"background-color",
|
|
6
|
+
"font",
|
|
7
|
+
"font-family",
|
|
8
|
+
"font-size",
|
|
9
|
+
"font-weight",
|
|
10
|
+
"font-style",
|
|
11
|
+
"font-stretch",
|
|
12
|
+
"line-height",
|
|
13
|
+
"letter-spacing",
|
|
14
|
+
"word-spacing",
|
|
15
|
+
"text-rendering",
|
|
16
|
+
"font-synthesis",
|
|
17
|
+
"font-kerning",
|
|
18
|
+
"font-feature-settings",
|
|
19
|
+
"font-variation-settings",
|
|
20
|
+
"font-optical-sizing",
|
|
21
|
+
"-webkit-font-smoothing",
|
|
22
|
+
"-moz-osx-font-smoothing",
|
|
23
|
+
"-webkit-text-size-adjust",
|
|
24
|
+
"text-size-adjust",
|
|
25
|
+
"color-scheme",
|
|
26
|
+
"scrollbar-color",
|
|
27
|
+
"scrollbar-width"
|
|
28
|
+
],
|
|
29
|
+
"reset": {
|
|
30
|
+
"box-sizing": [
|
|
31
|
+
"border-box",
|
|
32
|
+
"content-box"
|
|
33
|
+
],
|
|
34
|
+
"font": [
|
|
35
|
+
"inherit"
|
|
36
|
+
],
|
|
37
|
+
"font-family": [
|
|
38
|
+
"inherit"
|
|
39
|
+
],
|
|
40
|
+
"font-size": [
|
|
41
|
+
"inherit",
|
|
42
|
+
"100%"
|
|
43
|
+
],
|
|
44
|
+
"font-weight": [
|
|
45
|
+
"inherit",
|
|
46
|
+
"bolder",
|
|
47
|
+
"lighter"
|
|
48
|
+
],
|
|
49
|
+
"font-style": [
|
|
50
|
+
"inherit",
|
|
51
|
+
"normal"
|
|
52
|
+
],
|
|
53
|
+
"line-height": [
|
|
54
|
+
"inherit",
|
|
55
|
+
"normal"
|
|
56
|
+
],
|
|
57
|
+
"letter-spacing": [
|
|
58
|
+
"inherit",
|
|
59
|
+
"normal"
|
|
60
|
+
],
|
|
61
|
+
"word-spacing": [
|
|
62
|
+
"inherit",
|
|
63
|
+
"normal"
|
|
64
|
+
],
|
|
65
|
+
"text-align": [
|
|
66
|
+
"inherit",
|
|
67
|
+
"start"
|
|
68
|
+
],
|
|
69
|
+
"text-transform": [
|
|
70
|
+
"inherit",
|
|
71
|
+
"none"
|
|
72
|
+
],
|
|
73
|
+
"text-decoration": [
|
|
74
|
+
"inherit",
|
|
75
|
+
"none"
|
|
76
|
+
],
|
|
77
|
+
"text-indent": [
|
|
78
|
+
"0"
|
|
79
|
+
],
|
|
80
|
+
"color": [
|
|
81
|
+
"inherit",
|
|
82
|
+
"currentcolor"
|
|
83
|
+
],
|
|
84
|
+
"background": [
|
|
85
|
+
"transparent",
|
|
86
|
+
"none"
|
|
87
|
+
],
|
|
88
|
+
"background-color": [
|
|
89
|
+
"transparent"
|
|
90
|
+
],
|
|
91
|
+
"border": [
|
|
92
|
+
"0",
|
|
93
|
+
"none"
|
|
94
|
+
],
|
|
95
|
+
"border-width": [
|
|
96
|
+
"0"
|
|
97
|
+
],
|
|
98
|
+
"border-style": [
|
|
99
|
+
"none"
|
|
100
|
+
],
|
|
101
|
+
"border-color": [
|
|
102
|
+
"inherit",
|
|
103
|
+
"currentcolor",
|
|
104
|
+
"transparent"
|
|
105
|
+
],
|
|
106
|
+
"border-radius": [
|
|
107
|
+
"0"
|
|
108
|
+
],
|
|
109
|
+
"border-collapse": [
|
|
110
|
+
"collapse",
|
|
111
|
+
"separate"
|
|
112
|
+
],
|
|
113
|
+
"border-spacing": [
|
|
114
|
+
"0"
|
|
115
|
+
],
|
|
116
|
+
"list-style": [
|
|
117
|
+
"none"
|
|
118
|
+
],
|
|
119
|
+
"list-style-type": [
|
|
120
|
+
"none"
|
|
121
|
+
],
|
|
122
|
+
"list-style-image": [
|
|
123
|
+
"none"
|
|
124
|
+
],
|
|
125
|
+
"vertical-align": [
|
|
126
|
+
"baseline",
|
|
127
|
+
"middle"
|
|
128
|
+
],
|
|
129
|
+
"box-shadow": [
|
|
130
|
+
"none"
|
|
131
|
+
],
|
|
132
|
+
"text-shadow": [
|
|
133
|
+
"none"
|
|
134
|
+
],
|
|
135
|
+
"appearance": [
|
|
136
|
+
"none",
|
|
137
|
+
"auto"
|
|
138
|
+
],
|
|
139
|
+
"-webkit-appearance": [
|
|
140
|
+
"none",
|
|
141
|
+
"auto"
|
|
142
|
+
],
|
|
143
|
+
"overflow": [
|
|
144
|
+
"visible"
|
|
145
|
+
],
|
|
146
|
+
"cursor": [
|
|
147
|
+
"inherit",
|
|
148
|
+
"default"
|
|
149
|
+
],
|
|
150
|
+
"-webkit-tap-highlight-color": [
|
|
151
|
+
"transparent"
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
"replacedElements": [
|
|
155
|
+
"img",
|
|
156
|
+
"svg",
|
|
157
|
+
"video",
|
|
158
|
+
"canvas",
|
|
159
|
+
"audio",
|
|
160
|
+
"iframe",
|
|
161
|
+
"embed",
|
|
162
|
+
"object"
|
|
163
|
+
],
|
|
164
|
+
"replacedReset": {
|
|
165
|
+
"display": [
|
|
166
|
+
"block",
|
|
167
|
+
"inline-block"
|
|
168
|
+
],
|
|
169
|
+
"max-width": [
|
|
170
|
+
"100%"
|
|
171
|
+
],
|
|
172
|
+
"width": [
|
|
173
|
+
"auto"
|
|
174
|
+
],
|
|
175
|
+
"height": [
|
|
176
|
+
"auto"
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
"focus": [
|
|
180
|
+
"outline",
|
|
181
|
+
"outline-width",
|
|
182
|
+
"outline-style",
|
|
183
|
+
"outline-color",
|
|
184
|
+
"outline-offset"
|
|
185
|
+
],
|
|
186
|
+
"reducedMotion": [
|
|
187
|
+
"animation-duration",
|
|
188
|
+
"animation-delay",
|
|
189
|
+
"animation-iteration-count",
|
|
190
|
+
"transition-duration",
|
|
191
|
+
"transition-delay",
|
|
192
|
+
"scroll-behavior"
|
|
193
|
+
],
|
|
194
|
+
"selection": [
|
|
195
|
+
"color",
|
|
196
|
+
"background",
|
|
197
|
+
"background-color"
|
|
198
|
+
],
|
|
199
|
+
"scrollbar": [
|
|
200
|
+
"width",
|
|
201
|
+
"height",
|
|
202
|
+
"background",
|
|
203
|
+
"background-color",
|
|
204
|
+
"background-clip",
|
|
205
|
+
"border",
|
|
206
|
+
"border-width",
|
|
207
|
+
"border-style",
|
|
208
|
+
"border-color",
|
|
209
|
+
"border-radius"
|
|
210
|
+
]
|
|
211
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import selectorParser from "postcss-selector-parser";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
// CSS-language policy, not a project token catalogue or renderer classification.
|
|
5
|
+
const policy = JSON.parse(readFileSync(new URL("./contracts/foundation.json", import.meta.url), "utf8"));
|
|
6
|
+
const cssWide = /^(?:inherit|initial|unset|revert|revert-layer)$/i;
|
|
7
|
+
|
|
8
|
+
function rootAnchor(nodes) {
|
|
9
|
+
return nodes.some(node => node.type === "tag" && !node.namespace && node.value.toLowerCase() === "html"
|
|
10
|
+
|| node.type === "pseudo" && node.value.toLowerCase() === ":root"
|
|
11
|
+
|| node.type === "pseudo" && /^:(?:is|where)$/i.test(node.value) && node.nodes.length && node.nodes.every(selector => !selector.nodes.some(node => node.type === "combinator") && rootAnchor(selector.nodes)));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function rootCompound(node) {
|
|
15
|
+
for (let current = node; current.parent; current = current.parent) {
|
|
16
|
+
const parent = current.parent;
|
|
17
|
+
if (parent.type !== "selector") continue;
|
|
18
|
+
const index = parent.nodes.indexOf(current);
|
|
19
|
+
let start = index, end = index + 1;
|
|
20
|
+
while (start && parent.nodes[start - 1].type !== "combinator") --start;
|
|
21
|
+
while (end < parent.nodes.length && parent.nodes[end].type !== "combinator") ++end;
|
|
22
|
+
// A negative/relational argument cannot establish root context, but an
|
|
23
|
+
// explicit root outside it still can: :root:not([data-theme=light]).
|
|
24
|
+
const argument = parent.parent?.type === "pseudo" && !/^:(?:is|where)$/i.test(parent.parent.value);
|
|
25
|
+
if (!argument && rootAnchor(parent.nodes.slice(start, end))) return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Global defaults may address native elements and explicit document themes, not UI owners. */
|
|
31
|
+
export function foundationSelector(selector, { definitions = false } = {}) {
|
|
32
|
+
try {
|
|
33
|
+
let violation = false;
|
|
34
|
+
selectorParser(root => {
|
|
35
|
+
if (definitions && root.nodes.some(selector => selector.nodes.some(node => node.type === "combinator") || !rootAnchor(selector.nodes))) violation = true;
|
|
36
|
+
root.walk(node => {
|
|
37
|
+
if (["class", "id"].includes(node.type) && !rootCompound(node)) violation = true;
|
|
38
|
+
if (node.type === "tag" && node.value.includes("-")) violation = true;
|
|
39
|
+
if (node.type === "attribute" && /^(?:class|id|style|data-.+|aria-.+)$/i.test(node.attribute) && !rootCompound(node)) violation = true;
|
|
40
|
+
if (node.type === "pseudo" && node.value.toLowerCase() === ":has") violation = true;
|
|
41
|
+
if (node.type === "combinator") {
|
|
42
|
+
const before = node.parent.nodes.slice(0, node.parent.nodes.indexOf(node));
|
|
43
|
+
if (before.some(node => node.type === "combinator") || !rootAnchor(before)) violation = true;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}).processSync(selector);
|
|
47
|
+
return violation ? "Foundation defaults address native elements or explicit document-root themes. Component/pattern hooks, contextual layout and relational state selectors belong to their Linaria owner." : null;
|
|
48
|
+
} catch (error) { return `Invalid foundation selector: ${error.message}`; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function subjects(nodes) {
|
|
52
|
+
let variants = [[]];
|
|
53
|
+
for (const node of nodes) {
|
|
54
|
+
if (node.type === "pseudo" && /^:(?:where|is)$/i.test(node.value)) {
|
|
55
|
+
variants = variants.flatMap(prefix => node.nodes.flatMap(selector => subjects(selector.nodes).map(subject => [...prefix, ...subject])));
|
|
56
|
+
} else variants.forEach(subject => subject.push(node));
|
|
57
|
+
}
|
|
58
|
+
return variants;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function reducedMotion(rule) {
|
|
62
|
+
for (let parent = rule.parent; parent; parent = parent.parent) {
|
|
63
|
+
if (parent.type === "atrule" && parent.name.toLowerCase() === "media"
|
|
64
|
+
&& /^\(\s*prefers-reduced-motion\s*:\s*reduce\s*\)$/i.test(parent.params.trim())) return true;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function reset(property, value, nodes) {
|
|
70
|
+
const words = value.toLowerCase().split(/\s+/);
|
|
71
|
+
if (/^(?:margin|padding)(?:-(?:top|right|bottom|left|block|inline)(?:-(?:start|end))?)?$/.test(property)) return words.every(word => word === "0") || cssWide.test(value);
|
|
72
|
+
const tag = nodes.find(node => node.type === "tag")?.value.toLowerCase();
|
|
73
|
+
if (property === "font-weight" && /^(?:bolder|lighter)$/.test(value)) return ["b", "strong"].includes(tag);
|
|
74
|
+
if (policy.reset[property]) return policy.reset[property].includes(value.toLowerCase()) || cssWide.test(value);
|
|
75
|
+
if (policy.replacedElements.includes(tag) && policy.replacedReset[property]) return policy.replacedReset[property].includes(value.toLowerCase()) || cssWide.test(value);
|
|
76
|
+
return tag === "textarea" && property === "resize" && /^(?:vertical|inherit|initial|unset|revert(?:-layer)?)$/.test(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function roleAllows(rule, nodes, property, value) {
|
|
80
|
+
const pseudos = nodes.filter(node => node.type === "pseudo");
|
|
81
|
+
const tag = nodes.find(node => node.type === "tag")?.value.toLowerCase();
|
|
82
|
+
const universal = nodes.every(node => node.type === "universal" || node.type === "pseudo" || node.type === "comment");
|
|
83
|
+
if (universal && pseudos.some(node => node.value === ":focus-visible") && pseudos.every(node => node.value === ":focus-visible")) return policy.focus.includes(property) && !/^(?:none|0)$/.test(value);
|
|
84
|
+
if (universal && pseudos.some(node => node.value === "::selection") && pseudos.every(node => node.value === "::selection")) return policy.selection.includes(property);
|
|
85
|
+
if (universal && pseudos.every(node => ["::before", "::after"].includes(node.value)) && reducedMotion(rule)) {
|
|
86
|
+
if (policy.reducedMotion.includes(property)) return property !== "scroll-behavior" || value === "auto";
|
|
87
|
+
if (["animation", "transition"].includes(property) && value === "none") return true;
|
|
88
|
+
}
|
|
89
|
+
const document = (rootAnchor(nodes) || tag === "body") && pseudos.every(node => [":root", ":not"].includes(node.value));
|
|
90
|
+
if (document && policy.document.includes(property)) return true;
|
|
91
|
+
const documentScrollbar = (rootAnchor(nodes) || tag === "body") && pseudos.some(node => /^::-webkit-scrollbar(?:-thumb|-track)?$/.test(node.value))
|
|
92
|
+
&& pseudos.every(node => node.value === ":root" || /^::-webkit-scrollbar(?:-thumb|-track)?$/.test(node.value) || node.value === ":hover");
|
|
93
|
+
if (documentScrollbar) return policy.scrollbar.includes(property);
|
|
94
|
+
if (pseudos.some(node => node.value === "::placeholder") && pseudos.every(node => node.value === "::placeholder")) return property === "color" || property === "opacity" && value === "1";
|
|
95
|
+
if (pseudos.some(node => ![":root", ":not", "::before", "::after"].includes(node.value))) return false;
|
|
96
|
+
if (pseudos.some(node => node.value === ":not") && !rootAnchor(nodes)) return false;
|
|
97
|
+
return reset(property, value, nodes);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Closed foundation roles apply even when a native selector uses valid tokens. */
|
|
101
|
+
export function foundationDeclaration(rule, declaration) {
|
|
102
|
+
try {
|
|
103
|
+
const selectors = selectorParser().astSync(rule.selector);
|
|
104
|
+
const property = declaration.prop.toLowerCase(), value = declaration.value.trim();
|
|
105
|
+
for (const selector of selectors.nodes) {
|
|
106
|
+
const lastCombinator = selector.nodes.findLastIndex(node => node.type === "combinator");
|
|
107
|
+
for (let nodes of subjects(selector.nodes.slice(lastCombinator + 1))) {
|
|
108
|
+
nodes = nodes.slice(nodes.findLastIndex(node => node.type === "combinator") + 1);
|
|
109
|
+
if (!roleAllows(rule, nodes, property, value)) return "base.css permits document-root/body typography and canvas defaults, limited native normalization, global focus indicators and reduced-motion rules. Put control skins, layout and state styling in their Linaria owner.";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
} catch (error) { return `Invalid foundation declaration context: ${error.message}`; }
|
|
114
|
+
}
|
package/dist/init.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import ts from "typescript";
|
|
4
5
|
import { isInside, loadConfig, sourceFiles, supportedFrameworks } from "./config.mjs";
|
|
5
6
|
import { createLintConfig } from "./lint-config.mjs";
|
|
6
7
|
import { foundationFiles } from "./token-policy.mjs";
|
|
@@ -152,10 +153,16 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
|
|
|
152
153
|
});
|
|
153
154
|
if (!existingTokens.length) {
|
|
154
155
|
for (const name of ["Foundation.scenario.tsx", "Foundation.fixture.ts"]) {
|
|
155
|
-
|
|
156
|
-
if (selectedFramework === "preact" && name === "Foundation.scenario.tsx") content = content.replace('from "react"', 'from "preact/hooks"');
|
|
156
|
+
const content = readFileSync(path.join(starter, "design-system/tokens", name), "utf8").replaceAll('"@ds"', JSON.stringify(config.publicAlias));
|
|
157
157
|
addMissing(path.join(tokenRoot, name), content);
|
|
158
158
|
}
|
|
159
|
+
for (const name of ["TokenPreview.tsx", "Scenario.tsx", "fixture.ts"]) {
|
|
160
|
+
let content = readFileSync(path.join(starter, "design-system/patterns/token-preview", name), "utf8");
|
|
161
|
+
if (selectedFramework === "preact") content = content.replace('from "react"', 'from "preact/hooks"');
|
|
162
|
+
addMissing(path.join(dsRoot, "patterns/token-preview", name), content.replaceAll('"@ds"', JSON.stringify(config.publicAlias)));
|
|
163
|
+
}
|
|
164
|
+
const reviewFile = path.relative(root, path.join(dsRoot, "patterns/token-preview/TokenPreview.tsx")).replaceAll("\\", "/");
|
|
165
|
+
if (!config.boundaries.styleWrites.some(item => item.file === reviewFile)) config.boundaries.styleWrites.push({ file: reviewFile, properties: ["--ux-token-preview-value"] });
|
|
159
166
|
}
|
|
160
167
|
tokenFiles.sort((a, b) => foundationFiles.indexOf(path.basename(a)) - foundationFiles.indexOf(path.basename(b)) || a.localeCompare(b));
|
|
161
168
|
const stylesFile = path.join(dsRoot, config.stylesEntry);
|
|
@@ -165,7 +172,17 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
|
|
|
165
172
|
};
|
|
166
173
|
addMissing(stylesFile, tokenFiles.map((file) => `@import ${JSON.stringify(relativeImport(stylesFile, file))};`).join("\n") + "\n");
|
|
167
174
|
const entryFile = path.join(dsRoot, config.publicEntry);
|
|
168
|
-
|
|
175
|
+
const reviewExport = !existingTokens.length ? `export { TokenPreview } from ${JSON.stringify(relativeImport(entryFile, path.join(dsRoot, "patterns/token-preview/TokenPreview")))};\n` : "";
|
|
176
|
+
addMissing(entryFile, `// Export your project's public components and types here.\nimport ${JSON.stringify(relativeImport(entryFile, stylesFile))};\n${reviewExport}`);
|
|
177
|
+
if (reviewExport && existsSync(entryFile) && !readFileSync(entryFile, "utf8").includes("patterns/token-preview/TokenPreview")) {
|
|
178
|
+
let current = readFileSync(entryFile, "utf8");
|
|
179
|
+
if (/\bTokenPreview\b/.test(current)) throw new Error("TokenPreview is already owned by this project. Add a token review scenario using its public API before initializing the foundation.");
|
|
180
|
+
const ast = ts.createSourceFile(entryFile, current, ts.ScriptTarget.Latest, true);
|
|
181
|
+
for (const statement of [...ast.statements].reverse()) if (ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause) && !statement.exportClause.elements.length) {
|
|
182
|
+
current = current.slice(0, statement.getStart(ast)) + current.slice(statement.end);
|
|
183
|
+
}
|
|
184
|
+
updates.set(entryFile, current + "\n" + reviewExport);
|
|
185
|
+
}
|
|
169
186
|
for (const section of ["components", "patterns", "composition"]) addMissing(path.join(dsRoot, section, ".gitkeep"), "");
|
|
170
187
|
addMissing(path.join(dsRoot, "assets/brand/README.md"), readFileSync(path.join(starter, "design-system/assets/brand/README.md")));
|
|
171
188
|
addMissing(path.join(dsRoot, "assets/fonts/.gitkeep"), "");
|
|
@@ -175,9 +192,9 @@ export function initProject(directory = process.cwd(), { toolPackage, packageMan
|
|
|
175
192
|
else {
|
|
176
193
|
const original = readFileSync(configFile, "utf8");
|
|
177
194
|
const saved = JSON.parse(original);
|
|
178
|
-
if (saved.framework === undefined) {
|
|
195
|
+
if (saved.framework === undefined || !existingTokens.length) {
|
|
179
196
|
const indent = original.match(/\n([\t ]+)"/)?.[1] ?? " ";
|
|
180
|
-
updates.set(configFile, `${JSON.stringify({ ...saved, framework: selectedFramework }, null, indent)}\n`);
|
|
197
|
+
updates.set(configFile, `${JSON.stringify({ ...saved, framework: selectedFramework, ...(!existingTokens.length ? { boundaries: config.boundaries } : {}) }, null, indent)}\n`);
|
|
181
198
|
}
|
|
182
199
|
}
|
|
183
200
|
const lintConfigFile = path.join(root, ".ds/oxlintrc.json");
|
package/dist/mcp/apps.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const bridge = (project = false) => readFileSync(new URL(project ? "./project-wi
|
|
|
12
12
|
|
|
13
13
|
export function previewApp(selection) {
|
|
14
14
|
const script = readFileSync(new URL("./preview-widget.bundle.js", import.meta.url), "utf8").replaceAll("</script", "<\\/script");
|
|
15
|
-
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ADS preview</title><style>html,body,#root{height:100%;margin:0}body{overflow:
|
|
15
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ADS preview</title><style>html,body,#root{height:100%;margin:0}body{overflow:auto}#ads-preview-status{position:fixed;inset:12px 12px auto;z-index:2147483647;padding:12px;border:1px solid GrayText;background:Canvas;color:CanvasText;font:13px system-ui;white-space:pre-wrap}#ads-preview-status[hidden]{display:none}</style></head><body><aside id="ads-preview-status" role="status" hidden></aside><div id="root"></div>${selection ? `<script type="application/json" id="ads-preview-selection">${safeJson(selection)}</script>` : ""}<script type="module">${script}</script></body></html>`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function tokenWidgetData(inventory) {
|
|
@@ -44,7 +44,7 @@ export function projectApp(options, app, build) {
|
|
|
44
44
|
if (statSync(file).size > 2 * 1024 * 1024) throw new Error("Preview exceeds 2 MiB. Build a focused component/pattern example.");
|
|
45
45
|
const html = readFileSync(file, "utf8");
|
|
46
46
|
if (!/<\/body\s*>/i.test(html) || /<script\b[^>]*\bsrc\s*=|<link\b[^>]*\brel\s*=\s*["']?stylesheet\b/i.test(html)) throw new Error("Preview must be built as self-contained HTML, with JavaScript and styles inlined.");
|
|
47
|
-
const seed = { id: app.id, session: build?.session ?? null, revision: build?.current ? build.revision : null, state: build?.state ?? "unreported" };
|
|
47
|
+
const seed = { id: app.id, kind: app.kind, ...(app.samples ? { samples: app.samples } : {}), session: build?.session ?? null, revision: build?.current ? build.revision : null, state: build?.state ?? "unreported" };
|
|
48
48
|
return html.replace(/<\/body\s*>/i, () => `<script type="application/json" id="ads-preview-seed">${safeJson(seed)}</script><script type="module">${bridge(true)}</script></body>`);
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -8,9 +8,9 @@ import { readPreviewManifest } from "../preview-resources.mjs";
|
|
|
8
8
|
export const BUILD_URI = "ads://project/build";
|
|
9
9
|
export const buildStatusFile = (root) => path.join(root, ".ds/build-status.json");
|
|
10
10
|
const maxBytes = 256 * 1024;
|
|
11
|
-
const diagnostic = z.object({ message: z.string().max(4000), file: z.string().max(1000).optional(), line: z.number().int().positive().optional(), column: z.number().int().positive().optional(), plugin: z.string().max(200).optional() }).strict();
|
|
11
|
+
const diagnostic = z.object({ message: z.string().max(4000), file: z.string().max(1000).optional(), line: z.number().int().positive().optional(), column: z.number().int().positive().optional(), plugin: z.string().max(200).optional(), code: z.string().max(100).optional() }).strict();
|
|
12
12
|
const preview = z.object({ id: z.string().max(256), state: z.enum(["idle", "building", "ready", "error"]), revision: z.number().int().nonnegative(), lastSuccessfulRevision: z.number().int().positive().nullable(), artifactDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/).nullable(), diagnostics: z.array(diagnostic).max(20), diagnosticSet: z.number().int().nonnegative().optional() }).strict();
|
|
13
|
-
const snapshot = z.object({ schemaVersion: z.literal(1), session: z.string().regex(/^[\w-]{1,100}$/), sequence: z.number().int().nonnegative(), updatedAt: z.string().datetime(), previews: z.array(preview), diagnosticSets: z.array(z.array(diagnostic).max(20)).optional() }).strict().refine(value => value.previews.every(entry => entry.diagnosticSet === undefined || value.diagnosticSets?.[entry.diagnosticSet]), "Invalid shared diagnostic reference.");
|
|
13
|
+
const snapshot = z.object({ schemaVersion: z.literal(1), session: z.string().regex(/^[\w-]{1,100}$/), sequence: z.number().int().nonnegative(), updatedAt: z.string().datetime(), previews: z.array(preview), diagnostics: z.array(diagnostic).max(20).optional(), diagnosticSets: z.array(z.array(diagnostic).max(20)).optional() }).strict().refine(value => value.previews.every(entry => entry.diagnosticSet === undefined || value.diagnosticSets?.[entry.diagnosticSet]), "Invalid shared diagnostic reference.");
|
|
14
14
|
export const artifactDigest = (file) => {
|
|
15
15
|
if (realpathSync(file) !== file) throw new Error("Preview output cannot resolve through a symlink.");
|
|
16
16
|
if (statSync(file).size > 2 * 1024 * 1024) throw new Error("Preview exceeds 2 MiB.");
|
|
@@ -26,7 +26,7 @@ function normalizeDiagnostic(root, error) {
|
|
|
26
26
|
return { message: String(error?.message ?? error).slice(0, 4000),
|
|
27
27
|
...(file ? { file: (path.isAbsolute(file) ? path.relative(root, file) : file).replaceAll("\\", "/").slice(0, 1000) } : {}),
|
|
28
28
|
...(Number.isInteger(line) && line > 0 ? { line } : {}), ...(Number.isInteger(column) && column > 0 ? { column } : {}),
|
|
29
|
-
...(typeof error?.plugin === "string" ? { plugin: error.plugin.slice(0, 200) } : {}) };
|
|
29
|
+
...(typeof error?.plugin === "string" ? { plugin: error.plugin.slice(0, 200) } : {}), ...(typeof error?.code === "string" ? { code: error.code.slice(0, 100) } : {}) };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/** One publisher per project compiler; no dependency on a UI framework. */
|
|
@@ -36,8 +36,9 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
|
|
|
36
36
|
if (!isInside(root, target)) throw new Error("Build status must stay inside the project.");
|
|
37
37
|
const entries = new Map(apps.map((app) => [app.id, { id: app.id, state: "idle", revision: 0, lastSuccessfulRevision: null, artifactDigest: null, diagnostics: [] }]));
|
|
38
38
|
let sequence = 0;
|
|
39
|
+
let diagnostics = [];
|
|
39
40
|
function save() {
|
|
40
|
-
const value = { schemaVersion: 1, session, sequence: ++sequence, updatedAt: new Date().toISOString(), previews: [...entries.values()] };
|
|
41
|
+
const value = { schemaVersion: 1, session, sequence: ++sequence, updatedAt: new Date().toISOString(), previews: [...entries.values()], ...(diagnostics.length ? { diagnostics } : {}) };
|
|
41
42
|
let text = JSON.stringify(snapshot.parse(value));
|
|
42
43
|
// A shared graph failure applies to every scenario. Store repeated details
|
|
43
44
|
// once only when necessary; ordinary reports retain inline diagnostics.
|
|
@@ -66,6 +67,17 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
|
|
|
66
67
|
save();
|
|
67
68
|
return {
|
|
68
69
|
session,
|
|
70
|
+
failure(error) {
|
|
71
|
+
diagnostics = (Array.isArray(error) ? error : [error]).slice(0, 20).map(error => normalizeDiagnostic(root, error));
|
|
72
|
+
for (const value of entries.values()) { value.state = "error"; ++value.revision; value.diagnostics = []; }
|
|
73
|
+
save();
|
|
74
|
+
},
|
|
75
|
+
clearFailure() {
|
|
76
|
+
if (!diagnostics.length) return;
|
|
77
|
+
diagnostics = [];
|
|
78
|
+
for (const value of entries.values()) if (value.state === "error" && !value.diagnostics.length) value.state = "idle";
|
|
79
|
+
save();
|
|
80
|
+
},
|
|
69
81
|
setApps(next) {
|
|
70
82
|
const ids = new Set(next.map(app => app.id));
|
|
71
83
|
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
|
|
@@ -104,11 +116,11 @@ export function createBuildReporter(directory, { apps, session = process.env.ADS
|
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/** Read durable build events; a running process alone never means a fresh build. */
|
|
107
|
-
export function inspectBuildStatus(root, config, { session, processState } = {}) {
|
|
119
|
+
export function inspectBuildStatus(root, config, { session, processState, file = buildStatusFile(root) } = {}) {
|
|
108
120
|
let report;
|
|
109
121
|
let reportError;
|
|
110
|
-
const file = buildStatusFile(root);
|
|
111
122
|
try {
|
|
123
|
+
if (!isInside(root, path.resolve(file))) throw new Error("Build status must stay inside the project.");
|
|
112
124
|
if (existsSync(file)) {
|
|
113
125
|
if (realpathSync(file) !== file || statSync(file).size > maxBytes) throw new Error("Build status must be a real project file under 256 KiB.");
|
|
114
126
|
report = snapshot.parse(JSON.parse(readFileSync(file, "utf8")));
|
|
@@ -125,6 +137,7 @@ export function inspectBuildStatus(root, config, { session, processState } = {})
|
|
|
125
137
|
const value = report?.previews.find((entry) => entry.id === app.id);
|
|
126
138
|
let state = value?.state ?? "unreported";
|
|
127
139
|
let diagnostics = value?.diagnosticSet !== undefined ? report.diagnosticSets[value.diagnosticSet] : value?.diagnostics ?? [];
|
|
140
|
+
if (report?.diagnostics?.length) { state = "error"; diagnostics = [...report.diagnostics, ...diagnostics].slice(0, 20); }
|
|
128
141
|
const available = existsSync(path.resolve(root, app.file));
|
|
129
142
|
if (reportError) { state = "error"; diagnostics = [{ message: reportError }]; }
|
|
130
143
|
if (graphError && app.id.includes("/")) { state = "error"; diagnostics = [{ message: graphError }]; }
|
|
@@ -139,8 +152,8 @@ export function inspectBuildStatus(root, config, { session, processState } = {})
|
|
|
139
152
|
return { id: app.id, state, session: report?.session ?? null, revision: value?.revision ?? null, lastSuccessfulRevision: value?.lastSuccessfulRevision ?? null,
|
|
140
153
|
current, stale: state === "unreported" ? null : !current, available, diagnostics };
|
|
141
154
|
});
|
|
142
|
-
const state = previews.some((entry) => entry.state === "error") ? "error" : previews.some((entry) => entry.state === "building") ? "building" : previews.some((entry) => entry.state === "unreported") ? "unreported" : previews.some(entry => entry.current) ? "ready" : previews.length ? "idle" : "disabled";
|
|
143
|
-
return { state, reported: Boolean(report), session: report?.session ?? null, sequence: report?.sequence ?? null, updatedAt: report?.updatedAt ?? null, previews, resourceUri: BUILD_URI };
|
|
155
|
+
const state = reportError || report?.diagnostics?.length || previews.some((entry) => entry.state === "error") ? "error" : previews.some((entry) => entry.state === "building") ? "building" : previews.some((entry) => entry.state === "unreported") ? "unreported" : previews.some(entry => entry.current) ? "ready" : previews.length ? "idle" : "disabled";
|
|
156
|
+
return { state, reported: Boolean(report), session: report?.session ?? null, sequence: report?.sequence ?? null, updatedAt: report?.updatedAt ?? null, previews, ...(report?.diagnostics?.length ? { diagnostics: report.diagnostics } : {}), resourceUri: BUILD_URI };
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
export class PreviewBuildError extends Error {
|
|
@@ -154,7 +167,7 @@ export function summarizeBuild(build) {
|
|
|
154
167
|
return { ...value, diagnostics, diagnosticCount: value.diagnostics.length,
|
|
155
168
|
diagnosticsTruncated: value.diagnostics.length > 3 || value.diagnostics.some((entry) => entry.message.length > 1000) };
|
|
156
169
|
}
|
|
157
|
-
return build.previews ? { ...build, previews: build.previews.map(bounded) } : { ...bounded(build), resourceUri: BUILD_URI };
|
|
170
|
+
return build.previews ? { ...build, ...(build.diagnostics ? bounded(build) : {}), previews: build.previews.map(bounded) } : { ...bounded(build), resourceUri: BUILD_URI };
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
export function buildFailure(build) {
|
package/dist/mcp/context.mjs
CHANGED
|
@@ -56,7 +56,7 @@ Use this server for UI components/patterns, tokens/themes/branding, layout, inte
|
|
|
56
56
|
3. Select one workflow with ads_skills, then ads_read_skill. ${route.skills.map((skill) => `${skill.task}: ${skill.name}`).join("; ")}. Native skill activation is host-controlled. Relative skill references resolve from its SKILL.md URI. Read support files only when relevant.
|
|
57
57
|
4. ${route.preview} Token samples: ads_scenarios(kind: tokens), then ads_show_scenario or ads_show_tokens(category: id). Token source inspection: ads_tokens with a focused query. Registered previews: ${previews.map((preview) => `${preview.id}: ${preview.tool ?? "ads_show_scenario"}`).join(", ") || "none"}. Showcase URL: ${context.showcase.url ?? "not configured; use the project's showcase command"}.
|
|
58
58
|
Inspection connects a public export to its owning source, available guides, token values and discovered preview IDs. Bounded tools keep discovery focused; explicit resource reads supply detail. Source metadata does not prove layout, interaction or motion. A scenario renders the real public implementation and its fixture context in either review surface.
|
|
59
|
-
5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
|
|
59
|
+
5. Check ads_status.mutations. When enabled, discover one owner with ads_units, read relevant source ranges with ads_read_file and submit only a Git unified diff through ads_apply_patch({unit, patch, dryRun}). Include that owner's scenarios, fixtures and required public re-exports, never peer units. tokens is the foundation unit; its manifest lists companionFiles for required canonical foundation imports when token files are created/deleted in the same patch. Preserve unrelated imports/comments and public-entry statements, including export {};. Dry-run to inspect validation; apply only a passing patch. Fix rejected patches in the owning unit; report unsupported coupled changes instead of bypassing rejection with direct writes. Intentional project setup, registered guard/compiler code and exceptions use configuration; shared globals use foundation, guides documentation and assets their discovered assets/* unit. Keep mandatory ADS gates. Binary/large Git diffs may use patchFile under .ds/patches/ to avoid model-context bytes. In read-only mode, source edits use the client editor/filesystem tools. Reuse the local public API (${context.publicEntry}). Edit presentation in its ADS owner; keep application data/services/routing in application code and development fixtures beside their scenario; larger scenarios live in composition/. Linaria extracts owned styling. Inspect relevant tokens with ads_tokens and source/owning guides before edits.
|
|
60
60
|
6. ${route.validation} Registered commands: ${context.commands.map((command) => command.args?.join(" ") ?? `choose the project package manager for ${command.script}`).join("; ") || "none discovered; inspect the project's build setup"}. Run them from ${context.projectRoot}.
|
|
61
61
|
7. After edits, inspect ads_status.build or ads://project/build explicitly. Running/unreported/old output is not a current successful preview. Fix diagnostics in owned source, wait for rebuilding and invoke the preview tool again. Notifications may not start an agent turn.
|
|
62
62
|
|
|
@@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { statSync, mkdirSync, writeFileSync, renameSync, rmSync, realpathSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { buildFailure,
|
|
5
|
+
import { buildFailure, inspectBuildStatus } from "./build-status.mjs";
|
|
6
6
|
|
|
7
7
|
function stamp(file) {
|
|
8
8
|
try { const stat = statSync(file); return `${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`; }
|
|
@@ -13,7 +13,7 @@ function stamp(file) {
|
|
|
13
13
|
export function createPreviewWatch(root, config, { log = process.stderr, buildTimeout = 30000 } = {}) {
|
|
14
14
|
config = { ...config, apps: [...config.apps] };
|
|
15
15
|
const command = config.previewWatch;
|
|
16
|
-
|
|
16
|
+
let session = randomUUID();
|
|
17
17
|
let state = command ? "idle" : "disabled";
|
|
18
18
|
let child;
|
|
19
19
|
let error;
|
|
@@ -21,11 +21,14 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
21
21
|
let childClosed = false;
|
|
22
22
|
let killTimer;
|
|
23
23
|
let stopping;
|
|
24
|
+
let restarting;
|
|
25
|
+
let disposed = false;
|
|
24
26
|
const baselines = new Map();
|
|
25
27
|
const ready = new Set();
|
|
26
28
|
const requests = new Set();
|
|
27
29
|
let active;
|
|
28
|
-
|
|
30
|
+
let requestFile = path.join(root, ".ds", `preview-requests-${session}.json`);
|
|
31
|
+
let reportFile = path.join(root, ".ds", `build-status-${session}.json`);
|
|
29
32
|
function request(file) {
|
|
30
33
|
if (!command || requests.has(file) && active === file) return;
|
|
31
34
|
requests.add(file);
|
|
@@ -48,7 +51,7 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
48
51
|
} catch (failure) { if (failure.code !== "ESRCH") throw failure; }
|
|
49
52
|
}
|
|
50
53
|
function start() {
|
|
51
|
-
if (state !== "idle") return;
|
|
54
|
+
if (disposed || state !== "idle") return;
|
|
52
55
|
for (const app of config.apps) {
|
|
53
56
|
const file = path.resolve(root, app.file);
|
|
54
57
|
baselines.set(file, stamp(file));
|
|
@@ -57,7 +60,7 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
57
60
|
// No shell: configured arguments remain literal. Child stdin cannot consume
|
|
58
61
|
// the MCP channel, and both output streams go exclusively to diagnostics.
|
|
59
62
|
const executable = command.command === "node" ? process.execPath : command.command;
|
|
60
|
-
const env = { ...process.env, ADS_MCP_BUILD_SESSION: session, ADS_MCP_BUILD_STATUS:
|
|
63
|
+
const env = { ...process.env, ADS_MCP_BUILD_SESSION: session, ADS_MCP_BUILD_STATUS: reportFile, ADS_MCP_REQUEST_FILE: requestFile, PATH: [path.join(root, "node_modules/.bin"), path.dirname(process.execPath), process.env.PATH ?? ""].join(path.delimiter) };
|
|
61
64
|
try {
|
|
62
65
|
child = spawn(executable, command.args, { cwd: root, env, shell: false, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
63
66
|
} catch (failure) {
|
|
@@ -76,7 +79,7 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
76
79
|
clearTimeout(killTimer);
|
|
77
80
|
if (state !== "stopping") {
|
|
78
81
|
state = "failed";
|
|
79
|
-
error ??= `Preview watcher exited (${signal ?? code}).
|
|
82
|
+
error ??= `Preview watcher exited (${signal ?? code}). Fix the builder and invoke the preview again to restart it; MCP inspection remains available.`;
|
|
80
83
|
log.write(`ads mcp: ${error}\n`);
|
|
81
84
|
} else state = "stopped";
|
|
82
85
|
resolve();
|
|
@@ -91,16 +94,36 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
91
94
|
child.once("error", (failure) => { error = `Cannot start preview watcher: ${failure.message}`; });
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
function restart() {
|
|
98
|
+
return restarting ??= (async () => {
|
|
99
|
+
await stopChild();
|
|
100
|
+
if (disposed) return;
|
|
101
|
+
session = randomUUID();
|
|
102
|
+
requestFile = path.join(root, ".ds", `preview-requests-${session}.json`);
|
|
103
|
+
reportFile = path.join(root, ".ds", `build-status-${session}.json`);
|
|
104
|
+
state = "idle"; error = undefined; child = closed = stopping = undefined; childClosed = false;
|
|
105
|
+
ready.clear(); baselines.clear(); active = undefined;
|
|
106
|
+
start();
|
|
107
|
+
})().finally(() => { restarting = undefined; });
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
async function ensure(file, signal) {
|
|
111
|
+
if (signal?.aborted) throw new Error("Preview request cancelled.");
|
|
112
|
+
if (state === "failed" || restarting) await restart();
|
|
113
|
+
if (disposed) throw new Error("Preview watcher is stopped.");
|
|
95
114
|
start();
|
|
96
115
|
request(file);
|
|
97
116
|
if (signal?.aborted) throw new Error("Preview request cancelled.");
|
|
98
117
|
const app = config.apps.find((entry) => path.resolve(root, entry.file) === file);
|
|
99
|
-
|
|
118
|
+
let baseline = baselines.get(file) ?? null;
|
|
100
119
|
const deadline = Date.now() + buildTimeout;
|
|
120
|
+
let restarted = false;
|
|
101
121
|
while (Date.now() < deadline) {
|
|
102
122
|
if (signal?.aborted) throw new Error("Preview request cancelled.");
|
|
103
123
|
const build = buildStatus().previews.find((entry) => entry.id === app?.id);
|
|
124
|
+
if (!restarted && build?.diagnostics.some(item => item.code === "ADS_PREVIEW_RESTART")) {
|
|
125
|
+
restarted = true; await restart(); request(file); baseline = baselines.get(file) ?? null; continue;
|
|
126
|
+
}
|
|
104
127
|
if (build?.state === "error") throw buildFailure(build);
|
|
105
128
|
if (command && state !== "running") throw new Error(error ?? "Preview watcher is stopped.");
|
|
106
129
|
if (build?.current) return build;
|
|
@@ -113,23 +136,23 @@ export function createPreviewWatch(root, config, { log = process.stderr, buildTi
|
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
const status = () => ({ state, ...(child?.pid ? { pid: child.pid } : {}), ...(error ? { error } : {}) });
|
|
116
|
-
const buildStatus = () => inspectBuildStatus(root, config, command ? { session, processState: status() } : {});
|
|
139
|
+
const buildStatus = () => inspectBuildStatus(root, config, command ? { session, file: reportFile, processState: status() } : {});
|
|
117
140
|
|
|
118
|
-
async function
|
|
141
|
+
async function stopChild() {
|
|
119
142
|
rmSync(requestFile, { force: true });
|
|
120
143
|
if (stopping) return stopping;
|
|
121
|
-
if (!child || childClosed) { if (command) state = "stopped"; return; }
|
|
144
|
+
if (!child || childClosed) { if (command) state = "stopped"; rmSync(reportFile, { force: true }); return; }
|
|
122
145
|
if (state === "stopped") return;
|
|
123
146
|
state = "stopping";
|
|
124
147
|
stopping = (async () => {
|
|
125
148
|
kill("SIGTERM");
|
|
126
149
|
clearTimeout(killTimer);
|
|
127
150
|
const timer = setTimeout(() => kill("SIGKILL"), 1500);
|
|
128
|
-
try { await closed; } finally { clearTimeout(timer); }
|
|
151
|
+
try { await closed; } finally { clearTimeout(timer); rmSync(reportFile, { force: true }); }
|
|
129
152
|
})();
|
|
130
153
|
return stopping;
|
|
131
154
|
}
|
|
132
|
-
return { start, ensure, stop, kill: () => kill("SIGKILL"), status, buildStatus, setApps(apps) {
|
|
155
|
+
return { start, ensure, async stop() { disposed = true; await restarting; return stopChild(); }, kill: () => kill("SIGKILL"), status, buildStatus, setApps(apps) {
|
|
133
156
|
for (const app of apps) {
|
|
134
157
|
const file = path.resolve(root, app.file);
|
|
135
158
|
if (!baselines.has(file)) baselines.set(file, stamp(file));
|