Skip to content

Commit fdbcc6e

Browse files
committed
feat: warn on stale JS output blocks
1 parent d998263 commit fdbcc6e

2 files changed

Lines changed: 250 additions & 1 deletion

File tree

scripts/__tests__/test-examples.test.mjs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,83 @@ test("run compiles a real example block from an injected workspace", () => {
5454
/module M_0 = \{[\s\S]*let greeting = "hello"/,
5555
);
5656
});
57+
58+
test("warns about stale JS Output blocks without rewriting the file", () => {
59+
let fixture = `# Demo
60+
61+
<div className="hidden">
62+
63+
\`\`\`res prelude
64+
@val external alert: string => unit = "alert"
65+
\`\`\`
66+
67+
</div>
68+
69+
<CodeTab labels={["ReScript", "JS Output"]}>
70+
71+
\`\`\`res example
72+
alert("hello")
73+
\`\`\`
74+
75+
\`\`\`js
76+
console.log("stale");
77+
\`\`\`
78+
79+
</CodeTab>
80+
`;
81+
82+
let { docsRoot, tempRoot, file } = makeWorkspace(fixture);
83+
let { logger, warnings } = makeLogger();
84+
85+
let result = run({ docsRoot, tempRoot, logger });
86+
let nextContent = fs.readFileSync(file, "utf8");
87+
88+
assert.equal(result.success, true);
89+
assert.equal(result.warningCount, 1);
90+
assert.match(warnings[0], /sample\.mdx/);
91+
assert.match(warnings[0], /--update/);
92+
assert.match(nextContent, /console\.log\("stale"\);/);
93+
});
94+
95+
test("ignores standalone javascript fences outside a matching CodeTab", () => {
96+
let fixture = `# Demo
97+
98+
\`\`\`res example
99+
let value = 1
100+
\`\`\`
101+
102+
\`\`\`js
103+
console.log("leave me alone");
104+
\`\`\`
105+
`;
106+
107+
let { docsRoot, tempRoot } = makeWorkspace(fixture);
108+
let { logger } = makeLogger();
109+
110+
let result = run({ docsRoot, tempRoot, logger });
111+
112+
assert.equal(result.success, true);
113+
assert.equal(result.warningCount, 0);
114+
});
115+
116+
test("warns and skips malformed CodeTabs that never provide a JS Output fence", () => {
117+
let fixture = `# Demo
118+
119+
<CodeTab labels={["ReScript", "JS Output"]}>
120+
121+
\`\`\`res example
122+
let value = 1
123+
\`\`\`
124+
125+
</CodeTab>
126+
`;
127+
128+
let { docsRoot, tempRoot } = makeWorkspace(fixture);
129+
let { logger, warnings } = makeLogger();
130+
131+
let result = run({ docsRoot, tempRoot, logger });
132+
133+
assert.equal(result.success, true);
134+
assert.equal(result.warningCount, 1);
135+
assert.match(warnings[0], /missing paired JS Output block/);
136+
});

scripts/test-examples.mjs

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,151 @@ let parseFile = (content) => {
6262
.join("\n");
6363
};
6464

