@zeropress/build-core 0.6.13 → 0.7.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/README.md +145 -43
- package/package.json +8 -7
- package/src/api/build-site.js +969 -405
- package/src/assets/asset-processor.js +0 -13
- package/src/index.d.ts +43 -11
- package/src/render/content-renderer.js +98 -168
- package/src/render/control-flow-renderer.js +5 -2
- package/src/render/variable-resolver.js +6 -1
- package/src/render/zeropress-engine.js +3 -1
- package/src/theme/format-theme-validation.js +54 -0
- package/src/theme/load-theme-dir.js +134 -68
|
@@ -1,15 +1,51 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
validateThemeFiles,
|
|
3
|
+
validateThemePackageLimits,
|
|
4
|
+
} from '@zeropress/theme-validator';
|
|
5
|
+
import { formatThemeValidationFailure } from './format-theme-validation.js';
|
|
2
6
|
|
|
3
7
|
const TEXT_FILE_EXTENSIONS = new Set(['.html', '.json', '.css', '.js', '.txt', '.svg', '.xml']);
|
|
4
8
|
|
|
5
9
|
export async function loadThemePackageFromDir(themeDir) {
|
|
6
10
|
const fs = await import('node:fs/promises');
|
|
11
|
+
const { constants: fsConstants } = await import('node:fs');
|
|
7
12
|
const path = await import('node:path');
|
|
13
|
+
if (typeof themeDir !== 'string' || !themeDir.trim()) {
|
|
14
|
+
throw new Error('Theme directory must be a non-empty path');
|
|
15
|
+
}
|
|
16
|
+
const requestedThemeDir = path.resolve(themeDir);
|
|
8
17
|
|
|
9
|
-
const
|
|
10
|
-
|
|
18
|
+
const rootStat = await fs.lstat(requestedThemeDir);
|
|
19
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
20
|
+
throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
|
|
21
|
+
}
|
|
22
|
+
const canonicalThemeDir = await fs.realpath(requestedThemeDir);
|
|
23
|
+
const confirmedRootStat = await fs.lstat(requestedThemeDir);
|
|
24
|
+
if (confirmedRootStat.isSymbolicLink() || !confirmedRootStat.isDirectory()) {
|
|
25
|
+
throw new Error(`Theme directory must be a real directory and must not be a symbolic link: ${requestedThemeDir}`);
|
|
26
|
+
}
|
|
11
27
|
|
|
12
|
-
const
|
|
28
|
+
const fileMap = new Map();
|
|
29
|
+
const packageState = {
|
|
30
|
+
entryCount: 0,
|
|
31
|
+
fileSizes: new Map(),
|
|
32
|
+
pathEntries: [],
|
|
33
|
+
seenCanonicalPaths: new Map(),
|
|
34
|
+
};
|
|
35
|
+
await readThemeDir(
|
|
36
|
+
fs,
|
|
37
|
+
fsConstants,
|
|
38
|
+
path,
|
|
39
|
+
canonicalThemeDir,
|
|
40
|
+
canonicalThemeDir,
|
|
41
|
+
fileMap,
|
|
42
|
+
packageState,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const validation = await validateThemeFiles(fileMap, {
|
|
46
|
+
entryCount: packageState.entryCount,
|
|
47
|
+
pathEntries: packageState.pathEntries,
|
|
48
|
+
});
|
|
13
49
|
if (!validation.ok) {
|
|
14
50
|
throw new Error(formatThemeValidationFailure(validation));
|
|
15
51
|
}
|
|
@@ -58,82 +94,112 @@ export async function loadThemePackageFromDir(themeDir) {
|
|
|
58
94
|
};
|
|
59
95
|
}
|
|
60
96
|
|
|
61
|
-
function
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
...validation.errors.map((issue) => formatThemeValidationIssue(issue)),
|
|
69
|
-
];
|
|
70
|
-
return blocks.join('\n\n');
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function formatThemeValidationIssue(issue) {
|
|
74
|
-
if (!issue) {
|
|
75
|
-
return 'Reason: Unknown error';
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const lines = [`ERROR ${issue.code || 'THEME_VALIDATION_ERROR'}`];
|
|
79
|
-
const location = splitIssuePath(issue.path);
|
|
80
|
-
if (location.file) {
|
|
81
|
-
lines.push(`File: ${location.file}`);
|
|
82
|
-
}
|
|
83
|
-
if (location.path) {
|
|
84
|
-
lines.push(`Path: ${location.path}`);
|
|
85
|
-
}
|
|
86
|
-
if (Number.isInteger(issue.line) && Number.isInteger(issue.column)) {
|
|
87
|
-
lines.push(`Line: ${issue.line}, Column: ${issue.column}`);
|
|
88
|
-
}
|
|
89
|
-
if (issue.category) {
|
|
90
|
-
lines.push(`Category: ${issue.category}`);
|
|
91
|
-
}
|
|
92
|
-
lines.push(`Reason: ${issue.message || 'Unknown error'}`);
|
|
93
|
-
if (issue.snippet) {
|
|
94
|
-
const lineLabel = Number.isInteger(issue.line) ? String(issue.line) : '';
|
|
95
|
-
lines.push('', `${lineLabel} | ${issue.snippet.line}`, `${' '.repeat(lineLabel.length)} | ${issue.snippet.pointer}`);
|
|
96
|
-
}
|
|
97
|
-
if (issue.hint) {
|
|
98
|
-
lines.push('', 'Hint:', issue.hint);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return lines.join('\n');
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function splitIssuePath(issuePath) {
|
|
105
|
-
const normalizedPath = String(issuePath || '');
|
|
106
|
-
if (normalizedPath.startsWith('theme.json.')) {
|
|
107
|
-
return {
|
|
108
|
-
file: 'theme.json',
|
|
109
|
-
path: normalizedPath.slice('theme.json.'.length),
|
|
110
|
-
};
|
|
97
|
+
async function readThemeDir(fs, fsConstants, path, rootDir, currentDir, fileMap, packageState) {
|
|
98
|
+
const entries = [];
|
|
99
|
+
const directory = await fs.opendir(currentDir);
|
|
100
|
+
for await (const entry of directory) {
|
|
101
|
+
packageState.entryCount += 1;
|
|
102
|
+
assertThemePackageLimits(packageState);
|
|
103
|
+
entries.push(entry);
|
|
111
104
|
}
|
|
112
|
-
|
|
113
|
-
return { file: normalizedPath, path: '' };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function readThemeDir(fs, path, rootDir, currentDir, fileMap) {
|
|
117
|
-
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
105
|
+
entries.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
118
106
|
|
|
119
107
|
for (const entry of entries) {
|
|
120
108
|
const fullPath = path.join(currentDir, entry.name);
|
|
121
|
-
const
|
|
109
|
+
const rawRelativePath = path.relative(rootDir, fullPath);
|
|
110
|
+
const relativePath = rawRelativePath.replace(/\\/g, '/');
|
|
111
|
+
if (entry.name.includes('\\')) {
|
|
112
|
+
throw createThemePathError(
|
|
113
|
+
'PATH_ESCAPE',
|
|
114
|
+
rawRelativePath,
|
|
115
|
+
`Backslashes are not allowed in theme package paths: ${rawRelativePath}`,
|
|
116
|
+
packageState.fileSizes.size,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const collisionKey = relativePath.normalize('NFC').toLowerCase();
|
|
120
|
+
const existingPath = packageState.seenCanonicalPaths.get(collisionKey);
|
|
121
|
+
if (existingPath !== undefined) {
|
|
122
|
+
throw createThemePathError(
|
|
123
|
+
'THEME_PATH_COLLISION',
|
|
124
|
+
relativePath,
|
|
125
|
+
`Theme package path collision: '${relativePath}' conflicts with '${existingPath}' after NFC and case normalization`,
|
|
126
|
+
packageState.fileSizes.size,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
packageState.seenCanonicalPaths.set(collisionKey, relativePath);
|
|
130
|
+
const stat = await fs.lstat(fullPath);
|
|
131
|
+
const isSymlink = stat.isSymbolicLink();
|
|
132
|
+
packageState.pathEntries.push({ path: relativePath, isSymlink });
|
|
133
|
+
|
|
134
|
+
if (isSymlink) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
122
137
|
|
|
123
|
-
if (
|
|
124
|
-
await readThemeDir(fs, path, rootDir, fullPath, fileMap);
|
|
138
|
+
if (stat.isDirectory()) {
|
|
139
|
+
await readThemeDir(fs, fsConstants, path, rootDir, fullPath, fileMap, packageState);
|
|
125
140
|
continue;
|
|
126
141
|
}
|
|
127
142
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
if (!stat.isFile()) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const noFollow = fsConstants.O_NOFOLLOW || 0;
|
|
148
|
+
let handle;
|
|
149
|
+
try {
|
|
150
|
+
handle = await fs.open(fullPath, fsConstants.O_RDONLY | noFollow);
|
|
151
|
+
const openedStat = await handle.stat();
|
|
152
|
+
if (!openedStat.isFile()) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
packageState.fileSizes.set(relativePath, openedStat.size);
|
|
156
|
+
assertThemePackageLimits(packageState);
|
|
157
|
+
|
|
158
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
159
|
+
if (TEXT_FILE_EXTENSIONS.has(ext)) {
|
|
160
|
+
fileMap.set(relativePath, await handle.readFile('utf8'));
|
|
161
|
+
} else {
|
|
162
|
+
fileMap.set(relativePath, new Uint8Array(await handle.readFile()));
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error?.code === 'ELOOP') {
|
|
166
|
+
packageState.pathEntries[packageState.pathEntries.length - 1].isSymlink = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
} finally {
|
|
171
|
+
await handle?.close();
|
|
133
172
|
}
|
|
134
173
|
}
|
|
135
174
|
}
|
|
136
175
|
|
|
176
|
+
function createThemePathError(code, filePath, message, checkedFiles) {
|
|
177
|
+
return new Error(formatThemeValidationFailure({
|
|
178
|
+
errors: [{
|
|
179
|
+
code,
|
|
180
|
+
path: filePath,
|
|
181
|
+
message,
|
|
182
|
+
severity: 'error',
|
|
183
|
+
category: 'theme_package_paths',
|
|
184
|
+
}],
|
|
185
|
+
checkedFiles,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertThemePackageLimits(packageState) {
|
|
190
|
+
const errors = validateThemePackageLimits(packageState.fileSizes, {
|
|
191
|
+
entryCount: packageState.entryCount,
|
|
192
|
+
});
|
|
193
|
+
if (errors.length === 0) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(formatThemeValidationFailure({
|
|
198
|
+
errors,
|
|
199
|
+
checkedFiles: packageState.fileSizes.size,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
137
203
|
function toUint8Array(value) {
|
|
138
204
|
if (value instanceof Uint8Array) {
|
|
139
205
|
return value;
|