@ywal123456/jskim 0.4.0 → 0.5.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,96 @@
1
+ 'use strict';
2
+
3
+ const { formatConfigValidationError } = require('./format-diagnostic');
4
+
5
+ /**
6
+ * CLI --host / --port を resolved project に適用した新しいオブジェクトを返します。
7
+ * 元の project / config は変更しません。
8
+ *
9
+ * @param {object} project
10
+ * @param {{ host?: string, port?: string|number }} [overrides]
11
+ * @returns {object}
12
+ */
13
+ function applyServeCliOverrides(project, overrides = {}) {
14
+ if (!project || typeof project !== 'object') {
15
+ throw new Error('[JSKim] projectが不正です。');
16
+ }
17
+
18
+ const hasHost = overrides.host !== undefined;
19
+ const hasPort = overrides.port !== undefined;
20
+ if (!hasHost && !hasPort) {
21
+ return project;
22
+ }
23
+
24
+ const nextServe = {
25
+ ...(project.serve || {}),
26
+ };
27
+
28
+ if (hasHost) {
29
+ nextServe.host = overrides.host;
30
+ }
31
+
32
+ if (hasPort) {
33
+ nextServe.port = coercePort(overrides.port, project.name);
34
+ }
35
+
36
+ validateServeOverride(nextServe, project.name);
37
+ return {
38
+ ...project,
39
+ serve: nextServe,
40
+ };
41
+ }
42
+
43
+ function coercePort(value, projectName) {
44
+ if (typeof value === 'number') {
45
+ return value;
46
+ }
47
+ const text = String(value).trim();
48
+ if (text === '' || !/^-?\d+$/.test(text)) {
49
+ throw new Error(
50
+ formatConfigValidationError({
51
+ projectName,
52
+ configKey: 'CLI --port',
53
+ detail: '1から65535までの整数を指定してください。',
54
+ received: String(value),
55
+ })
56
+ );
57
+ }
58
+ return Number(text);
59
+ }
60
+
61
+ function validateServeOverride(serve, projectName) {
62
+ const host = serve && serve.host;
63
+ const port = serve && serve.port;
64
+
65
+ if (typeof host !== 'string' || host.trim() === '') {
66
+ throw new Error(
67
+ formatConfigValidationError({
68
+ projectName,
69
+ configKey: 'CLI --host / serve.host',
70
+ detail: '空でない文字列を指定してください。',
71
+ received: String(host),
72
+ })
73
+ );
74
+ }
75
+
76
+ if (
77
+ typeof port !== 'number' ||
78
+ !Number.isInteger(port) ||
79
+ port < 1 ||
80
+ port > 65535
81
+ ) {
82
+ throw new Error(
83
+ formatConfigValidationError({
84
+ projectName,
85
+ configKey: 'CLI --port / serve.port',
86
+ detail: '1から65535までの整数を指定してください。',
87
+ received: String(port),
88
+ })
89
+ );
90
+ }
91
+ }
92
+
93
+ module.exports = {
94
+ applyServeCliOverrides,
95
+ coercePort,
96
+ };
@@ -0,0 +1,88 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+
5
+ /**
6
+ * --all 用に、resolve済みprojectのoutputDir衝突を検査します。
7
+ *
8
+ * @param {Array<{ name: string, outputDir: string }>} projects
9
+ */
10
+ function assertCompatibleOutputDirs(projects) {
11
+ const items = Array.isArray(projects) ? projects : [];
12
+
13
+ for (let i = 0; i < items.length; i += 1) {
14
+ for (let j = i + 1; j < items.length; j += 1) {
15
+ const a = items[i];
16
+ const b = items[j];
17
+ const relation = classifyOutputDirRelation(a.outputDir, b.outputDir);
18
+ if (!relation) {
19
+ continue;
20
+ }
21
+
22
+ const label =
23
+ relation === 'same'
24
+ ? '同じoutputDir'
25
+ : '入れ子(祖先/子孫)のoutputDir';
26
+
27
+ throw new Error(
28
+ `[JSKim] --all でbuildできません: project間のoutputDirが衝突しています。\n` +
29
+ `理由: ${label} のため、clean付きbuildで他projectの成果物を消す恐れがあります。\n\n` +
30
+ `project: ${a.name}\n` +
31
+ `outputDir: ${toPosix(a.outputDir)}\n\n` +
32
+ `project: ${b.name}\n` +
33
+ `outputDir: ${toPosix(b.outputDir)}\n\n` +
34
+ `各projectのoutputDirを重複・入れ子にならないよう変更してください。`
35
+ );
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * @param {string} left
42
+ * @param {string} right
43
+ * @returns {'same'|'nested'|null}
44
+ */
45
+ function classifyOutputDirRelation(left, right) {
46
+ const a = path.resolve(left);
47
+ const b = path.resolve(right);
48
+ if (samePath(a, b)) {
49
+ return 'same';
50
+ }
51
+ if (isStrictAncestor(a, b) || isStrictAncestor(b, a)) {
52
+ return 'nested';
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function samePath(a, b) {
58
+ const na = path.resolve(a);
59
+ const nb = path.resolve(b);
60
+ if (process.platform === 'win32') {
61
+ return na.toLowerCase() === nb.toLowerCase();
62
+ }
63
+ return na === nb;
64
+ }
65
+
66
+ function isStrictAncestor(ancestor, descendant) {
67
+ let a = path.resolve(ancestor);
68
+ let d = path.resolve(descendant);
69
+ if (process.platform === 'win32') {
70
+ a = a.toLowerCase();
71
+ d = d.toLowerCase();
72
+ }
73
+ if (samePath(a, d)) {
74
+ return false;
75
+ }
76
+ const rel = path.relative(a, d);
77
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
78
+ }
79
+
80
+ function toPosix(abs) {
81
+ return path.resolve(abs).split(path.sep).join('/');
82
+ }
83
+
84
+ module.exports = {
85
+ assertCompatibleOutputDirs,
86
+ classifyOutputDirRelation,
87
+ samePath,
88
+ };
@@ -5,11 +5,17 @@ const path = require('node:path');
5
5
  const chokidar = require('chokidar');
6
6
  const { loadConfig, CONFIG_FILENAME } = require('./load-config');
7
7
  const { resolveProject } = require('./resolve-project');
8
+ const { selectProjectName } = require('./select-project-name');
9
+ const { applyServeCliOverrides } = require('./apply-serve-cli-overrides');
8
10
  const { createProjectWatcher } = require('./create-project-watcher');
9
11
  const { createStaticServer } = require('./create-static-server');
10
12
  const { createLiveReload } = require('./create-live-reload');
11
13
  const { formatListenError } = require('../commands/serve-errors');
12
14
  const { classifyReload } = require('./classify-reload');
15
+ const {
16
+ buildBrowserOpenUrl,
17
+ openBrowser: defaultOpenBrowser,
18
+ } = require('./open-browser');
13
19
 
14
20
  const DEV_RESTART_KEYS = [
15
21
  'outputDir',
@@ -28,16 +34,24 @@ const DEV_RESTART_KEYS = [
28
34
  * @param {string|undefined} options.projectName
29
35
  * @param {string} [options.commandName]
30
36
  * @param {string} [options.usageLine]
37
+ * @param {{ host?: string, port?: string|number, open?: boolean }} [options.cliOverrides]
31
38
  * @returns {{ start: Function, close: Function }}
32
39
  */
33
40
  function createWatchRuntime(options) {
34
41
  const mode = options.mode;
35
42
  const workspaceRoot = options.workspaceRoot || process.cwd();
36
- const projectName = options.projectName;
37
43
  const commandName = options.commandName || mode;
38
44
  const usageLine =
39
- options.usageLine || `npm run ${commandName} -- <project-name>`;
45
+ options.usageLine || `jskim ${commandName} [<project>]`;
46
+ const openBrowserFn = options.openBrowserFn || defaultOpenBrowser;
47
+ const cliOverrides = {
48
+ host: options.cliOverrides && options.cliOverrides.host,
49
+ port: options.cliOverrides && options.cliOverrides.port,
50
+ open: Boolean(options.cliOverrides && options.cliOverrides.open),
51
+ };
40
52
 
53
+ /** @type {string|undefined} */
54
+ let selectedProjectName = options.projectName;
41
55
  let currentProject = null;
42
56
  let configPath = null;
43
57
  let projectWatcher = null;
@@ -45,6 +59,7 @@ function createWatchRuntime(options) {
45
59
  let liveReload = null;
46
60
  let staticServer = null;
47
61
  let liveReloadEnabled = false;
62
+ let browserOpened = false;
48
63
 
49
64
  let stopping = false;
50
65
  let started = false;
@@ -53,15 +68,31 @@ function createWatchRuntime(options) {
53
68
  let pendingConfigReload = false;
54
69
  let sourceBuilding = false;
55
70
 
71
+ function withCliOverrides(project) {
72
+ return applyServeCliOverrides(project, {
73
+ host: cliOverrides.host,
74
+ port: cliOverrides.port,
75
+ });
76
+ }
77
+
56
78
  function resolveCurrentProject() {
57
79
  const loaded = loadConfig(workspaceRoot);
58
- const project = resolveProject({
80
+ const name = selectProjectName({
59
81
  config: loaded.config,
60
- workspaceRoot,
61
- projectName,
82
+ projectName: selectedProjectName,
62
83
  commandName,
63
84
  usageLine,
64
85
  });
86
+ selectedProjectName = name;
87
+ const project = withCliOverrides(
88
+ resolveProject({
89
+ config: loaded.config,
90
+ workspaceRoot,
91
+ projectName: name,
92
+ commandName,
93
+ usageLine,
94
+ })
95
+ );
65
96
  return {
66
97
  project,
67
98
  configPath: loaded.configPath,
@@ -118,6 +149,7 @@ function createWatchRuntime(options) {
118
149
  const host = project.serve.host.trim();
119
150
  const port = project.serve.port;
120
151
  const outputDisplay = toDisplayPath(project.outputDir, workspaceRoot);
152
+ const browserUrl = buildBrowserOpenUrl({ host, port });
121
153
 
122
154
  liveReload = createLiveReload({
123
155
  projectName: project.name,
@@ -154,6 +186,7 @@ function createWatchRuntime(options) {
154
186
  host,
155
187
  port,
156
188
  kind: '開発',
189
+ commandName: 'dev',
157
190
  });
158
191
  await cleanupDevComponents();
159
192
  throw formatted;
@@ -165,11 +198,30 @@ function createWatchRuntime(options) {
165
198
  console.log('[JSKim] 開発サーバーを起動しました。');
166
199
  console.log(`プロジェクト: ${project.name}`);
167
200
  console.log(`ルート: ${outputDisplay}`);
168
- console.log(`URL: ${staticServer.url}`);
201
+ console.log(`URL: ${browserUrl}`);
169
202
  console.log(
170
203
  `ライブリロード: ${liveReloadEnabled ? '有効' : '無効'}`
171
204
  );
172
205
  console.log('終了するには Ctrl+C を押してください。');
206
+ maybeOpenBrowser(browserUrl);
207
+ }
208
+ }
209
+
210
+ function maybeOpenBrowser(url) {
211
+ if (!cliOverrides.open || browserOpened || stopping) {
212
+ return;
213
+ }
214
+ browserOpened = true;
215
+ const result = openBrowserFn(url);
216
+ if (!result.ok) {
217
+ const message =
218
+ result.error && result.error.message
219
+ ? result.error.message
220
+ : String(result.error || 'unknown');
221
+ console.warn(
222
+ `[JSKim] browserを開けませんでした。手動で次のURLを開いてください: ${url}\n` +
223
+ `原因: ${message}`
224
+ );
173
225
  }
174
226
  }
175
227
 
@@ -314,13 +366,22 @@ function createWatchRuntime(options) {
314
366
  let candidate;
315
367
  try {
316
368
  const loaded = loadConfig(workspaceRoot);
317
- candidate = resolveProject({
369
+ const name = selectProjectName({
318
370
  config: loaded.config,
319
- workspaceRoot,
320
- projectName,
371
+ projectName: selectedProjectName,
321
372
  commandName,
322
373
  usageLine,
323
374
  });
375
+ selectedProjectName = name;
376
+ candidate = withCliOverrides(
377
+ resolveProject({
378
+ config: loaded.config,
379
+ workspaceRoot,
380
+ projectName: name,
381
+ commandName,
382
+ usageLine,
383
+ })
384
+ );
324
385
  } catch (err) {
325
386
  const message = err && err.message ? err.message : String(err);
326
387
  console.error('[JSKim] 設定ファイルの再読み込みに失敗しました。');
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+
5
+ /**
6
+ * browser で開く URL を組み立てます(listen host と分離)。
7
+ *
8
+ * @param {{ host: string, port: number }} options
9
+ * @returns {string}
10
+ */
11
+ function buildBrowserOpenUrl({ host, port }) {
12
+ let browserHost = String(host || '').trim();
13
+ if (browserHost === '0.0.0.0') {
14
+ browserHost = '127.0.0.1';
15
+ } else if (browserHost === '::') {
16
+ browserHost = 'localhost';
17
+ }
18
+
19
+ const portText = String(port);
20
+ if (browserHost.includes(':') && !browserHost.startsWith('[')) {
21
+ return `http://[${browserHost}]:${portText}/`;
22
+ }
23
+ return `http://${browserHost}:${portText}/`;
24
+ }
25
+
26
+ /**
27
+ * OS別の open 用 executable / args を返します(shell 不使用)。
28
+ *
29
+ * @param {string} url
30
+ * @param {string} [platform=process.platform]
31
+ * @returns {{ command: string, args: string[] }}
32
+ */
33
+ function buildOpenBrowserCommand(url, platform = process.platform) {
34
+ if (typeof url !== 'string' || url.trim() === '') {
35
+ throw new Error('[JSKim] browser URLが空です。');
36
+ }
37
+
38
+ if (platform === 'win32') {
39
+ return {
40
+ command: 'rundll32.exe',
41
+ args: ['url.dll,FileProtocolHandler', url],
42
+ };
43
+ }
44
+
45
+ if (platform === 'darwin') {
46
+ return {
47
+ command: 'open',
48
+ args: [url],
49
+ };
50
+ }
51
+
52
+ return {
53
+ command: 'xdg-open',
54
+ args: [url],
55
+ };
56
+ }
57
+
58
+ /**
59
+ * browser を開きます。失敗しても throw せず結果を返します。
60
+ *
61
+ * @param {string} url
62
+ * @param {object} [options]
63
+ * @param {typeof spawn} [options.spawnFn]
64
+ * @param {string} [options.platform]
65
+ * @returns {{ ok: boolean, error?: Error, command: string, args: string[] }}
66
+ */
67
+ function openBrowser(url, options = {}) {
68
+ const spawnFn = options.spawnFn || spawn;
69
+ const platform = options.platform || process.platform;
70
+ const { command, args } = buildOpenBrowserCommand(url, platform);
71
+
72
+ try {
73
+ const child = spawnFn(command, args, {
74
+ shell: false,
75
+ stdio: 'ignore',
76
+ detached: true,
77
+ windowsHide: true,
78
+ });
79
+ if (child && typeof child.unref === 'function') {
80
+ child.unref();
81
+ }
82
+ if (child && typeof child.on === 'function') {
83
+ child.on('error', () => {
84
+ // warning は呼び出し側で扱う
85
+ });
86
+ }
87
+ return { ok: true, command, args };
88
+ } catch (err) {
89
+ return {
90
+ ok: false,
91
+ error: err instanceof Error ? err : new Error(String(err)),
92
+ command,
93
+ args,
94
+ };
95
+ }
96
+ }
97
+
98
+ module.exports = {
99
+ buildBrowserOpenUrl,
100
+ buildOpenBrowserCommand,
101
+ openBrowser,
102
+ };
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ const COMMANDS = new Set(['build', 'watch', 'serve', 'dev']);
4
+
5
+ const COMMAND_OPTIONS = {
6
+ build: new Set(['--all']),
7
+ watch: new Set([]),
8
+ serve: new Set(['--host', '--port']),
9
+ dev: new Set(['--host', '--port', '--open']),
10
+ };
11
+
12
+ const VALUE_OPTIONS = new Set(['--host', '--port']);
13
+ const BOOLEAN_OPTIONS = new Set(['--all', '--open']);
14
+
15
+ /**
16
+ * jskim の argv(node / script を除く)を解析します。
17
+ *
18
+ * @param {string[]} argv
19
+ * @returns {{
20
+ * kind: 'help'|'version'|'command',
21
+ * command?: string,
22
+ * projectName?: string,
23
+ * options: { all: boolean, open: boolean, host?: string, port?: string }
24
+ * }}
25
+ */
26
+ function parseJskimArgv(argv) {
27
+ const args = Array.isArray(argv) ? argv.slice() : [];
28
+
29
+ if (args.length === 0) {
30
+ return { kind: 'help', options: emptyOptions() };
31
+ }
32
+
33
+ const first = args[0];
34
+ if (first === '--help' || first === '-h' || first === 'help') {
35
+ return { kind: 'help', options: emptyOptions() };
36
+ }
37
+ if (first === '--version' || first === '-v') {
38
+ return { kind: 'version', options: emptyOptions() };
39
+ }
40
+
41
+ if (!COMMANDS.has(first)) {
42
+ throw new Error(formatUnknownCommand(first));
43
+ }
44
+
45
+ const parsed = parseCommandArgv(first, args.slice(1));
46
+ return {
47
+ kind: 'command',
48
+ command: first,
49
+ projectName: parsed.projectName,
50
+ options: parsed.options,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * 特定 command の残 argv を解析します(scripts/*.js 用)。
56
+ *
57
+ * @param {string} command
58
+ * @param {string[]} argv command 名を除いた引数、または scripts の process.argv.slice(2)
59
+ * @returns {{ projectName?: string, options: object }}
60
+ */
61
+ function parseCommandArgv(command, argv) {
62
+ if (!COMMANDS.has(command)) {
63
+ throw new Error(formatUnknownCommand(command));
64
+ }
65
+
66
+ const allowed = COMMAND_OPTIONS[command];
67
+ const args = Array.isArray(argv) ? argv.slice() : [];
68
+ const options = emptyOptions();
69
+ /** @type {string[]} */
70
+ const positionals = [];
71
+ /** @type {Set<string>} */
72
+ const seen = new Set();
73
+
74
+ for (let i = 0; i < args.length; i += 1) {
75
+ const token = args[i];
76
+
77
+ if (token === '--') {
78
+ throw new Error(
79
+ `[JSKim] サポートされていない引数です: --\n` +
80
+ `使用方法: jskim ${command} ...`
81
+ );
82
+ }
83
+
84
+ if (token.startsWith('-')) {
85
+ if (token.includes('=') && token.startsWith('--')) {
86
+ throw new Error(
87
+ `[JSKim] この書き方のoptionはサポートしていません: ${token}\n` +
88
+ `例: --port 4000(= は使えません)`
89
+ );
90
+ }
91
+
92
+ if (!BOOLEAN_OPTIONS.has(token) && !VALUE_OPTIONS.has(token)) {
93
+ throw new Error(
94
+ `[JSKim] 不明なoptionです: ${token}\n` +
95
+ formatAllowedOptions(command)
96
+ );
97
+ }
98
+
99
+ if (!allowed.has(token)) {
100
+ throw new Error(
101
+ `[JSKim] コマンド "${command}" ではoption ${token} を使えません。\n` +
102
+ formatAllowedOptions(command)
103
+ );
104
+ }
105
+
106
+ if (seen.has(token)) {
107
+ throw new Error(`[JSKim] optionが重複しています: ${token}`);
108
+ }
109
+ seen.add(token);
110
+
111
+ if (BOOLEAN_OPTIONS.has(token)) {
112
+ if (token === '--all') {
113
+ options.all = true;
114
+ } else if (token === '--open') {
115
+ options.open = true;
116
+ }
117
+ continue;
118
+ }
119
+
120
+ // value option
121
+ const value = args[i + 1];
122
+ if (
123
+ value === undefined ||
124
+ (value.startsWith('-') && isKnownOptionToken(value))
125
+ ) {
126
+ throw new Error(
127
+ `[JSKim] option ${token} の値がありません。\n` +
128
+ `使用方法: ${token} <value>`
129
+ );
130
+ }
131
+ i += 1;
132
+ if (token === '--host') {
133
+ options.host = value;
134
+ } else if (token === '--port') {
135
+ options.port = value;
136
+ }
137
+ continue;
138
+ }
139
+
140
+ positionals.push(token);
141
+ }
142
+
143
+ if (positionals.length > 1) {
144
+ throw new Error(
145
+ `[JSKim] project名は1つだけ指定してください。\n` +
146
+ `受け取った値: ${positionals.join(', ')}\n` +
147
+ `使用方法: jskim ${command} [<project>]`
148
+ );
149
+ }
150
+
151
+ if (options.all && positionals.length > 0) {
152
+ throw new Error(
153
+ `[JSKim] --all と project名は同時に指定できません。\n` +
154
+ `使用方法: jskim build --all\n` +
155
+ `または: jskim build <project>`
156
+ );
157
+ }
158
+
159
+ if (options.all && command !== 'build') {
160
+ throw new Error(
161
+ `[JSKim] コマンド "${command}" ではoption --all を使えません。\n` +
162
+ formatAllowedOptions(command)
163
+ );
164
+ }
165
+
166
+ return {
167
+ projectName: positionals[0],
168
+ options,
169
+ };
170
+ }
171
+
172
+ function emptyOptions() {
173
+ return {
174
+ all: false,
175
+ open: false,
176
+ };
177
+ }
178
+
179
+ function isKnownOptionToken(token) {
180
+ return (
181
+ BOOLEAN_OPTIONS.has(token) ||
182
+ VALUE_OPTIONS.has(token) ||
183
+ token === '--help' ||
184
+ token === '-h' ||
185
+ token === '--version' ||
186
+ token === '-v'
187
+ );
188
+ }
189
+
190
+ function formatAllowedOptions(command) {
191
+ const allowed = [...COMMAND_OPTIONS[command]];
192
+ if (allowed.length === 0) {
193
+ return `コマンド "${command}" で使える追加optionはありません。`;
194
+ }
195
+ return `使えるoption: ${allowed.join(', ')}`;
196
+ }
197
+
198
+ function formatUnknownCommand(command) {
199
+ return [
200
+ `[JSKim] 不明なコマンドです: ${command}`,
201
+ '',
202
+ '使用できるコマンド:',
203
+ ' build [<project>]',
204
+ ' build --all',
205
+ ' watch [<project>]',
206
+ ' serve [<project>] [--host <host>] [--port <port>]',
207
+ ' dev [<project>] [--host <host>] [--port <port>] [--open]',
208
+ ].join('\n');
209
+ }
210
+
211
+ module.exports = {
212
+ parseJskimArgv,
213
+ parseCommandArgv,
214
+ COMMANDS,
215
+ COMMAND_OPTIONS,
216
+ };