@ywal123456/jskim 0.2.0 → 0.3.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.
@@ -0,0 +1,307 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const fse = require('fs-extra');
6
+ const fg = require('fast-glob');
7
+ const { computeRootPath } = require('./render-pages');
8
+ const { toDisplayPath } = require('./to-display-path');
9
+ const {
10
+ formatRenderError,
11
+ formatCollisionError,
12
+ formatPathOutsideError,
13
+ formatIoError,
14
+ } = require('./format-diagnostic');
15
+
16
+ const RESERVED_CONTEXT_KEYS = new Set(['rootPath']);
17
+
18
+ /**
19
+ * files pipeline を実行します。
20
+ * *.njk → render(末尾 .njk のみ削除)
21
+ * それ以外 → byte copy
22
+ *
23
+ * @param {object} options
24
+ * @param {import('nunjucks').Environment} options.env
25
+ * @param {object} options.project
26
+ * @returns {Promise<{ renderedCount: number, copiedCount: number, files: string[] }>}
27
+ */
28
+ async function processFiles({ env, project }) {
29
+ const { name, sourceDir, outputDir, files, templates, data } = project;
30
+ const rules = Array.isArray(files) ? files : [];
31
+ const templateRoots = resolveTemplateRoots(sourceDir, templates);
32
+
33
+ assertNoReservedDataCollision(data, name);
34
+
35
+ /** @type {Map<string, { sourceFile: string, outputFile: string, kind: string, relative: string, ruleIndex: number }>} */
36
+ const planned = new Map();
37
+
38
+ for (let i = 0; i < rules.length; i += 1) {
39
+ const rule = normalizeFilesRule(rules[i], name, i);
40
+ const fromDir = path.resolve(sourceDir, rule.from);
41
+ const toDir = path.resolve(outputDir, rule.to);
42
+
43
+ assertInside(sourceDir, fromDir, name, `files[${i}].from`, 'sourceDir', project.workspaceRoot);
44
+ assertInside(outputDir, toDir, name, `files[${i}].to`, 'outputDir', project.workspaceRoot);
45
+
46
+ if (!(await fse.pathExists(fromDir))) {
47
+ throw new Error(
48
+ `[JSKim] プロジェクト "${name}" の files[${i}].from が存在しません。\n` +
49
+ `設定キー: files[${i}].from\n` +
50
+ `設定値: ${rule.from}\n` +
51
+ `パス: ${toDisplayPath(fromDir, project.workspaceRoot)}`
52
+ );
53
+ }
54
+
55
+ const matches = await fg(rule.include, {
56
+ cwd: fromDir,
57
+ onlyFiles: true,
58
+ dot: false,
59
+ absolute: false,
60
+ ignore: rule.exclude,
61
+ });
62
+ matches.sort();
63
+
64
+ for (const relativeMatch of matches) {
65
+ const normalizedRel = relativeMatch.split(path.sep).join('/');
66
+ const sourceFile = path.join(fromDir, relativeMatch);
67
+
68
+ if (isInsideAny(templateRoots, sourceFile)) {
69
+ continue;
70
+ }
71
+
72
+ const isNjk = normalizedRel.endsWith('.njk');
73
+ const outRel = isNjk
74
+ ? stripTrailingNjk(normalizedRel)
75
+ : normalizedRel;
76
+ const outputFile = path.resolve(toDir, outRel.split('/').join(path.sep));
77
+
78
+ assertInside(
79
+ outputDir,
80
+ outputFile,
81
+ name,
82
+ `files[${i}] output`,
83
+ 'outputDir',
84
+ project.workspaceRoot
85
+ );
86
+
87
+ const collisionKey =
88
+ process.platform === 'win32'
89
+ ? outputFile.toLowerCase()
90
+ : outputFile;
91
+
92
+ if (planned.has(collisionKey)) {
93
+ const previous = planned.get(collisionKey);
94
+ throw new Error(
95
+ formatCollisionError({
96
+ projectName: name,
97
+ outputFile,
98
+ sourceFileA: previous.sourceFile,
99
+ sourceFileB: sourceFile,
100
+ ruleIndexA: previous.ruleIndex,
101
+ ruleIndexB: i,
102
+ workspaceRoot: project.workspaceRoot,
103
+ })
104
+ );
105
+ }
106
+
107
+ planned.set(collisionKey, {
108
+ sourceFile,
109
+ outputFile,
110
+ kind: isNjk ? 'render' : 'copy',
111
+ relative: normalizedRel,
112
+ ruleIndex: i,
113
+ });
114
+ }
115
+ }
116
+
117
+ let renderedCount = 0;
118
+ let copiedCount = 0;
119
+ const outputFiles = [];
120
+
121
+ for (const item of planned.values()) {
122
+ await fse.ensureDir(path.dirname(item.outputFile));
123
+
124
+ if (item.kind === 'copy') {
125
+ try {
126
+ await fse.copy(item.sourceFile, item.outputFile, { overwrite: true });
127
+ } catch (err) {
128
+ throw new Error(
129
+ formatIoError({
130
+ projectName: name,
131
+ actionLabel: 'ファイルコピー',
132
+ targetFile: item.sourceFile,
133
+ workspaceRoot: project.workspaceRoot,
134
+ err,
135
+ }) +
136
+ `\n出力: ${toDisplayPath(item.outputFile, project.workspaceRoot)}`
137
+ );
138
+ }
139
+ copiedCount += 1;
140
+ outputFiles.push(item.outputFile);
141
+ continue;
142
+ }
143
+
144
+ const templatePath = path
145
+ .relative(sourceDir, item.sourceFile)
146
+ .split(path.sep)
147
+ .join('/');
148
+ const rootPath = computeRootPath(item.outputFile, outputDir);
149
+ const context = buildRenderContext(data, rootPath);
150
+
151
+ let text;
152
+ try {
153
+ text = env.render(templatePath, context);
154
+ } catch (err) {
155
+ throw new Error(
156
+ formatRenderError({
157
+ projectName: name,
158
+ sourceFile: item.sourceFile,
159
+ templatePath,
160
+ workspaceRoot: project.workspaceRoot,
161
+ err,
162
+ })
163
+ );
164
+ }
165
+
166
+ try {
167
+ await fse.writeFile(item.outputFile, text, 'utf8');
168
+ } catch (err) {
169
+ throw new Error(
170
+ formatIoError({
171
+ projectName: name,
172
+ actionLabel: 'レンダリング結果の書き込み',
173
+ targetFile: item.outputFile,
174
+ workspaceRoot: project.workspaceRoot,
175
+ err,
176
+ })
177
+ );
178
+ }
179
+ renderedCount += 1;
180
+ outputFiles.push(item.outputFile);
181
+ }
182
+
183
+ return { renderedCount, copiedCount, files: outputFiles };
184
+ }
185
+
186
+ function normalizeFilesRule(rule, projectName, index) {
187
+ if (!rule || typeof rule !== 'object') {
188
+ throw new Error(
189
+ `[JSKim] プロジェクト "${projectName}" の files[${index}] が不正です。\n` +
190
+ `原因: 各 files ルールはオブジェクトである必要があります。`
191
+ );
192
+ }
193
+ if (!rule.from || String(rule.from).trim() === '') {
194
+ throw new Error(
195
+ `[JSKim] プロジェクト "${projectName}" の files[${index}].from が不正です。\n` +
196
+ `原因: from は必須です(sourceDir 基準)。`
197
+ );
198
+ }
199
+
200
+ const include = Array.isArray(rule.include) ? rule.include : ['**/*'];
201
+ if (include.length === 0) {
202
+ throw new Error(
203
+ `[JSKim] プロジェクト "${projectName}" の files[${index}].include が不正です。\n` +
204
+ `原因: include は空でない glob 配列である必要があります。`
205
+ );
206
+ }
207
+
208
+ const exclude = Array.isArray(rule.exclude) ? rule.exclude : [];
209
+
210
+ return {
211
+ from: String(rule.from).trim(),
212
+ to: rule.to == null ? '' : String(rule.to),
213
+ include: [...include],
214
+ exclude: [...exclude],
215
+ };
216
+ }
217
+
218
+ function stripTrailingNjk(relativePath) {
219
+ if (relativePath.endsWith('.njk')) {
220
+ return relativePath.slice(0, -4);
221
+ }
222
+ return relativePath;
223
+ }
224
+
225
+ function resolveTemplateRoots(sourceDir, templates) {
226
+ const roots = [];
227
+ for (const rel of Array.isArray(templates) ? templates : []) {
228
+ if (!rel) {
229
+ continue;
230
+ }
231
+ const abs = path.resolve(sourceDir, rel);
232
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
233
+ roots.push(abs);
234
+ }
235
+ }
236
+ return roots;
237
+ }
238
+
239
+ function isInsideAny(roots, candidate) {
240
+ for (const root of roots) {
241
+ if (isInsideOrSame(root, candidate)) {
242
+ return true;
243
+ }
244
+ }
245
+ return false;
246
+ }
247
+
248
+ function isInsideOrSame(parent, child) {
249
+ const p = path.resolve(parent);
250
+ const c = path.resolve(child);
251
+ if (samePath(p, c)) {
252
+ return true;
253
+ }
254
+ const rel = path.relative(p, c);
255
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
256
+ }
257
+
258
+ function samePath(a, b) {
259
+ const na = path.resolve(a);
260
+ const nb = path.resolve(b);
261
+ if (process.platform === 'win32') {
262
+ return na.toLowerCase() === nb.toLowerCase();
263
+ }
264
+ return na === nb;
265
+ }
266
+
267
+ function assertInside(root, candidate, projectName, label, rootKind, workspaceRoot) {
268
+ if (!isInsideOrSame(root, candidate)) {
269
+ throw new Error(
270
+ formatPathOutsideError({
271
+ projectName,
272
+ label,
273
+ root,
274
+ candidate,
275
+ rootKind,
276
+ workspaceRoot,
277
+ })
278
+ );
279
+ }
280
+ }
281
+
282
+ function assertNoReservedDataCollision(data, projectName) {
283
+ const keys = Object.keys(data || {});
284
+ for (const key of keys) {
285
+ if (RESERVED_CONTEXT_KEYS.has(key)) {
286
+ throw new Error(
287
+ `[JSKim] data のキーが予約語と衝突しています: ${key}\n` +
288
+ `プロジェクト: ${projectName}\n` +
289
+ `設定キー: data.${key}\n` +
290
+ `予約キー: ${[...RESERVED_CONTEXT_KEYS].join(', ')}`
291
+ );
292
+ }
293
+ }
294
+ }
295
+
296
+ function buildRenderContext(data, rootPath) {
297
+ return {
298
+ ...(data && typeof data === 'object' ? data : {}),
299
+ rootPath,
300
+ };
301
+ }
302
+
303
+ module.exports = {
304
+ processFiles,
305
+ stripTrailingNjk,
306
+ RESERVED_CONTEXT_KEYS,
307
+ };
@@ -3,6 +3,8 @@
3
3
  const path = require('node:path');
