@ywal123456/jskim 0.1.1 → 0.3.0

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,263 @@
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
+
9
+ const RESERVED_CONTEXT_KEYS = new Set(['rootPath']);
10
+
11
+ /**
12
+ * files pipeline を実行します。
13
+ * *.njk → render(末尾 .njk のみ削除)
14
+ * それ以外 → byte copy
15
+ *
16
+ * @param {object} options
17
+ * @param {import('nunjucks').Environment} options.env
18
+ * @param {object} options.project
19
+ * @returns {Promise<{ renderedCount: number, copiedCount: number, files: string[] }>}
20
+ */
21
+ async function processFiles({ env, project }) {
22
+ const { name, sourceDir, outputDir, files, templates, data } = project;
23
+ const rules = Array.isArray(files) ? files : [];
24
+ const templateRoots = resolveTemplateRoots(sourceDir, templates);
25
+
26
+ assertNoReservedDataCollision(data, name);
27
+
28
+ /** @type {Map<string, { sourceFile: string, outputFile: string, kind: string, relative: string, ruleIndex: number }>} */
29
+ const planned = new Map();
30
+
31
+ for (let i = 0; i < rules.length; i += 1) {
32
+ const rule = normalizeFilesRule(rules[i], name, i);
33
+ const fromDir = path.resolve(sourceDir, rule.from);
34
+ const toDir = path.resolve(outputDir, rule.to);
35
+
36
+ assertInside(sourceDir, fromDir, name, `files[${i}].from`);
37
+ assertInside(outputDir, toDir, name, `files[${i}].to`);
38
+
39
+ if (!(await fse.pathExists(fromDir))) {
40
+ throw new Error(
41
+ `[JSKim] プロジェクト "${name}" の files[${i}].from が存在しません。\n` +
42
+ `パス: ${fromDir}\n` +
43
+ `設定: files[${i}].from = ${rule.from}`
44
+ );
45
+ }
46
+
47
+ const matches = await fg(rule.include, {
48
+ cwd: fromDir,
49
+ onlyFiles: true,
50
+ dot: false,
51
+ absolute: false,
52
+ ignore: rule.exclude,
53
+ });
54
+ matches.sort();
55
+
56
+ for (const relativeMatch of matches) {
57
+ const normalizedRel = relativeMatch.split(path.sep).join('/');
58
+ const sourceFile = path.join(fromDir, relativeMatch);
59
+
60
+ if (isInsideAny(templateRoots, sourceFile)) {
61
+ continue;
62
+ }
63
+
64
+ const isNjk = normalizedRel.endsWith('.njk');
65
+ const outRel = isNjk
66
+ ? stripTrailingNjk(normalizedRel)
67
+ : normalizedRel;
68
+ const outputFile = path.resolve(toDir, outRel.split('/').join(path.sep));
69
+
70
+ assertInside(outputDir, outputFile, name, `files[${i}] output`);
71
+
72
+ const collisionKey =
73
+ process.platform === 'win32'
74
+ ? outputFile.toLowerCase()
75
+ : outputFile;
76
+
77
+ if (planned.has(collisionKey)) {
78
+ const previous = planned.get(collisionKey);
79
+ throw new Error(
80
+ `[JSKim] 出力パスが衝突しています。\n` +
81
+ `プロジェクト: ${name}\n` +
82
+ `出力: ${toDisplay(outputFile, project.workspaceRoot)}\n` +
83
+ `ソース1: ${toDisplay(previous.sourceFile, project.workspaceRoot)}\n` +
84
+ `ソース2: ${toDisplay(sourceFile, project.workspaceRoot)}`
85
+ );
86
+ }
87
+
88
+ planned.set(collisionKey, {
89
+ sourceFile,
90
+ outputFile,
91
+ kind: isNjk ? 'render' : 'copy',
92
+ relative: normalizedRel,
93
+ ruleIndex: i,
94
+ });
95
+ }
96
+ }
97
+
98
+ let renderedCount = 0;
99
+ let copiedCount = 0;
100
+ const outputFiles = [];
101
+
102
+ for (const item of planned.values()) {
103
+ await fse.ensureDir(path.dirname(item.outputFile));
104
+
105
+ if (item.kind === 'copy') {
106
+ await fse.copy(item.sourceFile, item.outputFile, { overwrite: true });
107
+ copiedCount += 1;
108
+ outputFiles.push(item.outputFile);
109
+ continue;
110
+ }
111
+
112
+ const templatePath = path
113
+ .relative(sourceDir, item.sourceFile)
114
+ .split(path.sep)
115
+ .join('/');
116
+ const rootPath = computeRootPath(item.outputFile, outputDir);
117
+ const context = buildRenderContext(data, rootPath);
118
+
119
+ let text;
120
+ try {
121
+ text = env.render(templatePath, context);
122
+ } catch (err) {
123
+ const message = err && err.message ? err.message : String(err);
124
+ throw new Error(
125
+ `[JSKim] プロジェクト "${name}" の Nunjucks レンダリングに失敗しました。\n` +
126
+ `ソース: ${item.sourceFile}\n` +
127
+ `テンプレート: ${templatePath}\n` +
128
+ `原因: ${message}`
129
+ );
130
+ }
131
+
132
+ await fse.writeFile(item.outputFile, text, 'utf8');
133
+ renderedCount += 1;
134
+ outputFiles.push(item.outputFile);
135
+ }
136
+
137
+ return { renderedCount, copiedCount, files: outputFiles };
138
+ }
139
+
140
+ function normalizeFilesRule(rule, projectName, index) {
141
+ if (!rule || typeof rule !== 'object') {
142
+ throw new Error(
143
+ `[JSKim] プロジェクト "${projectName}" の files[${index}] が不正です。\n` +
144
+ `原因: 各 files ルールはオブジェクトである必要があります。`
145
+ );
146
+ }
147
+ if (!rule.from || String(rule.from).trim() === '') {
148
+ throw new Error(
149
+ `[JSKim] プロジェクト "${projectName}" の files[${index}].from が不正です。\n` +
150
+ `原因: from は必須です(sourceDir 基準)。`
151
+ );
152
+ }
153
+
154
+ const include = Array.isArray(rule.include) ? rule.include : ['**/*'];
155
+ if (include.length === 0) {
156
+ throw new Error(
157
+ `[JSKim] プロジェクト "${projectName}" の files[${index}].include が不正です。\n` +
158
+ `原因: include は空でない glob 配列である必要があります。`
159
+ );
160
+ }
161
+
162
+ const exclude = Array.isArray(rule.exclude) ? rule.exclude : [];
163
+
164
+ return {
165
+ from: String(rule.from).trim(),
166
+ to: rule.to == null ? '' : String(rule.to),
167
+ include: [...include],
168
+ exclude: [...exclude],
169
+ };
170
+ }
171
+
172
+ function stripTrailingNjk(relativePath) {
173
+ if (relativePath.endsWith('.njk')) {
174
+ return relativePath.slice(0, -4);
175
+ }
176
+ return relativePath;
177
+ }
178
+
179
+ function resolveTemplateRoots(sourceDir, templates) {
180
+ const roots = [];
181
+ for (const rel of Array.isArray(templates) ? templates : []) {
182
+ if (!rel) {
183
+ continue;
184
+ }
185
+ const abs = path.resolve(sourceDir, rel);
186
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
187
+ roots.push(abs);
188
+ }
189
+ }
190
+ return roots;
191
+ }
192
+
193
+ function isInsideAny(roots, candidate) {
194
+ for (const root of roots) {
195
+ if (isInsideOrSame(root, candidate)) {
196
+ return true;
197
+ }
198
+ }
199
+ return false;
200
+ }
201
+
202
+ function isInsideOrSame(parent, child) {
203
+ const p = path.resolve(parent);
204
+ const c = path.resolve(child);
205
+ if (samePath(p, c)) {
206
+ return true;
207
+ }
208
+ const rel = path.relative(p, c);
209
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
210
+ }
211
+
212
+ function samePath(a, b) {
213
+ const na = path.resolve(a);
214
+ const nb = path.resolve(b);
215
+ if (process.platform === 'win32') {
216
+ return na.toLowerCase() === nb.toLowerCase();
217
+ }
218
+ return na === nb;
219
+ }
220
+
221
+ function assertInside(root, candidate, projectName, label) {
222
+ if (!isInsideOrSame(root, candidate)) {
223
+ throw new Error(
224
+ `[JSKim] プロジェクト "${projectName}" の ${label} が許可範囲外です。\n` +
225
+ `ルート: ${root}\n` +
226
+ `対象: ${candidate}`
227
+ );
228
+ }
229
+ }
230
+
231
+ function assertNoReservedDataCollision(data, projectName) {
232
+ const keys = Object.keys(data || {});
233
+ for (const key of keys) {
234
+ if (RESERVED_CONTEXT_KEYS.has(key)) {
235
+ throw new Error(
236
+ `[JSKim] data のキーが予約語と衝突しています: ${key}\n` +
237
+ `プロジェクト: ${projectName}\n` +
238
+ `予約キー: ${[...RESERVED_CONTEXT_KEYS].join(', ')}`
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ function buildRenderContext(data, rootPath) {
245
+ return {
246
+ ...(data && typeof data === 'object' ? data : {}),
247
+ rootPath,
248
+ };
249
+ }
250
+
251
+ function toDisplay(abs, workspaceRoot) {
252
+ const rel = path.relative(workspaceRoot, abs);
253
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
254
+ return abs.split(path.sep).join('/');
255
+ }
256
+ return rel.split(path.sep).join('/');
257
+ }
258
+
259
+ module.exports = {
260
+ processFiles,
261
+ stripTrailingNjk,
262
+ RESERVED_CONTEXT_KEYS,
263
+ };
@@ -81,10 +81,26 @@ async function renderPages({ env, project }) {
81
81
  .join('/');
82
82
 
83
83
  const rootPath = computeRootPath(outputFile, outputDir);
84
+ const context = {
85
+ ...(project.data && typeof project.data === 'object'
86
+ ? project.data
87
+ : {}),
88
+ rootPath,
89
+ };
90
+
91
+ if (
92
+ project.data &&
93
+ Object.prototype.hasOwnProperty.call(project.data, 'rootPath')
94
+ ) {
95
+ throw new Error(
96
+ `[JSKim] data のキーが予約語と衝突しています: rootPath\n` +
97
+ `プロジェクト: ${name}`
98
+ );
99
+ }
84
100
 
85
101
  let html;
86
102
  try {
87
- html = env.render(templatePath, { rootPath });
103
+ html = env.render(templatePath, context);
88
104
  } catch (err) {
89
105
  const message = err && err.message ? err.message : String(err);
90
106
  throw new Error(
@@ -12,7 +12,7 @@ const { mergeConfig } = require('./merge-config');
12
12
  * @param {string} options.workspaceRoot
13
13
  * @param {string|undefined} options.projectName
14
14
  * @param {string} [options.commandName='build']
15
- * @param {string} [options.usageLine] プロジェクト名欠落時の使用方法行
15
+ * @param {string} [options.usageLine]
16
16
  * @returns {object} 解決済みプロジェクト
17
17
  */
18
18
  function resolveProject({
@@ -45,7 +45,8 @@ function resolveProject({
45
45
  );
46
46
  }
47
47
 
48
- const merged = mergeConfig(config.defaults || {}, projectConfig);
48
+ const defaults = config.defaults || {};
49
+ const merged = mergeConfig(defaults, projectConfig);
49
50
 
50
51
  if (!merged.sourceDir || String(merged.sourceDir).trim() === '') {
51
52
  throw new Error(
@@ -79,39 +80,42 @@ function resolveProject({
79
80
  );
80
81
  }
81
82
 
82
- if (!Array.isArray(merged.render) || merged.render.length === 0) {
83
+ validateData(merged.data, name);
84
+ validateNunjucks(merged.nunjucks, name);
85
+
86
+ const hasFiles = Array.isArray(merged.files) && merged.files.length > 0;
87
+ const hasRender = Array.isArray(merged.render) && merged.render.length > 0;
88
+ const hasCopy = Array.isArray(merged.copy) && merged.copy.length > 0;
89
+
90
+ if (hasFiles && (hasRender || hasCopy)) {
91
+ const conflict = [];
92
+ if (hasRender) {
93
+ conflict.push('render');
94
+ }
95
+ if (hasCopy) {
96
+ conflict.push('copy');
97
+ }
83
98
  throw new Error(
84
- `[JSKim] プロジェクト "${name}" render 設定が無い、または空です。\n` +
85
- `jskim.config.js の defaults.render または projects.${name}.render を定義してください。`
99
+ `[JSKim] files ${conflict.join(' / ')} を同時に設定できません。\n` +
100
+ `プロジェクト: ${name}\n` +
101
+ `files mode を使う場合は render / copy を空にしてください。\n` +
102
+ `legacy mode を使う場合は files を設定しないでください。`
86
103
  );
87
104
  }
88
105
 
89
- for (let i = 0; i < merged.render.length; i += 1) {
90
- const rule = merged.render[i];
91
- if (!rule || typeof rule !== 'object') {
92
- throw new Error(
93
- `[JSKim] プロジェクト "${name}" render[${i}] が不正です。\n` +
94
- `原因: render ルールはオブジェクトである必要があります。`
95
- );
96
- }
97
- if (!rule.from || String(rule.from).trim() === '') {
98
- throw new Error(
99
- `[JSKim] プロジェクト "${name}" の render[${i}].from が不正です。\n` +
100
- `原因: from は必須です(sourceDir 基準)。`
101
- );
102
- }
103
- if (!Array.isArray(rule.include) || rule.include.length === 0) {
104
- throw new Error(
105
- `[JSKim] プロジェクト "${name}" の render[${i}].include が不正です。\n` +
106
- `原因: include は空でない glob パターン配列である必要があります。`
107
- );
108
- }
109
- if (!rule.extension || String(rule.extension).trim() === '') {
106
+ const pipelineMode = hasFiles ? 'files' : 'legacy';
107
+
108
+ if (pipelineMode === 'files') {
109
+ validateFilesRules(merged.files, name);
110
+ } else {
111
+ if (!hasRender) {
110
112
  throw new Error(
111
- `[JSKim] プロジェクト "${name}" の render[${i}].extension が不正です。\n` +
112
- `原因: extension は必須です(例: ".html")。`
113
+ `[JSKim] プロジェクト "${name}" の render 設定が無い、または空です。\n` +
114
+ `jskim.config.js defaults.render または projects.${name}.render を定義してください。\n` +
115
+ `新しい files pipeline を使う場合は defaults.files を設定してください。`
113
116
  );
114
117
  }
118
+ validateRenderRules(merged.render, name);
115
119
  }
116
120
 
117
121
  validateWatchConfig(merged.watch, name);
@@ -124,9 +128,13 @@ function resolveProject({
124
128
  outputDir,
125
129
  sourceDirConfig: merged.sourceDir,
126
130
  outputDirConfig: merged.outputDir,
131
+ pipelineMode,
127
132
  render: merged.render,
128
133
  templates: merged.templates,
129
134
  copy: merged.copy,
135
+ files: merged.files || [],
136
+ data: merged.data,
137
+ nunjucks: merged.nunjucks,
130
138
  build: merged.build,
131
139
  watch: merged.watch,
132
140
  serve: merged.serve,
@@ -135,6 +143,122 @@ function resolveProject({
135
143
  };
136
144
  }
137
145
 
146
+ function validateFilesRules(files, projectName) {
147
+ for (let i = 0; i < files.length; i += 1) {
148
+ const rule = files[i];
149
+ if (!rule || typeof rule !== 'object') {
150
+ throw new Error(
151
+ `[JSKim] プロジェクト "${projectName}" の files[${i}] が不正です。\n` +
152
+ `原因: 各 files ルールはオブジェクトである必要があります。`
153
+ );
154
+ }
155
+ if (!rule.from || String(rule.from).trim() === '') {
156
+ throw new Error(
157
+ `[JSKim] プロジェクト "${projectName}" の files[${i}].from が不正です。\n` +
158
+ `原因: from は必須です(sourceDir 基準)。`
159
+ );
160
+ }
161
+ if (rule.include !== undefined) {
162
+ if (!Array.isArray(rule.include) || rule.include.length === 0) {
163
+ throw new Error(
164
+ `[JSKim] プロジェクト "${projectName}" の files[${i}].include が不正です。\n` +
165
+ `原因: include は空でない glob 配列である必要があります。`
166
+ );
167
+ }
168
+ }
169
+ if (rule.exclude !== undefined && !Array.isArray(rule.exclude)) {
170
+ throw new Error(
171
+ `[JSKim] プロジェクト "${projectName}" の files[${i}].exclude が不正です。\n` +
172
+ `原因: exclude は配列である必要があります。`
173
+ );
174
+ }
175
+ }
176
+ }
177
+
178
+ function validateRenderRules(render, projectName) {
179
+ for (let i = 0; i < render.length; i += 1) {
180
+ const rule = render[i];
181
+ if (!rule || typeof rule !== 'object') {
182
+ throw new Error(
183
+ `[JSKim] プロジェクト "${projectName}" の render[${i}] が不正です。\n` +
184
+ `原因: 各 render ルールはオブジェクトである必要があります。`
185
+ );
186
+ }
187
+ if (!rule.from || String(rule.from).trim() === '') {
188
+ throw new Error(
189
+ `[JSKim] プロジェクト "${projectName}" の render[${i}].from が不正です。\n` +
190
+ `原因: from は必須です(sourceDir 基準)。`
191
+ );
192
+ }
193
+ if (!Array.isArray(rule.include) || rule.include.length === 0) {
194
+ throw new Error(
195
+ `[JSKim] プロジェクト "${projectName}" の render[${i}].include が不正です。\n` +
196
+ `原因: include は空でない glob パターン配列である必要があります。`
197
+ );
198
+ }
199
+ if (!rule.extension || String(rule.extension).trim() === '') {
200
+ throw new Error(
201
+ `[JSKim] プロジェクト "${projectName}" の render[${i}].extension が不正です。\n` +
202
+ `原因: extension は必須です(例: ".html")。`
203
+ );
204
+ }
205
+ }
206
+ }
207
+
208
+ function validateData(data, projectName) {
209
+ if (data == null) {
210
+ return;
211
+ }
212
+ if (typeof data !== 'object' || Array.isArray(data)) {
213
+ throw new Error(
214
+ `[JSKim] 設定値が不正です: data\n` +
215
+ `プロジェクト: ${projectName}\n` +
216
+ `plain object を指定してください(null / array / primitive は不可)。\n` +
217
+ `受け取った値: ${Array.isArray(data) ? 'array' : typeof data}`
218
+ );
219
+ }
220
+ }
221
+
222
+ function validateNunjucks(nunjucks, projectName) {
223
+ if (nunjucks == null) {
224
+ return;
225
+ }
226
+ if (typeof nunjucks !== 'object' || Array.isArray(nunjucks)) {
227
+ throw new Error(
228
+ `[JSKim] 設定値が不正です: nunjucks\n` +
229
+ `プロジェクト: ${projectName}\n` +
230
+ `plain object を指定してください。`
231
+ );
232
+ }
233
+
234
+ const filters = nunjucks.filters || {};
235
+ if (typeof filters !== 'object' || Array.isArray(filters)) {
236
+ throw new Error(
237
+ `[JSKim] 設定値が不正です: nunjucks.filters\n` +
238
+ `プロジェクト: ${projectName}\n` +
239
+ `plain object を指定してください。`
240
+ );
241
+ }
242
+ for (const [key, value] of Object.entries(filters)) {
243
+ if (typeof value !== 'function') {
244
+ throw new Error(
245
+ `[JSKim] 設定値が不正です: nunjucks.filters.${key}\n` +
246
+ `プロジェクト: ${projectName}\n` +
247
+ `filter は function である必要があります。`
248
+ );
249
+ }
250
+ }
251
+
252
+ const globals = nunjucks.globals || {};
253
+ if (typeof globals !== 'object' || Array.isArray(globals)) {
254
+ throw new Error(
255
+ `[JSKim] 設定値が不正です: nunjucks.globals\n` +
256
+ `プロジェクト: ${projectName}\n` +
257
+ `plain object を指定してください。`
258
+ );
259
+ }
260
+ }
261
+
138
262
  function validateWatchConfig(watch, projectName) {
139
263
  const debounce = watch && watch.debounce;
140
264
 
@@ -5,15 +5,25 @@ const path = require('node:path');
5
5
 
6
6
  /**
7
7
  * 解決済みプロジェクト設定から監視ディレクトリを計算します。
8
- * sourceDir 配下の render[].from / templates[] / copy[].from を監視します。
8
+ * files mode: files[].from / templates[]
9
+ * legacy mode: render[].from / templates[] / copy[].from
9
10
  * outputDir / dist / node_modules は監視しません。
10
11
  *
11
12
  * @param {object} project
12
13
  * @returns {{ absolutePaths: string[], displayPaths: string[] }}
13
14
  */
14
15
  function resolveWatchPaths(project) {
15
- const { name, sourceDir, outputDir, workspaceRoot, render, templates, copy } =
16
- project;
16
+ const {
17
+ name,
18
+ sourceDir,
19
+ outputDir,
20
+ workspaceRoot,
21
+ render,
22
+ templates,
23
+ copy,
24
+ files,
25
+ pipelineMode,
26
+ } = project;
17
27
 
18
28
  const absolutePaths = [];
19
29
  const seen = new Set();
@@ -39,9 +49,23 @@ function resolveWatchPaths(project) {
39
49
  absolutePaths.push(resolved);
40
50
  }
41
51
 
42
- for (const rule of render) {
43
- if (rule && rule.from) {
44
- addCandidate(path.join(sourceDir, rule.from));
52
+ if (pipelineMode === 'files') {
53
+ for (const rule of Array.isArray(files) ? files : []) {
54
+ if (rule && rule.from) {
55
+ addCandidate(path.join(sourceDir, rule.from));
56
+ }
57
+ }
58
+ } else {
59
+ for (const rule of Array.isArray(render) ? render : []) {
60
+ if (rule && rule.from) {
61
+ addCandidate(path.join(sourceDir, rule.from));
62
+ }
63
+ }
64
+
65
+ for (const rule of Array.isArray(copy) ? copy : []) {
66
+ if (rule && rule.from) {
67
+ addCandidate(path.join(sourceDir, rule.from));
68
+ }
45
69
  }
46
70
  }
47
71
 
@@ -51,16 +75,10 @@ function resolveWatchPaths(project) {
51
75
  }
52
76
  }
53
77
 
54
- for (const rule of Array.isArray(copy) ? copy : []) {
55
- if (rule && rule.from) {
56
- addCandidate(path.join(sourceDir, rule.from));
57
- }
58
- }
59
-
60
78
  if (absolutePaths.length === 0) {
61
79
  throw new Error(
62
80
  `[JSKim] プロジェクト "${name}" の監視パスがありません。\n` +
63
- `原因: render[].from / templates[] / copy[].from のいずれも既存パスに解決できませんでした。\n` +
81
+ `原因: files[].from / render[].from / templates[] / copy[].from のいずれも既存パスに解決できませんでした。\n` +
64
82
  `sourceDir: ${sourceDir}`
65
83
  );
66
84
  }