65+
let splitLines = (content) => content.split("\n");
66+
67+
let fenceKind = (line) => {
68+
if (line.startsWith("```res")) {
69+
return "res";
70+
}
71+
72+
if (line.startsWith("```js") || line.startsWith("```javascript")) {
73+
return "js";
74+
}
75+
76+
return null;
77+
};
78+
79+
let collectPreludeBlocks = (content) => {
80+
let lines = splitLines(content);
81+
let preludes = [];
82+
83+
for (let i = 0; i < lines.length; i++) {
84+
if (!lines[i].startsWith("```res prelude")) {
85+
continue;
86+
}
87+
88+
let start = i + 1;
89+
let end = start;
90+
while (end < lines.length && !lines[end].startsWith("```")) {
91+
end++;
92+
}
93+
94+
preludes.push({
95+
line: i + 1,
96+
content: lines.slice(start, end).join("\n"),
97+
});
98+
99+
i = end;
100+
}
101+
102+
return preludes;
103+
};
104+
105+
let collectCodeTabPairs = (content) => {
106+
let lines = splitLines(content);
107+
let pairs = [];
108+
let warnings = [];
109+
let inTargetTab = false;
110+
let pendingRes = null;
111+
112+
for (let i = 0; i < lines.length; i++) {
113+
let line = lines[i];
114+
115+
if (line.includes('<CodeTab labels={["ReScript", "JS Output"]}>')) {
116+
inTargetTab = true;
117+
pendingRes = null;
118+
continue;
119+
}
120+
121+
if (inTargetTab && line.includes("</CodeTab>")) {
122+
if (pendingRes != null) {
123+
warnings.push({
124+
line: pendingRes.line,
125+
message: "missing paired JS Output block",
126+
});
127+
}
128+
129+
inTargetTab = false;
130+
pendingRes = null;
131+
continue;
132+
}
133+
134+
if (!inTargetTab) {
135+
continue;
136+
}
137+
138+
let kind = fenceKind(line);
139+
140+
if (kind === "res") {
141+
let start = i + 1;
142+
let end = start;
143+
while (end < lines.length && !lines[end].startsWith("```")) {
144+
end++;
145+
}
146+
147+
pendingRes = {
148+
line: i + 1,
149+
content: lines.slice(start, end).join("\n"),
150+
};
151+
i = end;
152+
continue;
153+
}
154+
155+
if (kind === "js" && pendingRes != null) {
156+
let start = i + 1;
157+
let end = start;
158+
while (end < lines.length && !lines[end].startsWith("```")) {
159+
end++;
160+
}
161+
162+
pairs.push({
163+
line: pendingRes.line,
164+
res: pendingRes,
165+
js: {
166+
content: lines.slice(start, end).join("\n"),
167+
},
168+
});
169+
170+
pendingRes = null;
171+
i = end;
172+
}
173+
}
174+
175+
return { pairs, warnings };
176+
};
177+
178+
let stripCompilerBoilerplate = (output) => {
179+
let normalized = output.replace(
180+
/^\/\/ Generated by ReScript, PLEASE EDIT WITH CARE\n'use strict';\n\n/,
181+
"",
182+
);
183+
184+
return normalized.replace(/\n\/\*.*\*\/\s*$/s, "").trimEnd();
185+
};
186+
187+
let buildSnippetSource = ({ preludes, pair }) => {
188+
let visiblePreludes = preludes
189+
.filter((prelude) => prelude.line < pair.line)
190+
.map((prelude) => prelude.content)
191+
.filter(Boolean);
192+
193+
return [...visiblePreludes, pair.res.content].filter(Boolean).join("\n\n");
194+
};
195+
196+
let compileSnippet = (tempRoot, source) => {
197+
fs.writeFileSync(path.join(tempRoot, "src", "_tempFile.res"), source);
198+
child_process.execFileSync(
199+
"npm",
200+
["exec", "rescript", "build", tempRoot, "--", "--quiet"],
201+
{
202+
cwd: projectRoot,
203+
stdio: "pipe",
204+
},
205+
);
206+
207+
return fs.readFileSync(path.join(tempRoot, "src", "_tempFile.js"), "utf8");
208+
};
209+
65210
let ensureTempProject = (tempRoot) => {
66211
fs.mkdirSync(path.join(tempRoot, "src"), { recursive: true });
67212
fs.writeFileSync(path.join(tempRoot, "rescript.json"), rescriptJson);
@@ -87,6 +232,7 @@ export let run = ({
87232
ensureTempProject(tempRoot);
88233

89234
let success = true;
235+
let warningCount = 0;
90236

91237
globSync("{manual,react}/**/*.mdx", {
92238
cwd: docsRoot,
@@ -111,10 +257,33 @@ export let run = ({
111257
);
112258
} catch {
113259
success = false;
260+
return;
261+
}
262+
263+
let preludes = collectPreludeBlocks(content);
264+
let { pairs, warnings: malformedWarnings } = collectCodeTabPairs(content);
265+
266+
for (let warning of malformedWarnings) {
267+
logger.warn(`${file}:${warning.line} ${warning.message}`);
268+
warningCount++;
269+
}
270+
271+
for (let pair of pairs) {
272+
let snippetSource = buildSnippetSource({ preludes, pair });
273+
let compiledJs = compileSnippet(tempRoot, snippetSource);
274+
let expectedJs = stripCompilerBoilerplate(compiledJs);
275+
let currentJs = pair.js.content.trimEnd();
276+
277+
if (expectedJs !== currentJs) {
278+
logger.warn(
279+
`${file}:${pair.line} JS Output is stale. Run scripts/test-examples.mjs --update`,
280+
);
281+
warningCount++;
282+
}
114283
}
115284
});
116285

117-
return { success, warningCount: 0 };
286+
return { success, warningCount };
118287
};
119288

120289
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {

0 commit comments

Comments
 (0)