@ywal123456/jskim 0.2.0 → 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.
@@ -7,13 +7,15 @@ const nunjucks = require('nunjucks');
7
7
  /**
8
8
  * プロジェクト用の Nunjucks 環境を作成します。
9
9
  * loader ルート: sourceDir + templates[](重複除去)。
10
+ * filters / globals を登録します。
10
11
  *
11
12
  * @param {object} options
12
13
  * @param {string} options.sourceDir
13
14
  * @param {string[]} options.templates
15
+ * @param {object} [options.nunjucks]
14
16
  * @returns {nunjucks.Environment}
15
17
  */
16
- function createNunjucksEnv({ sourceDir, templates }) {
18
+ function createNunjucksEnv({ sourceDir, templates, nunjucks: nunjucksConfig }) {
17
19
  const roots = [];
18
20
  const seen = new Set();
19
21
 
@@ -48,14 +50,50 @@ function createNunjucksEnv({ sourceDir, templates }) {
48
50
  noCache: true,
49
51
  });
50
52
 
53
+ // HTML 互換のため autoescape は維持する。
54
+ // JSON/JS 出力が必要な filter は SafeString を返すこと。
51
55
  const env = new nunjucks.Environment(loader, {
52
56
  autoescape: true,
53
57
  noCache: true,
54
58
  });
55
59
 
60
+ const config =
61
+ nunjucksConfig && typeof nunjucksConfig === 'object' ? nunjucksConfig : {};
62
+ registerFilters(env, config.filters || {});
63
+ registerGlobals(env, config.globals || {});
64
+
56
65
  return env;
57
66
  }
58
67
 
68
+ function registerFilters(env, filters) {
69
+ for (const [name, fn] of Object.entries(filters)) {
70
+ env.addFilter(name, wrapSyncCallable(fn, `filter:${name}`));
71
+ }
72
+ }
73
+
74
+ function registerGlobals(env, globals) {
75
+ for (const [name, value] of Object.entries(globals)) {
76
+ if (typeof value === 'function') {
77
+ env.addGlobal(name, wrapSyncCallable(value, `global:${name}`));
78
+ } else {
79
+ env.addGlobal(name, value);
80
+ }
81
+ }
82
+ }
83
+
84
+ function wrapSyncCallable(fn, label) {
85
+ return function wrapped(...args) {
86
+ const result = fn.apply(this, args);
87
+ if (result && typeof result.then === 'function') {
88
+ throw new Error(
89
+ `[JSKim] 非同期${label.startsWith('filter') ? 'filter' : 'global'}は現在サポートされていません。\n` +
90
+ `対象: ${label}`
91
+ );
92
+ }
93
+ return result;
94
+ };
95
+ }
96
+
59
97
  module.exports = {
60
98
  createNunjucksEnv,
61
99
  };
@@ -4,8 +4,8 @@
4
4
  * defaults とプロジェクト設定をマージします。
5
5
  *
6
6
  * - スカラー: プロジェクト側があれば優先
7
- * - オブジェクト (build / watch / serve / dev): 1段階の shallow merge
8
- * - 配列 (render / templates / copy): プロジェクト側配列が defaults を丸ごと置き換え
7
+ * - オブジェクト (build / watch / serve / dev / data / nunjucks): 1段階の shallow merge
8
+ * - 配列 (render / templates / copy / files): プロジェクト側配列が defaults を丸ごと置き換え
9
9
  *
10
10
  * 元の defaults / プロジェクトオブジェクトは変更しません。
11
11
  *
