@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.
@@ -7,23 +7,17 @@ const { createNunjucksEnv } = require('./create-nunjucks-env');
7
7
  const { cleanOutput } = require('./clean-output');
8
8
  const { renderPages } = require('./render-pages');
9
9
  const { copyFiles } = require('./copy-files');
10
+ const { processFiles } = require('./process-files');
10
11
 
11
12
  /**
12
13
  * 設定を読み込み、プロジェクトを解決してフルビルドを実行します。
13
14
  *
14
15
  * @param {string|undefined} projectName
15
16
  * @param {object} [options]
16
- * @param {string} [options.workspaceRoot]
17
- * @param {string} [options.commandName='build']
18
- * @param {string} [options.usageLine]
19
- * @param {string} [options.logTitle='ビルドが完了しました']
20
- * @param {boolean} [options.includeOutput=true]
21
- * @param {boolean} [options.log=true]
22
17
  * @returns {Promise<object>}
23
18
  */
24
19
  async function buildProject(projectName, options = {}) {
25
- const workspaceRoot =
26
- options.workspaceRoot || process.cwd();
20
+ const workspaceRoot = options.workspaceRoot || process.cwd();
27
21
  const commandName = options.commandName || 'build';
28
22
 
29
23
  const { config } = loadConfig(workspaceRoot);
@@ -39,7 +33,7 @@ async function buildProject(projectName, options = {}) {
39
33
  }
40
34
 
41
35
  /**
42
- * 解決済みプロジェクトに対して clean → render → copy を実行します。
36
+ * 解決済みプロジェクトに対して clean → pipeline を実行します。
43
37
  *
44
38
  * @param {object} project
45
39
  * @param {object} [options]
@@ -62,10 +56,22 @@ async function runBuild(project, options = {}) {
62
56
  const env = createNunjucksEnv({
63
57
  sourceDir: project.sourceDir,
64
58
  templates: project.templates,
59
+ nunjucks: project.nunjucks,
65
60
  });
66
61
 
67
- const { renderedCount } = await renderPages({ env, project });
68
- const { copiedCount } = await copyFiles({ project });
62
+ let renderedCount = 0;
63
+ let copiedCount = 0;
64
+
65
+ if (project.pipelineMode === 'files') {
66
+ const result = await processFiles({ env, project });
67
+ renderedCount = result.renderedCount;
68
+ copiedCount = result.copiedCount;
69
+ } else {
70
+ const rendered = await renderPages({ env, project });
71
+ const copied = await copyFiles({ project });
72
+ renderedCount = rendered.renderedCount;
73
+ copiedCount = copied.copiedCount;
74
+ }
69
75
 
70
76
  const outputDisplay = path
71
77
  .relative(project.workspaceRoot, project.outputDir)
@@ -92,7 +98,7 @@ function printBuildResult(result, options = {}) {
92
98
 
93
99
  console.log(`[JSKim] ${logTitle}`);
94
100
  console.log(`プロジェクト: ${result.project.name}`);
95
- console.log(`レンダリングしたページ数: ${result.renderedCount}`);
101
+ console.log(`レンダリングしたファイル数: ${result.renderedCount}`);
96
102
  console.log(`コピーしたファイル数: ${result.copiedCount}`);
97
103
  if (includeOutput) {
98
104
  console.log(`出力先: ${result.outputDisplay}`);
@@ -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,51 @@ 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}\n` +
91
+ `同期の function のみ登録できます。`
92
+ );
93
+ }
94
+ return result;
95
+ };
96
+ }
97
+
59
98
  module.exports = {
60
99
  createNunjucksEnv,
61
100
  };
@@ -184,9 +184,9 @@ function createProjectWatcher(project, options = {}) {
184
184
  `[JSKim] プロジェクト "${project.name}" の初回ビルドに失敗しました。ウォッチャーは開始します。`
185
185
  );
186
186
  } else {
187
- const representative = events[events.length - 1] || null;
188
187
  console.error(`[JSKim] 再ビルドに失敗しました`);
189
188
  console.error(`プロジェクト: ${project.name}`);
189
+ const representative = events[events.length - 1] || null;
190
190
  if (representative) {
191
191
  console.error(`イベント: ${representative.event}`);
192
192
  console.error(`ファイル: ${representative.file}`);
@@ -194,7 +194,12 @@ function createProjectWatcher(project, options = {}) {
194
194
  if (events.length > 1) {
195
195
  console.error(`変更数: ${events.length} ファイル`);
196
196
  }
197
- console.error(`原因: ${message}`);
197
+ // 診断付きメッセージは重複ヘッダーを避けるためそのまま出力する
198
+ if (String(message).startsWith('[JSKim]')) {
199
+ console.error(message);
200
+ } else {
201
+ console.error(`原因: ${message}`);
202
+ }
198
203
  console.error(
199
204
  `[JSKim] ウォッチャーは継続中です。修正して保存すると再試行します。`
200
205
  );
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ const { toDisplayPath } = require('./to-display-path');
4
+
5
+ /**
6
+ * Error から短い原因メッセージを取り出します(stack は含めません)。
7
+ * @param {unknown} err
8
+ * @returns {string}
9
+ */
10
+ function getCauseMessage(err) {
11
+ if (!err) {
12
+ return String(err);
13
+ }
14
+ if (typeof err === 'string') {
15
+ return err;
16
+ }
17
+ if (err && typeof err.message === 'string' && err.message.trim() !== '') {
18
+ return err.message;
19
+ }
20
+ return String(err);
21
+ }
22
+
23
+ /**
24
+ * Nunjucks が lineno / colno を提供する場合だけ位置を返します。
25
+ * message 本文の正規表現解析は行いません。
26
+ *
27
+ * @param {unknown} err
28
+ * @returns {{ line?: number, column?: number }}
29
+ */
30
+ function readNunjucksLocation(err) {
31
+ if (!err || typeof err !== 'object') {
32
+ return {};
33
+ }
34
+
35
+ const lineno = err.lineno;
36
+ const colno = err.colno;
37
+ const result = {};
38
+
39
+ if (Number.isInteger(lineno) && lineno > 0) {
40
+ result.line = lineno;
41
+ }
42
+ if (Number.isInteger(colno) && colno > 0) {
43
+ result.column = colno;
44
+ }
45
+
46
+ return result;
47
+ }
48
+
49
+ /**
50
+ * Nunjucks レンダリング失敗の診断メッセージを組み立てます。
51
+ *
52
+ * @param {object} options
53
+ * @param {string} options.projectName
54
+ * @param {string} options.sourceFile
55
+ * @param {string} options.templatePath
56
+ * @param {string} [options.workspaceRoot]
57
+ * @param {unknown} options.err
58
+ * @returns {string}
59
+ */
60
+ function formatRenderError(options) {
61
+ const {
62
+ projectName,
63
+ sourceFile,
64
+ templatePath,
65
+ workspaceRoot,
66
+ err,
67
+ } = options;
68
+
69
+ const sourceDisplay = toDisplayPath(sourceFile, workspaceRoot);
70
+ const location = readNunjucksLocation(err);
71
+ const cause = getCauseMessage(err);
72
+
73
+ const lines = [
74
+ `[JSKim] プロジェクト "${projectName}" の Nunjucks レンダリングに失敗しました。`,
75
+ `ソース: ${sourceDisplay}`,
76
+ `テンプレート: ${templatePath}`,
77
+ ];
78
+
79
+ if (location.line != null) {
80
+ lines.push(`行: ${location.line}`);
81
+ }
82
+ if (location.column != null) {
83
+ lines.push(`列: ${location.column}`);
84
+ }
85
+
86
+ lines.push(`原因: ${cause}`);
87
+ lines.push('テンプレート構文または参照先(extends / include)を確認してください。');
88
+
89
+ return lines.join('\n');
90
+ }
91
+
92
+ /**
93
+ * 出力パス衝突の診断メッセージを組み立てます。
94
+ *
95
+ * @param {object} options
96
+ * @param {string} options.projectName
97
+ * @param {string} options.outputFile
98
+ * @param {string} options.sourceFileA
99
+ * @param {string} options.sourceFileB
100
+ * @param {number} [options.ruleIndexA]
101
+ * @param {number} [options.ruleIndexB]
102
+ * @param {string} [options.workspaceRoot]
103
+ * @returns {string}
104
+ */
105
+ function formatCollisionError(options) {
106
+ const {
107
+ projectName,
108
+ outputFile,
109
+ sourceFileA,
110
+ sourceFileB,
111
+ ruleIndexA,
112
+ ruleIndexB,
113
+ workspaceRoot,
114
+ } = options;
115
+
116
+ const lines = [
117
+ `[JSKim] 出力パスが衝突しています。`,
118
+ `プロジェクト: ${projectName}`,
119
+ `出力: ${toDisplayPath(outputFile, workspaceRoot)}`,
120
+ formatCollisionSource('ソース1', sourceFileA, ruleIndexA, workspaceRoot),
121
+ formatCollisionSource('ソース2', sourceFileB, ruleIndexB, workspaceRoot),
122
+ '同じ出力になるソースを分けるか、どちらかを除外してください。',
123
+ ];
124
+
125
+ return lines.join('\n');
126
+ }
127
+
128
+ function formatCollisionSource(label, sourceFile, ruleIndex, workspaceRoot) {
129
+ const display = toDisplayPath(sourceFile, workspaceRoot);
130
+ if (Number.isInteger(ruleIndex) && ruleIndex >= 0) {
131
+ return `${label}: ${display} (files[${ruleIndex}])`;
132
+ }
133
+ return `${label}: ${display}`;
134
+ }
135
+
136
+ /**
137
+ * sourceDir / outputDir 外へのパス逸脱メッセージを組み立てます。
138
+ *
139
+ * @param {object} options
140
+ * @param {string} options.projectName
141
+ * @param {string} options.label
142
+ * @param {string} options.root
143
+ * @param {string} options.candidate
144
+ * @param {'sourceDir'|'outputDir'|string} [options.rootKind]
145
+ * @param {string} [options.workspaceRoot]
146
+ * @returns {string}
147
+ */
148
+ function formatPathOutsideError(options) {
149
+ const {
150
+ projectName,
151
+ label,
152
+ root,
153
+ candidate,
154
+ rootKind,
155
+ workspaceRoot,
156
+ } = options;
157
+
158
+ const kindLabel =
159
+ rootKind === 'sourceDir'
160
+ ? 'sourceDir'
161
+ : rootKind === 'outputDir'
162
+ ? 'outputDir'
163
+ : '許可ルート';
164
+
165
+ return [
166
+ `[JSKim] プロジェクト "${projectName}" の ${label} が許可範囲外です。`,
167
+ `種別: ${kindLabel} の外への参照`,
168
+ `ルート: ${toDisplayPath(root, workspaceRoot)}`,
169
+ `対象: ${toDisplayPath(candidate, workspaceRoot)}`,
170
+ '相対パスが sourceDir / outputDir 内に収まるように設定してください。',
171
+ ].join('\n');
172
+ }
173
+
174
+ /**
175
+ * 設定バリデーション向けの共通ヘッダー行を組み立てます。
176
+ *
177
+ * @param {object} options
178
+ * @param {string} options.projectName
179
+ * @param {string} options.configKey
180
+ * @param {string} [options.detail]
181
+ * @param {string} [options.received]
182
+ * @param {string} [options.hint]
183
+ * @returns {string}
184
+ */
185
+ function formatConfigValidationError(options) {
186
+ const { projectName, configKey, detail, received, hint } = options;
187
+ const lines = [
188
+ `[JSKim] 設定値が不正です: ${configKey}`,
189
+ `プロジェクト: ${projectName}`,
190
+ `設定キー: projects.${projectName} または defaults の ${configKey}`,
191
+ ];
192
+
193
+ if (detail) {
194
+ lines.push(detail);
195
+ }
196
+ if (received !== undefined) {
197
+ lines.push(`受け取った値: ${received}`);
198
+ }
199
+ if (hint) {
200
+ lines.push(hint);
201
+ }
202
+
203
+ return lines.join('\n');
204
+ }
205
+
206
+ /**
207
+ * 書き込み失敗などの I/O エラーメッセージを組み立てます。
208
+ *
209
+ * @param {object} options
210
+ * @param {string} options.projectName
211
+ * @param {string} options.actionLabel
212
+ * @param {string} options.targetFile
213
+ * @param {string} [options.workspaceRoot]
214
+ * @param {unknown} options.err
215
+ * @returns {string}
216
+ */
217
+ function formatIoError(options) {
218
+ const { projectName, actionLabel, targetFile, workspaceRoot, err } = options;
219
+ return [
220
+ `[JSKim] プロジェクト "${projectName}" の${actionLabel}に失敗しました。`,
221
+ `対象: ${toDisplayPath(targetFile, workspaceRoot)}`,
222
+ `原因: ${getCauseMessage(err)}`,
223
+ ].join('\n');
224
+ }
225
+
226
+ module.exports = {
227
+ getCauseMessage,
228
+ readNunjucksLocation,
229
+ formatRenderError,
230
+ formatCollisionError,
231
+ formatPathOutsideError,
232
+ formatConfigValidationError,
233
+ formatIoError,
234
+ };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
+ const { toDisplayPath } = require('./to-display-path');
4
5
 
5
6
  const CONFIG_FILENAME = 'jskim.config.js';
6
7
 
@@ -11,6 +12,7 @@ const CONFIG_FILENAME = 'jskim.config.js';
11
12
  */
12
13
  function loadConfig(workspaceRoot) {
13
14
  const configPath = path.join(workspaceRoot, CONFIG_FILENAME);
15
+ const configDisplay = toDisplayPath(configPath, workspaceRoot);
14
16
 
15
17
  let config;
16
18
  try {
@@ -25,21 +27,21 @@ function loadConfig(workspaceRoot) {
25
27
 
26
28
  if (missingConfig) {
27
29
  throw new Error(
28
- `[JSKim] 設定ファイルが見つかりません: ${configPath}\n` +
30
+ `[JSKim] 設定ファイルが見つかりません: ${configDisplay}\n` +
29
31
  `ワークスペースルートに ${CONFIG_FILENAME} を作成してください。`
30
32
  );
31
33
  }
32
34
  }
33
35
 
34
36
  throw new Error(
35
- `[JSKim] 設定ファイルの読み込みに失敗しました: ${configPath}\n` +
37
+ `[JSKim] 設定ファイルの読み込みに失敗しました: ${configDisplay}\n` +
36
38
  `原因: ${err && err.message ? err.message : String(err)}`
37
39
  );
38
40
  }
39
41
 
40
42
  if (!config || typeof config !== 'object') {
41
43
  throw new Error(
42
- `[JSKim] 設定が不正です: ${configPath}\n` +
44
+ `[JSKim] 設定が不正です: ${configDisplay}\n` +
43
45
  `原因: module.exports はオブジェクトである必要があります。`
44
46
  );
45
47
  }
@@ -47,7 +49,7 @@ function loadConfig(workspaceRoot) {
47
49
  if (!config.projects || typeof config.projects !== 'object') {
48
50
  throw new Error(
49
51
  `[JSKim] 設定が不正です: projects が無い、またはオブジェクトではありません。\n` +
50
- `設定: ${configPath}`
52
+ `設定: ${configDisplay}`
51
53
  );
52
54
  }
53
55
 
@@ -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 : {};