@ywal123456/jskim 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -14,14 +14,16 @@ Nunjucks を使った汎用の静的 HTML ビルド環境です。
14
14
  - ファイル監視(`watch`)後の全体再ビルド
15
15
  - ビルド結果のローカル静的サーバー(`serve`)
16
16
  - 開発サーバー(`dev` = build + watch + serve)
17
- - 成功した再ビルド後のページ全体ライブリロード(SSE)
17
+ - 成功した再ビルド後のライブリロード(SSE)
18
+ - 再ビルド失敗時の browser error overlay(`dev` + `liveReload`)
19
+ - 安全な CSS 変更時の stylesheet soft reload(`dev` + `liveReload`)
18
20
  - CLI binary `jskim`
19
21
  - プロジェクト生成 CLI `create-jskim`
20
22
 
21
23
  ## 現在の非対応範囲
22
24
 
23
25
  - ブラウザ自動起動
24
- - HMR / CSS だけのホット更新
26
+ - HMR / JavaScript module hot replacement
25
27
  - JSON / YAML などの外部データファイル自動読み込み
26
28
  - API / Mock API
27
29
  - 増分ビルド
@@ -234,9 +236,14 @@ http://127.0.0.1:3000/
234
236
  - `serve` は自動で build / watch を実行しません
235
237
  - サーバールートは `outputDir`(例: `dist/sample`)
236
238
  - `serve` は HTML を変換せず、ライブリロード script を注入しません
237
- - `dev` は成功した再ビルドのあとだけ SSE でページ全体 reload を送ります
238
- - `dev` でも `dist` ファイルへ script は書き込みません
239
+ - `watch` にも browser overlay / CSS soft reload はありません
240
+ - `dev` `dev.liveReload: true` のときだけ browser 向け機能を有効にします
241
+ - 再ビルド失敗時は terminal 診断に加え、browser error overlay を表示します(成功するまで full reload / CSS reload は送りません)
242
+ - CSS ファイルだけが安全に変更された再ビルドでは、ページ全体ではなく stylesheet を更新します
243
+ - CSS 以外の変更や判定が不確実な場合は、従来どおりページ全体 reload します
244
+ - `dev` でも `dist` ファイルへ runtime は書き込みません
239
245
  - ブラウザ自動起動はサポートしません
246
+ - 厳格な Content Security Policy で inline script または inline style が禁止されている場合、dev の live reload・error overlay・CSS soft reload が動作しないことがあります
240
247
 
241
248
  `/_jskim/live-reload` は `dev.liveReload: true` のときだけ使う内部予約パスです。実際の静的ファイルパスとしては使わないことを推奨します。
242
249
 
@@ -309,16 +309,21 @@ files mode では出力パスの衝突を検出します。たとえば同じ `f
309
309
 
310
310
  - `GET /_jskim/live-reload` で SSE を提供
311
311
  - HTML レスポンスにだけ client script を一時注入(`dist` には書き込まない)
312
- - 成功した再ビルドのあとだけ `reload` event を送信
312
+ - 成功した再ビルドのあとだけ browser 更新を送信(通常はページ全体 reload)
313
+ - 再ビルド失敗時は browser error overlay を表示(成功するまで reload / CSS 更新は送らない)
314
+ - CSS ファイルだけが安全に変更された場合は、ページ全体ではなく stylesheet を更新
313
315
 
314
316
  `false` のとき:
315
317
 
316
318
  - SSE endpoint は提供しない(404)
317
319
  - HTML への script 注入なし
320
+ - error overlay / CSS soft reload も無効
318
321
  - build + watch + serve 自体は動作
319
322
 
320
323
  `/_jskim/live-reload` は内部予約パスです。プロジェクトの静的ファイルとしては使わないことを推奨します。
321
324
 
325
+ 厳格な Content Security Policy で inline script または inline style が禁止されている場合、dev の live reload・error overlay・CSS soft reload が動作しないことがあります。`watch` と `serve` にはこれらの browser 機能はありません。
326
+
322
327
  ## config hot reload
323
328
 
324
329
  `watch` と `dev` はワークスペースルートの `jskim.config.js` を監視します。
@@ -54,12 +54,12 @@ project/
54
54
 
55
55
  `jskim.config.js` は `files: [{ from: 'pages', to: '' }]` を使います。`.njk` は末尾の `.njk` だけを外してレンダリングし、画像などはそのままコピーします。
56
56
 
57
- `package.json` の scripts は bin 名 `jskim` を使います(package name ではありません)。既定の engine dependency は `@ywal123456/jskim`(`^0.3.0`)です。
57
+ `package.json` の scripts は bin 名 `jskim` を使います(package name ではありません)。既定の engine dependency は `@ywal123456/jskim`(`^0.4.0`)です。
58
58
 
59
59
  ```json