@@ -23,6 +23,9 @@ function mergeConfig(defaults, project) {
23
23
  render: pickArray(override.render, base.render, []),
24
24
  templates: pickArray(override.templates, base.templates, []),
25
25
  copy: pickArray(override.copy, base.copy, []),
26
+ files: pickOptionalArray(override.files, base.files),
27
+ data: mergePlainObject(base.data, override.data),
28
+ nunjucks: mergeNunjucks(base.nunjucks, override.nunjucks),
26
29
  build: mergeBuild(base.build, override.build),
27
30
  watch: mergeWatch(base.watch, override.watch),
28
31
  serve: mergeServe(base.serve, override.serve),
@@ -35,6 +38,19 @@ function mergeConfig(defaults, project) {
35
38
  render: merged.render.map((rule) => ({ ...rule })),
36
39
  templates: [...merged.templates],
37
40
  copy: merged.copy.map((rule) => ({ ...rule })),
41
+ files:
42
+ merged.files == null
43
+ ? null
44
+ : merged.files.map((rule) => ({
45
+ ...rule,
46
+ include: Array.isArray(rule.include) ? [...rule.include] : undefined,
47
+ exclude: Array.isArray(rule.exclude) ? [...rule.exclude] : undefined,
48
+ })),
49
+ data: isPlainObject(merged.data) ? { ...merged.data } : merged.data,
50
+ nunjucks: {
51
+ filters: { ...merged.nunjucks.filters },
52
+ globals: { ...merged.nunjucks.globals },
53
+ },
38
54
  build: { ...merged.build },
39
55
  watch: { ...merged.watch },
40
56
  serve: { ...merged.serve },
@@ -56,6 +72,75 @@ function pickArray(projectValue, defaultValue, fallback) {
56
72
  return fallback.slice();
57
73
  }
58
74
 
75
+ /**
76
+ * files 未設定を null で表し、legacy と区別します。
77
+ * @returns {object[]|null}
78
+ */
79
+ function pickOptionalArray(projectValue, defaultValue) {
80
+ if (Array.isArray(projectValue)) {
81
+ return projectValue.slice();
82
+ }
83
+ if (Array.isArray(defaultValue)) {
84
+ return defaultValue.slice();
85
+ }
86
+ return null;
87
+ }
88
+
89
+ function mergePlainObject(defaultValue, projectValue) {
90
+ if (projectValue !== undefined && !isPlainObject(projectValue)) {
91
+ return projectValue;
92
+ }
93
+ if (projectValue === undefined && defaultValue !== undefined && !isPlainObject(defaultValue)) {
94
+ return defaultValue;
95
+ }
96
+
97
+ const base = isPlainObject(defaultValue) ? defaultValue : {};
98
+ const override = isPlainObject(projectValue) ? projectValue : {};
99
+ return { ...base, ...override };
100
+ }
101
+
102
+ function isPlainObject(value) {
103
+ return value && typeof value === 'object' && !Array.isArray(value);
104
+ }
105
+
106
+ function mergeNunjucks(defaultValue, projectValue) {
107
+ const base =
108
+ defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)
109
+ ? defaultValue
110
+ : {};
111
+ const override =
112
+ projectValue && typeof projectValue === 'object' && !Array.isArray(projectValue)
113
+ ? projectValue
114
+ : {};
115
+
116
+ const baseFilters =
117
+ base.filters && typeof base.filters === 'object' && !Array.isArray(base.filters)
118
+ ? base.filters
119
+ : {};
120
+ const overrideFilters =
121
+ override.filters &&
122
+ typeof override.filters === 'object' &&
123
+ !Array.isArray(override.filters)
124
+ ? override.filters
125
+ : {};
126
+
127
+ const baseGlobals =
128
+ base.globals && typeof base.globals === 'object' && !Array.isArray(base.globals)
129
+ ? base.globals
130
+ : {};
131
+ const overrideGlobals =
132
+ override.globals &&
133
+ typeof override.globals === 'object' &&
134
+ !Array.isArray(override.globals)
135
+ ? override.globals
136
+ : {};
137
+
138
+ return {
139
+ filters: { ...baseFilters, ...overrideFilters },
140
+ globals: { ...baseGlobals, ...overrideGlobals },
141
+ };
142
+ }
143
+
59
144
  function mergeBuild(defaultBuild, projectBuild) {
60
145
  const base =
61
146
  defaultBuild && typeof defaultBuild === 'object' ? defaultBuild : {};
@@ -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