4
4
  const fse = require('fs-extra');
5
5
  const fg = require('fast-glob');
6
+ const { formatRenderError, formatIoError } = require('./format-diagnostic');
7
+ const { toDisplayPath } = require('./to-display-path');
6
8
 
7
9
  /**
8
10
  * 出力ファイルのディレクトリから outputDir までの相対パスとして rootPath を計算します。
@@ -39,7 +41,7 @@ function computeRootPath(outputFilePath, outputDir) {
39
41
  * @returns {Promise<{ renderedCount: number, files: string[] }>}
40
42
  */
41
43
  async function renderPages({ env, project }) {
42
- const { name, sourceDir, outputDir, render } = project;
44
+ const { name, sourceDir, outputDir, render, workspaceRoot } = project;
43
45
  let renderedCount = 0;
44
46
  const files = [];
45
47
 
@@ -54,8 +56,9 @@ async function renderPages({ env, project }) {
54
56
  if (!(await fse.pathExists(fromDir))) {
55
57
  throw new Error(
56
58
  `[JSKim] プロジェクト "${name}" の render[${i}].from が存在しません。\n` +
57
- `パス: ${fromDir}\n` +
58
- `設定: render[${i}].from = ${rule.from}`
59
+ `設定キー: render[${i}].from\n` +
60
+ `設定値: ${rule.from}\n` +
61
+ `パス: ${toDisplayPath(fromDir, workspaceRoot)}`
59
62
  );
60
63
  }
61
64
 
@@ -81,17 +84,37 @@ async function renderPages({ env, project }) {
81
84
  .join('/');
82
85
 
83
86
  const rootPath = computeRootPath(outputFile, outputDir);
87
+ const context = {
88
+ ...(project.data && typeof project.data === 'object'
89
+ ? project.data
90
+ : {}),
91
+ rootPath,
92
+ };
93
+
94
+ if (
95
+ project.data &&
96
+ Object.prototype.hasOwnProperty.call(project.data, 'rootPath')
97
+ ) {
98
+ throw new Error(
99
+ `[JSKim] data のキーが予約語と衝突しています: rootPath\n` +
100
+ `プロジェクト: ${name}\n` +
101
+ `設定キー: data.rootPath\n` +
102
+ `予約キー: rootPath`
103
+ );
104
+ }
84
105
 
85
106
  let html;
86
107
  try {
87
- html = env.render(templatePath, { rootPath });
108
+ html = env.render(templatePath, context);
88
109
  } catch (err) {
89
- const message = err && err.message ? err.message : String(err);
90
110
  throw new Error(
91
- `[JSKim] プロジェクト "${name}" の Nunjucks レンダリングに失敗しました。\n` +
92
- `ソース: ${sourceFile}\n` +
93
- `テンプレート: ${templatePath}\n` +
94
- `原因: ${message}`
111
+ formatRenderError({
112
+ projectName: name,
113
+ sourceFile,
114
+ templatePath,
115
+ workspaceRoot,
116
+ err,
117
+ })
95
118
  );
96
119
  }
97
120
 
@@ -99,11 +122,14 @@ async function renderPages({ env, project }) {
99
122
  await fse.ensureDir(path.dirname(outputFile));
100
123
  await fse.writeFile(outputFile, html, 'utf8');
101
124
  } catch (err) {
102
- const message = err && err.message ? err.message : String(err);
103
125
  throw new Error(
104
- `[JSKim] プロジェクト "${name}" のレンダリング結果の書き込みに失敗しました。\n` +
105
- `出力: ${outputFile}\n` +
106
- `原因: ${message}`
126
+ formatIoError({
127
+ projectName: name,
128
+ actionLabel: 'レンダリング結果の書き込み',
129
+ targetFile: outputFile,
130
+ workspaceRoot,
131
+ err,
132
+ })
107
133
  );
108
134
  }
109
135