Skip to content

Commit b3eced8

Browse files
authored
feat(docs/ci): add automatic formatting checks for code blocks in the Cookbook (#2980)
1 parent ec9b04c commit b3eced8

File tree

13 files changed

+370
-267
lines changed

13 files changed

+370
-267
lines changed

.github/workflows/tact-docs-test.yml

+5-1
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,14 @@ jobs:
5050
yarn install
5151
yarn build:fast
5252
53-
- name: Perform syntax and type checking of the Cookbook
53+
- name: Perform syntax and type checking of Tact examples in the Cookbook
5454
working-directory: docs
5555
run: node scripts/typecheck-examples.js
5656

57+
- name: Check formatting of Tact examples in the Cookbook
58+
working-directory: docs
59+
run: node scripts/fmt-check-examples.js
60+
5761
- name: Install dependencies
5862
working-directory: docs
5963
run: yarn deps

dev-docs/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Docs
1111

12+
- Enabled format checking across the Cookbook: PR [#2980](https://github.com/tact-lang/tact/pull/2980)
1213
- Added references to https://github.com/tact-lang/defi-cookbook: PR [#2985](https://github.com/tact-lang/tact/pull/2985)
1314

1415
### Release contributors

docs/scripts/common.js

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import { spawnSync } from 'node:child_process';
2+
import { tmpdir } from 'node:os';
3+
import {
4+
mkdtempSync,
5+
readFileSync,
6+
writeFileSync,
7+
readdirSync,
8+
statSync,
9+
} from 'node:fs';
10+
import { chdir, cwd } from 'node:process';
11+
import { dirname } from 'node:path';
12+
import { fileURLToPath } from 'node:url';
13+
14+
// TODO(?): Use git for this instead, since scripts/ might move some day
15+
// and CI might change the starting working directory as well
16+
// chdir(`${__dirname}/../../`); // docs, presumably
17+
18+
/*******************/
19+
/* Utility helpers */
20+
/*******************/
21+
22+
/** The `__dirname` replacement for ESM */
23+
export const __dirname = dirname(fileURLToPath(import.meta.url));
24+
25+
/** Default directory for temporary files with / separator (because even PowerShell can use direct slash) */
26+
export const globalTmpDir = tmpdir() + '/';
27+
28+
/**
29+
* Obtains the list of files with target extension in the target directory and its
30+
* sub-directories as a flat array of names.
31+
*
32+
* @param dir {string | undefined} defaults to "." (current directory)
33+
* @param extension {string | undefined} defaults to any file
34+
* @returns {string[]}
35+
*/
36+
export const getFileNames = (dir, extension) => {
37+
/**
38+
* @param dir {string | undefined}
39+
* @param extension {string | undefined}
40+
* @returns {string[]}
41+
*/
42+
const recGetFileNames = (dir, extension, _files) => {
43+
_files = _files || [];
44+
let files = readdirSync(dir);
45+
for (let i in files) {
46+
let name = dir + '/' + files[i];
47+
if (statSync(name).isDirectory()) {
48+
recGetFileNames(name, extension, _files);
49+
continue;
50+
}
51+
if (extension === undefined || name.endsWith(extension)) {
52+
_files.push(name.trim());
53+
}
54+
}
55+
return _files;
56+
};
57+
58+
return recGetFileNames(dir ?? ".", extension);
59+
};
60+
61+
/**
62+
* @param src {string} source of the .md or .mdx file to extract code blocks from
63+
* @returns {string[]} all Tact code blocks on the page ready to be processed
64+
*/
65+
export const extractTactCodeBlocks = (src) => {
66+
/** @type RegExpExecArray[] */
67+
const regexMatches = [...src.matchAll(/```(\w*).*?\n([\s\S]*?)```/gm)];
68+
/** @type string[] */
69+
let res = [];
70+
71+
for (let i = 0; i < regexMatches.length; i += 1) {
72+
// Skip non-Tact matches
73+
if (regexMatches[i].at(1)?.trim() !== "tact") {
74+
continue;
75+
}
76+
77+
// Guard the contents
78+
let code = regexMatches[i].at(2)?.trimStart();
79+
// let code = regexMatches[i].at(2)?.trim();
80+
if (code === undefined || code.length === 0) {
81+
console.log(`Error: regex failed when processing code blocks of:\n\n${src}`);
82+
process.exit(1);
83+
}
84+
85+
// See if the `code` needs additional wrapping in a global function or not
86+
// i.e. if it doesn't contain any module-level items (implicit convention in Tact docs)
87+
// This approach is rather brittle, but good enough for the time being.
88+
const moduleItems = code.split('\n').filter((line) => {
89+
const matchRes = line.match(/^\s*(?:import|primitive|const|asm|fun|extends|mutates|virtual|override|inline|abstract|@name|@interface|contract|trait|struct(?!\s*:)|message(?!\s*(?::|\(\s*M|\(\s*\))))\b/);
90+
91+
if (matchRes === null) { return false; }
92+
else { return true; }
93+
});
94+
95+
if (moduleItems.length === 0) {
96+
// The extra manipulations below are needed to keep the code blocks well-formatted
97+
let lines = code.split('\n');
98+
lines.pop();
99+
const linesCollected = lines
100+
.map(line => line.trim() ? ' ' + line : '')
101+
.join('\n');
102+
code = `fun showcase() {\n${linesCollected}\n}\n`;
103+
}
104+
105+
// Save the code
106+
res.push(code);
107+
}
108+
109+
return res;
110+
};
111+
112+
/**
113+
* @param filepaths {string[]} an array of paths to .mdx files
114+
* @param actionCheck {(filepath: string) => { ok: true } | { ok: false, error: string }}
115+
*/
116+
export const processMdxFiles = (filepaths, actionCheck) => {
117+
for (const filepath of filepaths) {
118+
const file = readFileSync(filepath, { encoding: 'utf8' });
119+
const codeBlocks = extractTactCodeBlocks(file);
120+
const tmpDirForCurrentPage = mkdtempSync(globalTmpDir);
121+
const pageName = filepath.slice(
122+
filepath.lastIndexOf('/') + 1,
123+
filepath.lastIndexOf('.mdx'),
124+
);
125+
126+
for (let j = 0; j < codeBlocks.length; j += 1) {
127+
const tactFilePath = `${tmpDirForCurrentPage}/${pageName}-block-${(j + 1).toString()}.tact`;
128+
writeFileSync(tactFilePath, codeBlocks[j], { encoding: 'utf8', mode: '644' });
129+
console.log(`Checking ${tactFilePath}`);
130+
131+
// TODO(?):
132+
// An alternative solution to individual checks
133+
// would be to prepare a tact.config.json on the fly
134+
const savedCwd = cwd();
135+
chdir(tmpDirForCurrentPage);
136+
137+
// Perform individual checks
138+
const checkRes = actionCheck(tactFilePath);
139+
chdir(savedCwd);
140+
141+
if (checkRes.ok === false) {
142+
// NOTE: This line is handy for debugging
143+
// console.log(readFileSync(tactFilePath).toString());
144+
console.log(`Error: check of ${tactFilePath} has failed:\n\n${checkRes.error}`);
145+
process.exit(1);
146+
}
147+
}
148+
}
149+
};
150+
151+
/*****************************/
152+
/* Actions or checks to take */
153+
/*****************************/
154+
155+
/**
156+
* @requires Node.js 22+ with npm installed
157+
* @param filepath {string} a path to Tact file
158+
* @returns {{ ok: true } | { ok: false, error: string }}
159+
*/
160+
export const actionTypecheckTactFile = (filepath) => {
161+
// Using the built Tact compiler from the parent folder to
162+
// 1. Ensure everything still builds
163+
// 2. Prevent excessive compiler downloads in CI
164+
const res = spawnSync('node',
165+
[`${__dirname}/../../bin/tact.js`, '--check', filepath],
166+
{ encoding: 'utf8' }
167+
);
168+
169+
if (res.status !== 0) {
170+
return {
171+
ok: false,
172+
error: res.stdout + res.stderr,
173+
};
174+
}
175+
176+
return { ok: true };
177+
};
178+
179+
/**
180+
* @requires Node.js 22+ with npm installed
181+
* @param filepath {string} a path to Tact file
182+
* @returns {{ ok: true } | { ok: false, error: string }}
183+
*/
184+
export const actionCheckFmtTactFile = (filepath) => {
185+
const res = spawnSync('node',
186+
[`${__dirname}/../../bin/tact-fmt.js`, '--check', filepath],
187+
{ encoding: 'utf8' }
188+
);
189+
190+
if (res.status !== 0) {
191+
return {
192+
ok: false,
193+
error: res.stdout + res.stderr,
194+
};
195+
}
196+
197+
return { ok: true };
198+
};

docs/scripts/fmt-check-examples.js

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*─────────────────────────────────────────────────────────────────────────────╗
2+
│ IMPORTANT: │
3+
│ Run this script from the root of the docs, not from the scripts directory! │
4+
╞══════════════════════════════════════════════════════════════════════════════╡
5+
│ The script: │
6+
│ 1. Goes over every file in Cookbook │
7+
│ 2. Extracts the Tact code blocks from them │
8+
│ 3. For every code block, it runs the latest version of the Tact formatter │
9+
│ from the main branch, performing the necessary checks │
10+
│ 4. If there are any errors, outputs them and exits │
11+
╚─────────────────────────────────────────────────────────────────────────────*/
12+
13+
import { existsSync } from 'node:fs';
14+
import {
15+
getFileNames,
16+
actionCheckFmtTactFile,
17+
processMdxFiles,
18+
} from './common.js';
19+
20+
/** @type string */
21+
const cookbookPath = "src/content/docs/cookbook";
22+
23+
if (!existsSync(cookbookPath)) {
24+
console.log(`Error: path ${cookbookPath} doesn't exist, ensure that you're in the right directory!`);
25+
process.exit(1);
26+
}
27+
28+
/** @type string[] */
29+
const mdxFileNames = getFileNames(cookbookPath, ".mdx");
30+
processMdxFiles(mdxFileNames, actionCheckFmtTactFile);

0 commit comments

Comments
 (0)