60
60
  {
61
61
  "devDependencies": {
62
- "@ywal123456/jskim": "^0.3.0"
62
+ "@ywal123456/jskim": "^0.4.0"
63
63
  },
64
64
  "scripts": {
65
65
  "build": "jskim build sample",
@@ -85,7 +85,7 @@ create package の `package.json` に `jskimEngine` metadata を持ち、生成
85
85
  {
86
86
  "jskimEngine": {
87
87
  "packageName": "@ywal123456/jskim",
88
- "version": "^0.3.0"
88
+ "version": "^0.4.0"
89
89
  }
90
90
  }
91
91
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ywal123456/jskim",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Nunjucks-based static HTML build environment with watch, serve, and live reload",
5
5
  "keywords": [
6
6
  "nunjucks",
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+
6
+ /**
7
+ * rebuild 単位の watcher events から browser 更新方法を分類します。
8
+ * 不確実な場合は必ず full reload にします。
9
+ *
10
+ * @param {object} options
11
+ * @param {Array<{ event?: string, absolutePath?: string, file?: string }>} options.events
12
+ * @param {string} options.sourceDir
13
+ * @param {string[]} [options.templates]
14
+ * @returns {'css'|'reload'}
15
+ */
16
+ function classifyReload(options) {
17
+ const events = Array.isArray(options.events) ? options.events : [];
18
+ if (events.length === 0) {
19
+ return 'reload';
20
+ }
21
+
22
+ const templateRoots = resolveTemplateRoots(
23
+ options.sourceDir,
24
+ options.templates
25
+ );
26
+
27
+ for (const item of events) {
28
+ if (!item || typeof item !== 'object') {
29
+ return 'reload';
30
+ }
31
+
32
+ const eventName = item.event;
33
+ if (eventName !== 'change') {
34
+ return 'reload';
35
+ }
36
+
37
+ const absolutePath = resolveEventAbsolutePath(item);
38
+ if (!absolutePath) {
39
+ return 'reload';
40
+ }
41
+
42
+ if (isInsideAny(templateRoots, absolutePath)) {
43
+ return 'reload';
44
+ }
45
+
46
+ if (!isCssSourcePath(absolutePath)) {
47
+ return 'reload';
48
+ }
49
+ }
50
+
51
+ return 'css';
52
+ }
53
+
54
+ /**
55
+ * @param {{ absolutePath?: string, file?: string }} item
56
+ * @returns {string|null}
57
+ */
58
+ function resolveEventAbsolutePath(item) {
59
+ if (item.absolutePath && typeof item.absolutePath === 'string') {
60
+ const abs = path.resolve(item.absolutePath);
61
+ if (path.isAbsolute(abs)) {
62
+ return abs;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * 末尾が .css または .css.njk のときだけ true(大文字小文字は無視)。
70
+ * @param {string} absolutePath
71
+ * @returns {boolean}
72
+ */
73
+ function isCssSourcePath(absolutePath) {
74
+ const normalized = absolutePath.split(path.sep).join('/').toLowerCase();
75
+ return normalized.endsWith('.css.njk') || normalized.endsWith('.css');
76
+ }
77
+
78
+ function resolveTemplateRoots(sourceDir, templates) {
79
+ const roots = [];
80
+ if (!sourceDir) {
81
+ return roots;
82
+ }
83
+ for (const rel of Array.isArray(templates) ? templates : []) {
84
+ if (!rel) {
85
+ continue;
86
+ }
87
+ const abs = path.resolve(sourceDir, rel);
88
+ try {
89
+ if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
90
+ roots.push(abs);
91
+ }
92
+ } catch {
93
+ // 存在確認失敗は root として扱わない
94
+ }
95
+ }
96
+ return roots;
97
+ }
98
+
99
+ function isInsideAny(roots, candidate) {
100
+ for (const root of roots) {
101
+ if (isInsideOrSame(root, candidate)) {
102
+ return true;
103
+ }
104
+ }
105
+ return false;
106
+ }
107
+
108
+ function isInsideOrSame(parent, child) {
109
+ const p = path.resolve(parent);
110
+ const c = path.resolve(child);
111
+ if (samePath(p, c)) {
112
+ return true;
113
+ }
114
+ const rel = path.relative(p, c);
115
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
116
+ }
117
+
118
+ function samePath(a, b) {
119
+ const na = path.resolve(a);
120
+ const nb = path.resolve(b);
121
+ if (process.platform === 'win32') {
122
+ return na.toLowerCase() === nb.toLowerCase();
123
+ }
124
+ return na === nb;
125
+ }
126
+
127
+ module.exports = {
128
+ classifyReload,
129
+ isCssSourcePath,
130
+ resolveTemplateRoots,
131
+ };
@@ -1,7 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const {
4
+ isSameOriginStylesheetHref,
5
+ appendCacheBustParam,
6
+ } = require('./stylesheet-reload');
7
+
3
8
  const LIVE_RELOAD_PATH = '/_jskim/live-reload';
4
9
  const HEARTBEAT_MS = 20000;
10
+ const CSS_LOAD_TIMEOUT_MS = 8000;
11
+ const OVERLAY_HOST_ID = '__jskim_error_overlay__';
5
12
 
6
13
  /**
7
14
  * SSE ベースのライブリロード管理です。
@@ -15,6 +22,10 @@ function createLiveReload({ projectName, enabled = true }) {
15
22
  const clients = new Set();
16
23
  let heartbeatTimer = null;
17
24
  let closed = false;
25
+ /** @type {string|null} */
26
+ let configError = null;
27
+ /** @type {string|null} */
28
+ let buildError = null;
18
29
 
19
30
  function ensureHeartbeat() {
20
31
  if (!enabled || heartbeatTimer || closed) {
@@ -46,6 +57,32 @@ function createLiveReload({ projectName, enabled = true }) {
46
57
  }
47
58
  }
48
59
 
60
+ function writeEvent(res, eventName, data) {
61
+ const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
62
+ res.write(payload);
63
+ }
64
+
65
+ function broadcast(eventName, data) {
66
+ if (!enabled || closed) {
67
+ return;
68
+ }
69
+ for (const res of [...clients]) {
70
+ try {
71
+ writeEvent(res, eventName, data);
72
+ } catch {
73
+ removeClient(res);
74
+ }
75
+ }
76
+ }
77
+
78
+ function getActiveErrorMessage() {
79
+ return configError || buildError;
80
+ }
81
+
82
+ function hasConfigError() {
83
+ return Boolean(configError);
84
+ }
85
+
49
86
  /**
50
87
  * 内部 SSE リクエストを処理します。
51
88
  * @returns {boolean} 処理したら true
@@ -80,7 +117,6 @@ function createLiveReload({ projectName, enabled = true }) {
80
117
  res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
81
118
  res.setHeader('Cache-Control', 'no-store');
82
119
  res.setHeader('Connection', 'keep-alive');
83
- // nginx 等のバッファリング回避(ローカルでも無害)
84
120
  res.setHeader('X-Accel-Buffering', 'no');
85
121
 
86
122
  if (method === 'HEAD') {
@@ -92,6 +128,18 @@ function createLiveReload({ projectName, enabled = true }) {
92
128
  clients.add(res);
93
129
  ensureHeartbeat();
94
130
 
131
+ const active = getActiveErrorMessage();
132
+ if (active) {
133
+ try {
134
+ writeEvent(res, 'error', {
135
+ project: projectName,
136
+ message: active,
137
+ });
138
+ } catch {
139
+ removeClient(res);
140
+ }
141
+ }
142
+
95
143
  const onClose = () => {
96
144
  removeClient(res);
97
145
  };
@@ -103,30 +151,13 @@ function createLiveReload({ projectName, enabled = true }) {
103
151
  }
104
152
 
105
153
  function getClientScript() {
106
- return [
107
- '<script>',
108
- '(function () {',
109
- ' try {',
110
- ` var source = new EventSource(${JSON.stringify(LIVE_RELOAD_PATH)});`,
111
- " source.addEventListener('reload', function () {",
112
- ' window.location.reload();',
113
- ' });',
114
- ' source.onerror = function () {',
115
- " console.info('[JSKim] ライブリロード接続を再試行しています…');",
116
- ' };',
117
- ' } catch (err) {',
118
- " console.warn('[JSKim] ライブリロードを開始できませんでした。');",
119
- ' }',
120
- '})();',
121
- '</script>',
122
- ].join('');
154
+ return buildClientScript({
155
+ liveReloadPath: LIVE_RELOAD_PATH,
156
+ overlayHostId: OVERLAY_HOST_ID,
157
+ cssLoadTimeoutMs: CSS_LOAD_TIMEOUT_MS,
158
+ });
123
159
  }
124
160
 
125
- /**
126
- * HTML 文字列に client script を注入します(メモリ上のみ)。
127
- * @param {string} html
128
- * @returns {string}
129
- */
130
161
  function injectHtml(html) {
131
162
  if (!enabled || typeof html !== 'string') {
132
163
  return html;
@@ -146,22 +177,113 @@ function createLiveReload({ projectName, enabled = true }) {
146
177
  return html + script;
147
178
  }
148
179
 
149
- function broadcastReload() {
180
+ function broadcastConfigError(message) {
181
+ if (!enabled || closed) {
182
+ return;
183
+ }
184
+ configError = normalizeErrorMessage(message);
185
+ broadcast('error', {
186
+ project: projectName,
187
+ message: configError,
188
+ });
189
+ }
190
+
191
+ function broadcastBuildError(message) {
192
+ if (!enabled || closed) {
193
+ return;
194
+ }
195
+ buildError = normalizeErrorMessage(message);
196
+ // 未解決の config error がある間は config を優先表示する
197
+ if (!configError) {
198
+ broadcast('error', {
199
+ project: projectName,
200
+ message: buildError,
201
+ });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * 後方互換: source を指定して error を送る。
207
+ * @param {unknown} message
208
+ * @param {'build'|'config'} [source='build']
209
+ */
210
+ function broadcastError(message, source = 'build') {
211
+ if (source === 'config') {
212
+ broadcastConfigError(message);
213
+ return;
214
+ }
215
+ broadcastBuildError(message);
216
+ }
217
+
218
+ function clearConfigError() {
219
+ configError = null;
220
+ }
221
+
222
+ function clearBuildError() {
223
+ buildError = null;
224
+ }
225
+
226
+ function clearErrorState() {
227
+ configError = null;
228
+ buildError = null;
229
+ }
230
+
231
+ function broadcastClearError() {
150
232
  if (!enabled || closed) {
151
233
  return;
152
234
  }
235
+ clearErrorState();
236
+ broadcast('clear-error', {
237
+ project: projectName,
238
+ });
239
+ }
153
240
 
154
- const payload = `event: reload\ndata: ${JSON.stringify({
241
+ function broadcastCssReload() {
242
+ if (!enabled || closed) {
243
+ return false;
244
+ }
245
+ // 未解決の config エラー中は source 成功で overlay を消さない
246
+ if (configError) {
247
+ return false;
248
+ }
249
+ clearBuildError();
250
+ broadcast('clear-error', {
251
+ project: projectName,
252
+ });
253
+ broadcast('css', {
155
254
  project: projectName,
156
- })}\n\n`;
255
+ });
256
+ return true;
257
+ }
157
258
 
158
- for (const res of [...clients]) {
159
- try {
160
- res.write(payload);
161
- } catch {
162
- removeClient(res);
163
- }
259
+ function broadcastReload() {
260
+ if (!enabled || closed) {
261
+ return false;
262
+ }
263
+ // reconnect で stale error が復帰しないよう、reload 前に server 状態を消す
264
+ clearErrorState();
265
+ broadcast('reload', {
266
+ project: projectName,
267
+ });
268
+ return true;
269
+ }
270
+
271
+ /**
272
+ * source rebuild 成功時専用。config error が残っていれば何もしない。
273
+ * @param {'css'|'reload'} kind
274
+ * @returns {boolean}
275
+ */
276
+ function notifySourceBuildSuccess(kind) {
277
+ if (!enabled || closed) {
278
+ return false;
164
279
  }
280
+ if (configError) {
281
+ return false;
282
+ }
283
+ if (kind === 'css') {
284
+ return broadcastCssReload();
285
+ }
286
+ return broadcastReload();
165
287
  }
166
288
 
167
289
  function close() {
@@ -169,6 +291,7 @@ function createLiveReload({ projectName, enabled = true }) {
169
291
  return;
170
292
  }
171
293
  closed = true;
294
+ clearErrorState();
172
295
 
173
296
  if (heartbeatTimer) {
174
297
  clearInterval(heartbeatTimer);
@@ -187,15 +310,282 @@ function createLiveReload({ projectName, enabled = true }) {
187
310
  handleRequest,
188
311
  injectHtml,
189
312
  broadcastReload,
313
+ broadcastError,
314
+ broadcastConfigError,
315
+ broadcastBuildError,
316
+ broadcastClearError,
317
+ broadcastCssReload,
318
+ notifySourceBuildSuccess,
319
+ clearConfigError,
320
+ clearBuildError,
321
+ clearErrorState,
322
+ hasConfigError,
190
323
  getClientScript,
191
324
  close,
192
325
  get clientCount() {
193
326
  return clients.size;
194
327
  },
328
+ get lastErrorMessage() {
329
+ return getActiveErrorMessage();
330
+ },
331
+ get configErrorMessage() {
332
+ return configError;
333
+ },
334
+ get buildErrorMessage() {
335
+ return buildError;
336
+ },
195
337
  };
196
338
  }
197
339
 
340
+ function normalizeErrorMessage(message) {
341
+ if (message == null) {
342
+ return String(message);
343
+ }
344
+ if (typeof message === 'string') {
345
+ return message;
346
+ }
347
+ if (typeof message === 'object' && typeof message.message === 'string') {
348
+ return message.message;
349
+ }
350
+ return String(message);
351
+ }
352
+
353
+ /**
354
+ * browser に注入する runtime script を組み立てます。
355
+ * stylesheet 判定は stylesheet-reload.js の関数本体を埋め込み、実装 drift を防ぎます。
356
+ *
357
+ * @param {object} options
358
+ * @returns {string}
359
+ */
360
+ function buildClientScript(options) {
361
+ const liveReloadPath = JSON.stringify(options.liveReloadPath);
362
+ const overlayHostId = JSON.stringify(options.overlayHostId);
363
+ const cssLoadTimeoutMs = Number(options.cssLoadTimeoutMs) || CSS_LOAD_TIMEOUT_MS;
364
+
365
+ return [
366
+ '<script>',
367
+ '(function () {',
368
+ ' var LIVE_RELOAD_PATH = ' + liveReloadPath + ';',
369
+ ' var OVERLAY_HOST_ID = ' + overlayHostId + ';',
370
+ ' var CSS_LOAD_TIMEOUT_MS = ' + cssLoadTimeoutMs + ';',
371
+ ' var overlayHost = null;',
372
+ ' var fallbackReloadScheduled = false;',
373
+ ' var isSameOriginStylesheetHref = ' +
374
+ isSameOriginStylesheetHref.toString() +
375
+ ';',
376
+ ' var appendCacheBustParam = ' + appendCacheBustParam.toString() + ';',
377
+ '',
378
+ ' function scheduleFullReload() {',
379
+ ' if (fallbackReloadScheduled) {',
380
+ ' return;',
381
+ ' }',
382
+ ' fallbackReloadScheduled = true;',
383
+ ' window.location.reload();',
384
+ ' }',
385
+ '',
386
+ ' function ensureOverlayHost() {',
387
+ ' var existing = document.getElementById(OVERLAY_HOST_ID);',
388
+ ' if (existing) {',
389
+ ' overlayHost = existing;',
390
+ ' return existing;',
391
+ ' }',
392
+ ' var host = document.createElement("div");',
393
+ ' host.id = OVERLAY_HOST_ID;',
394
+ ' host.setAttribute("data-jskim-overlay", "true");',
395
+ ' host.style.all = "initial";',
396
+ ' host.style.position = "fixed";',
397
+ ' host.style.inset = "0";',
398
+ ' host.style.zIndex = "2147483646";',
399
+ ' host.style.pointerEvents = "none";',
400
+ ' document.documentElement.appendChild(host);',
401
+ ' overlayHost = host;',
402
+ ' return host;',
403
+ ' }',
404
+ '',
405
+ ' function showErrorOverlay(message) {',
406
+ ' var host = ensureOverlayHost();',
407
+ ' var shadow = host.shadowRoot || host.attachShadow({ mode: "open" });',
408
+ ' while (shadow.firstChild) {',
409
+ ' shadow.removeChild(shadow.firstChild);',
410
+ ' }',
411
+ ' var style = document.createElement("style");',
412
+ ' style.textContent = [',
413
+ ' ":host { all: initial; }",',
414
+ ' ".wrap { pointer-events: auto; position: fixed; inset: 0; display: flex; align-items: flex-start; justify-content: center; padding: 24px 16px; box-sizing: border-box; background: rgba(20, 20, 20, 0.55); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }",',
415
+ ' ".panel { width: min(920px, 100%); max-height: min(80vh, 720px); display: flex; flex-direction: column; background: #111; color: #f5f5f5; border: 1px solid #444; box-shadow: 0 12px 40px rgba(0,0,0,0.45); }",',
416
+ ' ".head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid #333; background: #1a1a1a; }",',
417
+ ' ".title { font-size: 14px; font-weight: 700; letter-spacing: 0.02em; }",',
418
+ ' ".close { pointer-events: auto; border: 1px solid #666; background: #222; color: #fff; padding: 4px 10px; cursor: pointer; font: inherit; }",',
419
+ ' ".body { overflow: auto; padding: 14px; margin: 0; white-space: pre-wrap; word-break: break-word; font-size: 12px; line-height: 1.5; }"',
420
+ ' ].join("");',
421
+ ' var wrap = document.createElement("div");',
422
+ ' wrap.className = "wrap";',
423
+ ' var panel = document.createElement("div");',
424
+ ' panel.className = "panel";',
425
+ ' var head = document.createElement("div");',
426
+ ' head.className = "head";',
427
+ ' var title = document.createElement("div");',
428
+ ' title.className = "title";',
429
+ ' title.textContent = "JSKim dev error";',
430
+ ' var closeBtn = document.createElement("button");',
431
+ ' closeBtn.type = "button";',
432
+ ' closeBtn.className = "close";',
433
+ ' closeBtn.textContent = "Close";',
434
+ ' closeBtn.addEventListener("click", function () {',
435
+ ' hideErrorOverlay();',
436
+ ' });',
437
+ ' var body = document.createElement("pre");',
438
+ ' body.className = "body";',
439
+ ' body.textContent = String(message == null ? "" : message);',
440
+ ' head.appendChild(title);',
441
+ ' head.appendChild(closeBtn);',
442
+ ' panel.appendChild(head);',
443
+ ' panel.appendChild(body);',
444
+ ' wrap.appendChild(panel);',
445
+ ' shadow.appendChild(style);',
446
+ ' shadow.appendChild(wrap);',
447
+ ' host.style.display = "block";',
448
+ ' host.style.pointerEvents = "none";',
449
+ ' }',
450
+ '',
451
+ ' function hideErrorOverlay() {',
452
+ ' var host = overlayHost || document.getElementById(OVERLAY_HOST_ID);',
453
+ ' if (!host) {',
454
+ ' return;',
455
+ ' }',
456
+ ' if (host.shadowRoot) {',
457
+ ' while (host.shadowRoot.firstChild) {',
458
+ ' host.shadowRoot.removeChild(host.shadowRoot.firstChild);',
459
+ ' }',
460
+ ' }',
461
+ ' host.style.display = "none";',
462
+ ' }',
463
+ '',
464
+ ' function isSameOriginHref(href) {',
465
+ ' return isSameOriginStylesheetHref(href, window.location.href);',
466
+ ' }',
467
+ '',
468
+ ' function withCacheBust(href, token) {',
469
+ ' return appendCacheBustParam(href, window.location.href, token);',
470
+ ' }',
471
+ '',
472
+ ' function reloadStylesheets() {',
473
+ ' var links = Array.prototype.slice.call(',
474
+ ' document.querySelectorAll(\'link[rel~="stylesheet"]\')',
475
+ ' );',
476
+ ' var targets = [];',
477
+ ' for (var i = 0; i < links.length; i += 1) {',
478
+ ' var link = links[i];',
479
+ ' var href = link.getAttribute("href") || link.href;',
480
+ ' if (isSameOriginHref(href)) {',
481
+ ' targets.push(link);',
482
+ ' }',
483
+ ' }',
484
+ ' if (targets.length === 0) {',
485
+ ' scheduleFullReload();',
486
+ ' return;',
487
+ ' }',
488
+ '',
489
+ ' var token = String(Date.now());',
490
+ ' var remaining = targets.length;',
491
+ ' var failed = false;',
492
+ '',
493
+ ' function doneOne(ok) {',
494
+ ' if (failed) {',
495
+ ' return;',
496
+ ' }',
497
+ ' if (!ok) {',
498
+ ' failed = true;',
499
+ ' scheduleFullReload();',
500
+ ' return;',
501
+ ' }',
502
+ ' remaining -= 1;',
503
+ ' if (remaining <= 0) {',
504
+ ' fallbackReloadScheduled = false;',
505
+ ' }',
506
+ ' }',
507
+ '',
508
+ ' for (var j = 0; j < targets.length; j += 1) {',
509
+ ' (function (oldLink) {',
510
+ ' var nextHref = withCacheBust(oldLink.getAttribute("href") || oldLink.href, token);',
511
+ ' if (!nextHref) {',
512
+ ' doneOne(false);',
513
+ ' return;',
514
+ ' }',
515
+ ' var clone = oldLink.cloneNode(true);',
516
+ ' clone.setAttribute("href", nextHref);',
517
+ ' var settled = false;',
518
+ ' var timer = setTimeout(function () {',
519
+ ' if (settled) {',
520
+ ' return;',
521
+ ' }',
522
+ ' settled = true;',
523
+ ' doneOne(false);',
524
+ ' }, CSS_LOAD_TIMEOUT_MS);',
525
+ ' function finish(ok) {',
526
+ ' if (settled) {',
527
+ ' return;',
528
+ ' }',
529
+ ' settled = true;',
530
+ ' clearTimeout(timer);',
531
+ ' if (ok) {',
532
+ ' if (oldLink.parentNode) {',
533
+ ' oldLink.parentNode.removeChild(oldLink);',
534
+ ' }',
535
+ ' }',
536
+ ' doneOne(ok);',
537
+ ' }',
538
+ ' clone.addEventListener("load", function () { finish(true); });',
539
+ ' clone.addEventListener("error", function () { finish(false); });',
540
+ ' if (oldLink.parentNode) {',
541
+ ' oldLink.parentNode.insertBefore(clone, oldLink.nextSibling);',
542
+ ' } else {',
543
+ ' finish(false);',
544
+ ' }',
545
+ ' })(targets[j]);',
546
+ ' }',
547
+ ' }',
548
+ '',
549
+ ' try {',
550
+ ' var source = new EventSource(LIVE_RELOAD_PATH);',
551
+ ' source.addEventListener("reload", function () {',
552
+ ' window.location.reload();',
553
+ ' });',
554
+ ' source.addEventListener("error", function (event) {',
555
+ ' if (event && typeof event.data === "string" && event.data) {',
556
+ ' try {',
557
+ ' var payload = JSON.parse(event.data);',
558
+ ' showErrorOverlay(payload.message || "Build error");',
559
+ ' } catch (err) {',
560
+ ' showErrorOverlay("Build error");',
561
+ ' }',
562
+ ' return;',
563
+ ' }',
564
+ ' console.info("[JSKim] ライブリロード接続を再試行しています…");',
565
+ ' });',
566
+ ' source.addEventListener("clear-error", function () {',
567
+ ' hideErrorOverlay();',
568
+ ' });',
569
+ ' source.addEventListener("css", function () {',
570
+ ' try {',
571
+ ' reloadStylesheets();',
572
+ ' } catch (err) {',
573
+ ' scheduleFullReload();',
574
+ ' }',
575
+ ' });',
576
+ ' } catch (err) {',
577
+ ' console.warn("[JSKim] ライブリロードを開始できませんでした。");',
578
+ ' }',
579
+ '})();',
580
+ '</script>',
581
+ ].join('\n');
582
+ }
583
+
198
584
  module.exports = {
199
585
  createLiveReload,
200
586
  LIVE_RELOAD_PATH,
587
+ OVERLAY_HOST_ID,
588
+ CSS_LOAD_TIMEOUT_MS,
589
+ buildClientScript,
590
+ normalizeErrorMessage,
201
591
  };
@@ -114,9 +114,11 @@ function createProjectWatcher(project, options = {}) {
114
114
  return;
115
115
  }
116
116
 
117
+ const absolutePath = path.resolve(filePath);
117
118
  pendingEvents.push({
118
119
  event: eventName,
119
- file: toDisplayPath(filePath, workspaceRoot),
120
+ file: toDisplayPath(absolutePath, workspaceRoot),
121
+ absolutePath,
120
122
  });
121
123
 
122
124
  if (debounceTimer) {
@@ -9,6 +9,7 @@ const { createProjectWatcher } = require('./create-project-watcher');
9
9
  const { createStaticServer } = require('./create-static-server');
10
10
  const { createLiveReload } = require('./create-live-reload');
11
11
  const { formatListenError } = require('../commands/serve-errors');
12
+ const { classifyReload } = require('./classify-reload');
12
13
 
13
14
  const DEV_RESTART_KEYS = [
14
15
  'outputDir',
@@ -77,13 +78,15 @@ function createWatchRuntime(options) {
77
78
  currentProject = resolved.project;
78
79
  configPath = resolved.configPath;
79
80
 
81
+ // ready ログより先に config 監視を開始する。
82
+ // そうしないと「監視しています」直後の config 変更を見逃すことがある。
83
+ beginConfigWatching();
84
+
80
85
  if (mode === 'dev') {
81
86
  await startDevSession(currentProject, { initial: true });
82
87
  } else {
83
88
  await startWatchSession(currentProject, { initial: true });
84
89
  }
85
-
86
- beginConfigWatching();
87
90
  }
88
91
 
89
92
  async function startWatchSession(project, { initial }) {
@@ -185,10 +188,28 @@ function createWatchRuntime(options) {
185
188
  }
186
189
 
187
190
  function wireDevSourceReload(watcher) {
188
- watcher.on('build:success', ({ initial }) => {
189
- if (!initial && liveReloadEnabled && liveReload) {
190
- liveReload.broadcastReload();
191
+ watcher.on('build:success', ({ initial, events }) => {
192
+ if (initial || !liveReloadEnabled || !liveReload) {
193
+ return;
191
194
  }
195
+ // 未解決の config error がある間は source 成功で overlay/reload しない
196
+ if (liveReload.hasConfigError()) {
197
+ return;
198
+ }
199
+ const kind = classifyReload({
200
+ events,
201
+ sourceDir: currentProject && currentProject.sourceDir,
202
+ templates: currentProject && currentProject.templates,
203
+ });
204
+ liveReload.notifySourceBuildSuccess(kind);
205
+ });
206
+
207
+ watcher.on('build:failure', ({ error }) => {
208
+ if (!liveReloadEnabled || !liveReload) {
209
+ return;
210
+ }
211
+ // Error.message は完成済みの診断文字列を再利用する
212
+ liveReload.broadcastBuildError(error);
192
213
  });
193
214
  }
194
215
 
@@ -305,9 +326,17 @@ function createWatchRuntime(options) {
305
326
  console.error('[JSKim] 設定ファイルの再読み込みに失敗しました。');
306
327
  console.error(message);
307
328
  console.error('[JSKim] 以前の正常な設定を継続します。');
329
+ if (mode === 'dev' && liveReloadEnabled && liveReload) {
330
+ liveReload.broadcastConfigError(message);
331
+ }
308
332
  return;
309
333
  }
310
334
 
335
+ // candidate は validation 済み。以降の失敗は build error として扱う
336
+ if (mode === 'dev' && liveReloadEnabled && liveReload) {
337
+ liveReload.clearConfigError();
338
+ }
339
+
311
340
  if (mode === 'dev') {
312
341
  const restartKeys = getRestartRequiredChanges(currentProject, candidate);
313
342
  if (restartKeys.length > 0) {
@@ -348,6 +377,9 @@ function createWatchRuntime(options) {
348
377
  console.error('[JSKim] 設定の再読み込み後に監視の更新に失敗しました。');
349
378
  console.error(message);
350
379
  console.error('[JSKim] 以前の正常な設定を継続します。');
380
+ if (mode === 'dev' && liveReloadEnabled && liveReload) {
381
+ liveReload.broadcastConfigError(message);
382
+ }
351
383
  return;
352
384
  }
353
385
 
@@ -388,9 +420,9 @@ function createWatchRuntime(options) {
388
420
  hooks.onInitialBuildSuccess();
389
421
  }
390
422
  };
391
- const onFailure = ({ initial }) => {
423
+ const onFailure = ({ initial, error }) => {
392
424
  if (initial && hooks.onInitialBuildFailure) {
393
- hooks.onInitialBuildFailure();
425
+ hooks.onInitialBuildFailure(error);
394
426
  }
395
427
  };
396
428
  projectWatcher.on('build:success', onSuccess);
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ const CSS_CACHE_PARAM = '_jskim';
4
+
5
+ /**
6
+ * same-origin の stylesheet URL かどうかを判定します。
7
+ * browser runtime へ Function#toString で埋め込むため、外部クロージャに依存しません。
8
+ *
9
+ * @param {string} href
10
+ * @param {string} pageOrigin location.origin または location.href
11
+ * @returns {boolean}
12
+ */
13
+ function isSameOriginStylesheetHref(href, pageOrigin) {
14
+ if (!href || typeof href !== 'string') {
15
+ return false;
16
+ }
17
+ const trimmed = href.trim();
18
+ if (!trimmed || trimmed.startsWith('data:')) {
19
+ return false;
20
+ }
21
+
22
+ try {
23
+ const resolved = new URL(trimmed, pageOrigin);
24
+ const origin = new URL(pageOrigin);
25
+ return resolved.origin === origin.origin;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * cache-busting query を付与または更新します。hash と他 query は維持します。
33
+ * browser runtime へ Function#toString で埋め込むため、パラメータ名はリテラルです。
34
+ *
35
+ * @param {string} href
36
+ * @param {string} pageOrigin
37
+ * @param {string|number} token
38
+ * @returns {string|null}
39
+ */
40
+ function appendCacheBustParam(href, pageOrigin, token) {
41
+ if (!isSameOriginStylesheetHref(href, pageOrigin)) {
42
+ return null;
43
+ }
44
+
45
+ try {
46
+ const url = new URL(href, pageOrigin);
47
+ url.searchParams.set('_jskim', String(token));
48
+ return `${url.pathname}${url.search}${url.hash}`;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ module.exports = {
55
+ CSS_CACHE_PARAM,
56
+ isSameOriginStylesheetHref,
57
+ appendCacheBustParam,
58
+ };