PageSourceSearch

https://tanoshimo.jp/scripts/app.js?v=126

js tanoshimo.jp collected 2026-10-09 22:27:40 UTC 57,338 bytes, 797 lines download raw bytes

1/* tanoshimo-site クライアント機能: モバイルメニュー / テキストサイズ切替 / ブックマーク / 共有
2   静的サイト(認証なし)のため、ブックマークは localStorage(端末ローカル)。UTARのUIを踏襲。 */
3(function () {
4  var LS_FS = 'tano:fs';     // テキストサイズ 0/1/2/3(小・中・大・特大)
5  var LS_BM = 'tano:bm';     // ブックマーク url 配列
6  var ARTICLES = [];
7  var path = decodeURIComponent(location.pathname).replace(/\/$/, '') || '/';
8  // 英語ページ(/en 配下)かどうか。日本語向けの器(文字サイズ切替・ブックマーク・
9  // 保存/共有バー・記事検索・日本語のお問い合わせFAB)は英語の読み手には使えないので出さず、
10  // 代わりに英語のFABだけを置く。判定に <html lang> を使うのは、パスの持ち方
11  // (/en, /en/..., trailingSlash:'never' で実体は en.html)に依存しないため。
12  // 'en' と 'en-US' のような地域つきだけを英語とみなす(f.js の normalizeLang と同じ判定)。
13  var htmlLang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
14  var IS_EN = htmlLang === 'en' || htmlLang.indexOf('en-') === 0;
15
16  function ls(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch (e) { return d; } }
17  function save(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
18  function esc(s) { return (s || '').replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
19  // GA4へのイベント送信。gtagが無い環境(ローカル等)では黙って何もしない。
20  function track(name, params) {
21    if (typeof window.gtag === 'function') window.gtag('event', name, params || {});
22  }
23  function bm() { return ls(LS_BM, []); }
24  function isBm(u) { return bm().indexOf(u) > -1; }
25  function toggleBm(u) { var a = bm(); var i = a.indexOf(u); if (i > -1) a.splice(i, 1); else a.unshift(u); save(LS_BM, a); updateBmCount(); return i === -1; }
26  // しおり型ブックマークアイコン。on=保存済みは塗りつぶし、未保存は輪郭のみ。
27  function bmIcon(on) {
28    return '<svg class="tano-bm-ico" width="15" height="15" viewBox="0 0 24 24" fill="' + (on ? 'currentColor' : 'none') +
29      '" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
30      '<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
31  }
32
33  /* ---------- テキストサイズ ---------- */
34  var FS = ['s', 'm', 'l', 'xl'];
35  function applyFs(i) { document.documentElement.classList.remove('tano-fs-s', 'tano-fs-m', 'tano-fs-l', 'tano-fs-xl'); document.documentElement.classList.add('tano-fs-' + FS[i]); }
36  // 日本語ページで保存した文字サイズ設定は英語ページには持ち込まない。英語ページには
37  // 切替のUI自体が無く、overrides.css の .notion-root zoom が既定(m=zoom:1)以外だと
38  // 読み手が戻す手段を持たないため、英語ページは常に既定サイズで描画する。
39  var fsIdx = IS_EN ? 1 : Math.min(3, Math.max(0, ls(LS_FS, 1)));
40  applyFs(fsIdx);
41
42  /* ---------- navbar への注入 ---------- */
43  function injectNavbar() {
44    var actions = document.querySelector('.super-navbar__actions');
45    if (!actions) return;
46    var wrap = document.createElement('div');
47    wrap.className = 'tano-nav-tools';
48    wrap.innerHTML =
49      '<div class="tano-fsswitch" role="group" aria-label="文字サイズ">' +
50        '<button data-fs="0">小</button>' +
51        '<button data-fs="1">中</button>' +
52        '<button data-fs="2">大</button>' +
53        '<button data-fs="3">特大</button>' +
54      '</div>' +
55      '<button class="tano-bm-open" aria-label="保存した記事">★<span class="tano-bm-count">0</span></button>';
56    // 言語トグル。対訳ページがある場合だけ meta が出ているので、それを見て出す。
57    // 英語ページは EnNav が自前で出すため、ここは日本語ページ用。
58    // 英語ページのヘッダ切替は EnNav がサーバ側で描くので、ここでは作らない(二重表示になる)。
59    var alt = IS_EN ? null : document.querySelector('meta[name="tano:alt-lang"]');
60    if (alt && alt.content) {
61      var label = document.querySelector('meta[name="tano:alt-lang-label"]');
62      // JP と EN を常に両方出し、いま見ている方を濃く、行ける方を淡く出す。
63      // 片方(行き先)だけを出すと、それが現在地なのか行き先なのか読み取れなã
63„
64      // (2026-08-21 大東指摘)。並び順は日英どちらのページでも JP → EN で固定し、
65      // 動くのは濃淡だけにする。現在地はリンクにしない(押しても何も起きない導線を作らない)。
66      // 地球のアイコンは語と対で置く。記号単体では意味が通らない。
67      var g = document.createElement('span');
68      g.className = 'tano-lang';
69      g.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
70        'stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/>' +
71        '<path d="M2 12h20M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20"/></svg>' +
72        '<span class="tano-lang__cur" aria-current="true">JP</span>' +
73        '<span class="tano-lang__sep" aria-hidden="true">/</span>' +
74        '<a class="tano-lang__alt"></a>';
75      var altLink = g.querySelector('.tano-lang__alt');
76      altLink.href = alt.content;
77      altLink.textContent = 'EN';
78      altLink.setAttribute('aria-label', label && label.content ? label.content : 'English');
79      wrap.appendChild(g);
80    }
81    // ヘッダの常設CTA(2026-09-17 大東指示。旧サイトはヘッダに「LINE@移住相談」「資料請求」を
82    // 常設していた)。つながり続ける入口=LINEに1本だけ絞る。860px以下は .super-navbar__actions
83    // ごと非表示になるので、モバイルには出ない(読了時パネルが担う)。
84    // 計測は既存の委譲ハンドラ(SuperLayout)が href から cta_click_line を出すので、
85    // 置き場所だけ data-cta-loc で渡す。新しいイベントは作らない。
86    var navcta = document.createElement('a');
87    navcta.className = 'tano-navcta';
88    navcta.href = 'https://line.me/R/ti/p/@371dzkvg';
89    navcta.target = '_blank';
90    navcta.rel = 'noopener';
91    navcta.setAttribute('data-cta-loc', 'header');
92    // ラベルはフッターで使っている「公式LINE登録」に揃える(2026-09-17 大東)。
93    // 「LINEで登録」では何の登録か伝わらないという指摘への対応で、新しい言い方は作らない。
94    navcta.innerHTML =
95      '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
96      '<path d="M12 3C6.9 3 2.8 6.4 2.8 10.6c0 3.8 3.3 7 7.8 7.6.3.1.8.2.9.5.1.3.1.7 0 1l-.2.8c0 .2-.2.8.7.5.9-.4 5-3 6.7-5.1 1.2-1.3 1.8-2.7 1.8-4.3C20.5 6.4 16.4 3 12 3z"/></svg>' +
97      '<span>公式LINE登録</span>';
98    // 並びは「CTA → 文字サイズ・保存・言語 → 検索」。CTAを操作列の先頭(文字サイズの左)に置く
99    // ため、道具(wrap)を先に入れてからCTAを先頭へ入れる(2026-09-17 大東指示)。
100    actions.insertBefore(wrap, actions.firstChild);
101    actions.insertBefore(navcta, actions.firstChild);
102    wrap.querySelectorAll('.tano-fsswitch button').forEach(function (b) {
103      b.addEventListener('click', function () { fsIdx = +b.getAttribute('data-fs'); save(LS_FS, fsIdx); applyFs(fsIdx); markFs(); });
104    });
105    wrap.querySelector('.tano-bm-open').addEventListener('click', openBmPanel);
106    markFs();
107    updateBmCount();
108  }
109  function markFs() { document.querySelectorAll('.tano-fsswitch button').forEach(function (b) { b.classList.toggle('on', +b.getAttribute('data-fs') === fsIdx); }); }
110  function updateBmCount() { var n = bm().length; document.querySelectorAll('.tano-bm-count').forEach(function (c) { c.textContent = n; c.style.display = n ? '' : 'none'; }); }
111
112  /* ---------- ブックマーク パネル ---------- */
113  function openBmPanel() {
114    closeOverlays();
115    var urls = bm();
116    var items = urls.map(function (u) {
117      var a = ARTICLES.find(function (x) { return x.url === u; });
118      var title = a ? a.title : u;
119      return '<li><a href="' + esc(u) + '">' + esc(title) + '</a>' +
120        '<button class="tano-bm-del" data-u="' + esc(u) + '" aria-label="削除">×</button></li>';
121    }).join('');
122    var ov = document.createElement('div');
123    ov.className = 'tano-overlay tano-bm-panel';
124    ov.innerHTML = '<div class="tano-panel"><div class="tano-panel-head"><span>保存した記事</span>' +
125      '<button class="tano-close" aria-label="閉じる">×</button></div>' +
126      (urls.length ? '<ul class="tano-bm-list">' + items + '</ul>' : '<p class="tano-empty">まだ保存した記事はありません。記事ページの「保存」から追加できます。</p>') +
127      '</div>';
128    document.body.appendChild(ov);
129    ov.addEventListener('click', function (e) { if (e.target === ov || e.target.closest('.tano-close')) ov.remove(); });
130    ov.querySelectorAll('.tano-bm-del').forEach(function (b) {
131      b.addEventListener('click', function () { toggleBm(b.getAttribute('data-u')); b.closest('li').remove(); if (!bm().length) ov.remove(); });
132    });
133  }
134  // お問い合わせモーダル(inquiryOverlay)だけは破棄しない。Hyperdash埋め込み(f.js)は
135  // DOMContentLoaded時点で一度だけ data-hd-form を探して描画するため、他のオーバーレイのように
136  // 毎回作り直すと二度と見つからなくなる。閉じるときは非表示にするだけにする。
137  function closeOverlays() { document.querySelectorAll('.tano-overlay').forEach(function (o) { if (o === inquiryOverlay) { o.hidden = true; } else { o.remove(); } }); }
138
139  /* ---------- モバイルメニュー(自前ハンバーガーを注入) ---------- */
140  function injectMobileMenu() {
141    var items = [].slice.call(document.querySelectorAll('.super-navbar__item')).map(function (a) {
142      return '<a href="' + a.getAttribute('href') + '">' + esc(a.textContent.trim()) + '</a>';
143    }).join('');
144    var btn = document.createElement('button');
145    btn.className = 'tano-hamburger';
146    btn.setAttribute('aria-label', IS_EN ? 'Menu' : 'メニュー');
147    btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#2A4D14" stroke-width="2.2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
148    // z-index はモーダル(.tano-overlay = 10000)より必ず下にする。
149    // 99999 にしていたためモーダルの上にハンバーガーが重なって表示されていた。
150    btn.style.cssText = 'position:fixed;top:10px;right:14px;z-index:600;width:40px;height:40px;background:none;border:0;cursor:pointer;align-items:center;justify-content:center;padding:0;';
151    document.body.appendChild(btn);
152    // 表示/非表示は CSS の media query(.tano-hamburger)で制御(display は inline で指定しない)
153    btn.addEventListener('click', function (e) {
154      e.preventDefault();
155      if (document.querySelector('.tano-drawer')) { closeOverlays(); return; }
156      closeOverlays();
157      var ov = document.createElement('div');
158      ov.className = 'tano-overlay tano-drawer';
159      // 英語ページのドロワーはナビだけ。中の道具(文字サイズ・保存した記事・記事を検索)は
160      // どれも日本語の器なので置かない(記事検索が引くのは日本語記事の一覧)。
161      // 言語切替はドロワーにも置く。モバイルはヘッダの道具が全部ここに畳まれるので、
162      // 入れないと英語版への入口が小さい画面から消える(2026-08-21 大東指摘)。
163      // 日英どちらのページでも出す。ヘッダと同じく JP → EN の順で、濃淡で現在地を示す。
164      var altMeta = document.querySelector('meta[name="tano:alt-lang"]');
165      var langRow = '';
166      if (altMeta && altMeta.content) {
167        var here = '<span class="tano-lang__cur" aria-current="true">' + (IS_EN ? 'EN' : 'JP') + '</span>';
168        var there = '<a class="tano-lang__alt" href="' + esc(altMeta.content) + '">' + (IS_EN ? 'JP' : 'EN') + '</a>';
169        langRow =
170          '<div class="tano-dt-row">' + (IS_EN ? 'Language' : '言語') + ' ' +
171            '<span class="tano-lang tano-lang--drawer">' +
172              '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20a15 15 0 0 1 0-20"/></svg>' +
173              (IS_EN ? there : here) +
174              '<span class="tano-lang__sep" aria-hidden="true">/</span>' +
175              (IS_EN ? here : there) +
176            '</span>' +
177          '</div>';
178      }
179      // 英語ページのドロワーは言語切替だけ。他の道具(文字サイズ・保存した記事・記事を検索)は
180      // どれも日本語の器なので置かない(記事検索が引くのは日本語記事の一覧)。
181      var tools = IS_EN
182        ? (langRow ? '<div class="tano-drawer-tools">' + langRow + '</div>' : '')
183        : '<div class="tano-drawer-tools">' +
184            langRow +
185            '<div class="tano-dt-row">文字サイズ <div class="tano-fsswitch">' +
186              '<button data-fs="0">小</button><button data-fs="1">中</button><button data-fs="2">大</button><button data-fs="3">特大</button>' +
187            '</div></div>' +
188            '<button class="tano-dt-btn" data-act="bm">' + bmIcon(false) + ' 保存した記事</button>' +
189            '<button class="tano-dt-btn" data-act="search">記事を検索</button>' +
190          '</div>';
191      ov.innerHTML = '<div class="tano-drawer-inner"><button class="tano-close" aria-label="' + (IS_EN ? 'Close' : '閉じる') + '">×</button>' +
192        '<nav>' + items + '</nav>' + tools + '</div>';
193      document.body.appendChild(ov);
194      if (!IS_EN) {
195        markFs();
196        ov.querySelectorAll('.tano-fsswitch button').forEach(function (b) {
197          b.addEventListener('click', function () { fsIdx = +b.getAttribute('data-fs'); save(LS_FS, fsIdx); applyFs(fsIdx); markFs(); });
198        });
199        ov.querySelector('[data-act="bm"]').addEventListener('click', function () { openBmPanel(); });
200        ov.querySelector('[data-act="search"]').addEventListener('click', function () { openSearch(); });
201      }
202      ov.addEventListener('click', function (e2) { if (e2.target === ov || e2.target.closest('.tano-close') || e2.target.tagName === 'A') ov.remove(); });
203    });
204  }
205
206  /* ---------- 記事ページ: ブックマーク + 共有バー ---------- */
207  function injectArticleBar() {
208    if (document.querySelector('.tano-artbar')) return; // 二重挿入防止
209    // TOP以外の全ページに出す。下層はどれも「あとで見返す」「人に送る」対象になるため
210    // ページ種別で出し分けない(出る条件が読めないと学習コストになる)。
211    // TOPは入口であって保存・共有の対象ではないので除く。
212    if (path === '/' || path === '/index.html') return;
213    var art = ARTICLES.find(function (x) { return x.url === path; });
214    var header = document.querySelector('.tano-md__header') || document.querySelector('.notion-header__title-wrapper');
215    var root = document.querySelector('.notion-root');
216    if (!header && !root) return;
217    var canon = location.origin + location.pathname;
218    // 共有には短縮URL(/s/xxxx)を使う。長いURLは口頭・紙で伝えられず、
219    // SNSの本文でも折り返して読めなくなるため。無ければ通常URLに落とす。
220    var sm = document.querySelector('meta[name="tano:short-url"]');
221    var url = (sm && sm.content) || canon;
222    var title = (art && art.title) || document.title;
223    // SNS共有テキストにはサイト名を添える(Xはtextを使う。LINE/FacebookはOGからタイトルを拾う)。
224    var shareText = title + ' | タノシモ、しもかわ。';
225    var xIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>';
226    var lineIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z"/></svg>';
227    var copyIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.72"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
228    var fbIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M24 12.07C24 5.4 18.63 0 12 0S0 5.4 0 12.07C0 18.1 4.39 23.1 10.13 24v-8.44H7.08v-3.49h3.05V9.41c0-3.02 1.79-4.69 4.53-4.69 1.31 0 2.68.24 2.68.24v2.97h-1.51c-1.49 0-1.95.93-1.95 1.88v2.26h3.32l-.53 3.49h-2.79V24C19.61 23.1 24 18.1 24 12.07z"/></svg>';
229    var bar = document.createElement('div');
230    bar.className = 'tano-artbar';
231    bar.innerHTML =
232      '<button class="tano-ab-pill tano-bm-btn' + (isBm(path) ? ' on' : '') + '" type="button">' + bmIcon(isBm(path)) + '<span class="tano-act__lbl">' + (isBm(path) ? '保存済み' : '保存') + '</span></button>' +
233      '<a class="tano-ab-pill tano-ab-pill--icon tano-ab-sh x" target="_blank" rel="noopener" aria-label="Xで共有" title="Xで共有" href="https://twitter.com/intent/tweet?text=' + encodeURIComponent(shareText) + '&url=' + encodeURIComponent(url) + '">' + xIco + '</a>' +
234      '<a class="tano-ab-pill tano-ab-pill--icon tano-ab-sh line" target="_blank" rel="noopener" aria-label="LINEで共有" title="LINEで共有" href="https://social-plugins.line.me/lineit/share?url=' + encodeURIComponent(url) + '">' + lineIco + '</a>' +
235      '<a class="tano-ab-pill tano-ab-pill--icon tano-ab-sh fb" target="_blank" rel="noopener" aria-label="Facebookで共有" title="Facebookで共有" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">' + fbIco + '</a>' +
236      '<button class="tano-ab-pill tano-ab-sh copy" type="button" aria-label="URLをコピー">' + copyIco + '<span class="tano-copy-lbl">URLをコピー</span></button>';
237    // 忠実移植の記事ページは notion-header が非表示(offsetParent=null)で、本文タイトルは
238    // .notion-root 内にある。可視なヘッダがあればその直後、無ければ本文先頭の見出し直後に置く。
239    if (header && header.offsetParent !== null) {
240      header.insertAdjacentElement('afterend', bar);
241    } else if (root) {
242      var h = root.querySelector('h1, h2, .notion-heading');
243      if (h && h.offsetParent !== null) h.insertAdjacentElement('afterend', bar);
244      else root.insertAdjacentElement('afterbegin', bar);
245    } else {
246      header.insertAdjacentElement('afterend', bar);
247    }
248    var bmb = bar.querySelector('.tano-bm-btn');
249    bmb.addEventListener('click', function () {
250      var on = toggleBm(path);
251      bmb.classList.toggle('on', on);
252      bmb.innerHTML = bmIcon(on) + '<span class="tano-act__lbl">' + (on ? '保存済み' : '保存') + '</span>';
253      // 保存は「あとで読み返す意思」の最も直接的な指標。継続利用の先行指標として計測する。
254      track(on ? 'bookmark_add' : 'bookmark_remove', { page_path: path });
255    });
256    bar.querySelectorAll('.tano-ab-sh').forEach(function (b) {
257      b.addEventListener('click', function () {
258        var to = b.classList.contains('x') ? 'x'
259          : b.classList.contains('line') ? 'line'
260          : b.classList.contains('fb') ? 'facebook'
261          : b.classList.contains('copy') ? 'copy' : 'other';
262        track('share_click', { method: to, page_path: path, shared_url: url });
263      });
264    });
265    bar.querySelector('.copy').addEventListener('click', function (e) {
266      var lbl = e.currentTarget.querySelector('.tano-copy-lbl');
267      navigator.clipboard.writeText(url).then(function () {
268        if (lbl) { lbl.textContent = 'コピーしました'; setTimeout(function () { lbl.textContent = 'URLをコピー'; }, 1600); }
269      });
270    });
271    // スクロールでヘッダ直下に固定し、ミニマイズ表示に切り替える。
272    var navbar = document.querySelector('.super-navbar');
273    function updateStick() {
274      var nb = navbar ? Math.round(navbar.getBoundingClientRect().height) : 0;
275      bar.style.top = nb + 'px';
276      bar.classList.toggle('is-stuck', bar.getBoundingClientRect().top <= nb + 1);
277    }
278    updateStick();
279    window.addEventListener('scroll', updateStick, { passive: true });
280    window.addEventListener('resize', updateStick);
281  }
282
283  /* ---------- 記事ページ: 末尾に関連記事(回遊強化) ----------
284     UMP記事はサーバ側(RelatedArticles.astro)で既に描画済みなので、その場合はスキップ。
285     faithful記事は本文(.notion-root)直後に、同タグ+新着から最大3本を注入する。 */
286  // ラベル表示:正典順で並べ、分野ラベルはカテゴリpillと別色。article-index.js と同仕様(この並びは前後させない)。
287  var THEME_LABELS = { '仕事': 1, '住まい': 1, '子育てと教育': 1, 'お金': 1, '医療': 1, 'コミュニティ': 1, '冬の暮らし': 1 };
288  var LABEL_ORDER = ['下川町のヒト', '下川町のモノ', '下川町のコト', '仕事', '住まい', '子育てと教育', '冬の暮らし', 'お金', '医療', 'コミュニティ',
289    'しもかわ人名鑑', '移住者の物語', '起業・事業承継', '地域おこし協力隊', '下川人財バンク', 'イベント開催のお知らせ', 'イベントレポート', 'ふるさと納税', 'しいたけ', 'イチゴ', 'トマト', 'トマトジュース'];
290  var PRIMARY_LABELS = { '下川町のヒト': 1, '下川町のモノ': 1, '下川町のコト': 1 };
291  function orderIdx(l) { var i = LABEL_ORDER.indexOf(l); return i < 0 ? LABEL_ORDER.length : i; }
292  function pillClass(l) { return THEME_LABELS[l] ? 'tix-pill tix-pill--theme' : (PRIMARY_LABELS[l] ? 'tix-pill' : 'tix-pill tix-pill--sub'); }
293  function pillsHtml(labels) {
294    labels = (labels || []).slice().sort(function (a, b) { return orderIdx(a) - orderIdx(b); }).slice(0, 4);
295    return labels.map(function (l) { return '<span class="' + pillClass(l) + '">' + esc(l) + '</span>'; }).join('');
296  }
297  function relatedCard(a) {
298    return '<a class="tix-card" href="' + esc(a.url) + '">' +
299      '<span class="tix-cover"' + (a.cover ? ' style="background-image:url(' + esc(a.cover) + ')"' : '') + '></span>' +
300      '<span class="tix-tbody">' +
301      (a.date ? '<span class="tix-date">' + esc(a.date.replace(/-/g, '.')) + '</span>' : '') +
302      '<span class="tix-title">' + esc(a.title) + '</span>' +
303      '<span class="tix-pills">' + pillsHtml(a.labels) + '</span>' +
304      '</span></a>';
305  }
306  function injectRelated() {
307    if (document.querySelector('.tano-related')) return; // UMP記事(サーバ描画済み)
308    var self = ARTICLES.find(function (x) { return x.url === path; });
309    var isUmp = !!document.querySelector('.tano-md');
310    if (!self && !isUmp) return; // 記事ページのみ
311    var anchor = document.querySelector('.notion-root');
312    if (!anchor) return;
313    // 移行前の関連コレクション(ギャラリー)と、その上のイントロ
314    // [カテゴリ見出し(下川町のヒト等)+説明文]+下の「もっと読む」リンクを隠す。
315    // 関連は下の自動生成3本に一本化する。
316    Array.prototype.forEach.call(anchor.querySelectorAll('.notion-collection'), function (col) {
317      col.style.display = 'none';
318      // 直前のイントロを遡って収集。見出しが見つかった場合のみ実際に隠す(本文誤検出の防止)。
319      var toHide = [], prev = col.previousElementSibling, sawDesc = false, sawHeading = false;
320      while (prev) {
321        var c = prev.className || '';
322        if (/notion-(heading__anchor|divider)/.test(c)) { toHide.push(prev); prev = prev.previousElementSibling; continue; }
323        if (!sawDesc && /notion-text/.test(c) && !prev.querySelector('a')) { toHide.push(prev); sawDesc = true; prev = prev.previousElementSibling; continue; }
324        if (/notion-heading/.test(c)) { toHide.push(prev); sawHeading = true; }
325        break;
326      }
327      if (sawHeading) toHide.forEach(function (e) { e.style.display = 'none'; });
328      // 直後の「もっと読む」リンク段落
329      var next = col.nextElementSibling;
330      if (next && /notion-text/.test(next.className || '') && next.querySelector('a')) next.style.display = 'none';
331    });
332    if (!self) return; // articles.json 未掲載(UMP等)は自動関連を生成しない
333    var selfLabels = self.labels || [];
334    var list = ARTICLES.filter(function (a) { return a.url !== path; })
335      .map(function (a) {
336        var score = (a.labels || []).reduce(function (s, l) { return s + (selfLabels.indexOf(l) > -1 ? 1 : 0); }, 0);
337        return { a: a, score: score, date: a.date || '' };
338      })
339      .sort(function (x, y) { return y.score - x.score || y.date.localeCompare(x.date); })
340      .slice(0, 3).map(function (x) { return x.a; });
341    if (!list.length) return;
342    var sec = document.createElement('section');
343    sec.className = 'tano-related';
344    sec.innerHTML = '<h2 class="tano-related__title">関連記事</h2><div class="tix-grid">' +
345      list.map(relatedCard).join('') + '</div>';
346    // 本文(.notion-root)内の末尾に入れて本文と左端・幅を揃える(共通ブロックは隠し済み)。
347    anchor.appendChild(sec);
348  }
349
350  /* ---------- 検索(ヘッダ右の検索ボタン / ドロワー共通) ---------- */
351  function injectSearch() {
352    var btn = document.querySelector('.super-navbar__search');
353    if (!btn) return;
354    btn.style.cursor = 'pointer';
355    btn.addEventListener('click', function (e) { e.preventDefault(); openSearch(); });
356  }
357  function openSearch() {
358    {
359      closeOverlays();
360      var ov = document.createElement('div');
361      ov.className = 'tano-overlay tano-search';
362      ov.innerHTML = '<div class="tano-search-box"><div class="tano-search-head">' +
363        '<input type="search" class="tano-search-input" placeholder="記事を検索(タイトル・タグ)" autocomplete="off">' +
364        '<button class="tano-close" aria-label="閉じる">×</button></div>' +
365        '<div class="tano-search-results"></div></div>';
366      document.body.appendChild(ov);
367      var input = ov.querySelector('.tano-search-input');
368      var results = ov.querySelector('.tano-search-results');
369      input.focus();
370      function run() {
371        var q = input.value.trim().toLowerCase();
372        if (!q) { results.innerHTML = '<p class="tano-search-hint">タイトル・タグで記事を検索できます。</p>'; return; }
373        var list = ARTICLES.filter(function (a) {
374          return (a.title || '').toLowerCase().indexOf(q) > -1 ||
375            (a.labels || []).some(function (l) { return l.toLowerCase().indexOf(q) > -1; });
376        });
377        results.innerHTML = list.length ? list.slice(0, 40).map(function (a) {
378          return '<a href="' + esc(a.url) + '">' +
379            (a.cover ? '<span class="tano-sr-cover" style="background-image:url(' + esc(a.cover) + ')"></span>' : '<span class="tano-sr-cover"></span>') +
380            '<span class="tano-sr-t">' + esc(a.title) + '</span></a>';
381        }).join('') : '<p class="tano-search-hint">該当する記事がありません。</p>';
382      }
383      input.addEventListener('input', run);
384      run();
385      ov.addEventListener('click', function (e2) { if (e2.target === ov || e2.target.closest('.tano-close')) ov.remove(); });
386      document.addEventListener('keydown', function esc2(ev) { if (ev.key === 'Escape') { ov.remove(); document.removeEventListener('keydown', esc2); } });
387    }
388  }
389
390  /* ---------- お問い合わせ・ご相談(右下常設FAB → Hyperdash配信フォーム) ----------
391     全ページ右下に常設のFABを出し、クリックでモーダルを開く。フォーム本体は
392     Hyperdash の埋め込み(f.js + data-hd-form="tano-inquiry-hd" 相当)。
393     項目・必須条件・送信先・自動返信文は Hyperdash 側の /forms(識別子 tanoshimo-inquiry)が持つ。
394     ここはFABとモーダルの枠(開閉・オーバーレイ・見出し・予約導線)と埋め込みの置き場所だけを持つ。
395
396     f.js は data-hd-form を持つ要素を DOMContentLoaded 時点で一度しか探しに来ない。
397     このFABはモーダルをクリック時に動的生成していたが、それだと生成タイミングが
398     DOMContentLoadedより後になり、f.jsから永遠に見つからない。そのため、モーダルの枠は
399     クリックを待たず init() の時点で(非表示のã
399¾ã¾ï¼‰DOMに作っておく。SuperLayout側で
400     f.js を app.js の直後に defer 読み込みしているのはこの順序(枠が先、f.jsのスキャンが後)を
401     保証するため。開閉は要素の作り直しではなく hidden の付け外しで行う
402     (closeOverlays() 側もこのモーダルだけは remove() せず hidden にする)。 */
403  var HD_INQUIRY_SLUG = 'tanoshimo-inquiry';
404  // 英語ページ用は別フォーム。Hyperdash のフォームは項目のラベルを1言語しか持てないため、
405  // 2言語のサイトは「言語ごとにフォームを作って貼り分ける」運用になる(f.js のコメントに同旨)。
406  // 届いた問い合わせがどちらの言語で書かれたかも、スラッグで分かる。
407  // このスラッグが Hyperdash 側にまだ無いあいだは、f.js が枠の中に
408  // 「フォームを読み込めなかった」旨と https://focaccia.dev/f/<slug> へのリンクを英語で出す
409  // (f.js は <html lang> を見て文言を切り替える)。JSが動かない場合の受け皿は枠の中の noscript。
410  var HD_INQUIRY_SLUG_EN = 'tanoshimo-inquiry-en';
411  var inquiryOverlay = null;
412  var inquiryTriggersBound = false;
413  // 送信成功イベント発火時には f.js がフォームDOMを既に消しているため、topicの値は
414  // 送信(submit)の瞬間に退避しておく。GA4計測はtopicが取れなくても落とさない
415  // (必須ではなく、送信自体が計測できないことの方が問題なため)。
416  var inquiryLastTopic = '';
417  // 英語のモーダル。日本語版にある電話番号とオンライン移住相談の予約(どちらも日本語の受け口)は
418  // 置かず、英語の行き先はこのフォーム1つにする。見た目のクラスは日本語版と同じものを借りる。
419  function enInquiryHtml() {
420    return '<div class="tano-fb-modal" role="dialog" aria-modal="true" aria-label="Contact us">' +
421      '<div class="tano-fb-head">' +
422        '<h2 class="tano-fb-title">Contact us</h2>' +
423        '<button class="tano-fb-x tano-fb-cancel" type="button" aria-label="Close">&times;</button>' +
424      '</div>' +
425      '<div class="tano-fb-body">' +
426      '<p class="tano-fb-lede">Write in English about a visit, a stay, or moving here, and your message goes to the Shimokawa Regional Development Foundation, which publishes this site.</p>' +
427      '<div data-hd-form="' + HD_INQUIRY_SLUG_EN + '">' +
428        '<noscript><a href="https://focaccia.dev/f/' + HD_INQUIRY_SLUG_EN + '">Open the contact form</a></noscript>' +
429      '</div>' +
430      '</div></div>';
431  }
432  function buildInquiryModal() {
433    if (inquiryOverlay) return inquiryOverlay;
434    var ov = document.createElement('div');
435    ov.className = 'tano-overlay tano-fb-overlay';
436    ov.hidden = true;
437    ov.innerHTML = IS_EN ? enInquiryHtml() :
438      '<div class="tano-fb-modal" role="dialog" aria-modal="true" aria-label="お問い合わせ・ご相談">' +
439      '<div class="tano-fb-head">' +
440        '<h2 class="tano-fb-title">お問い合わせ・ご相談</h2>' +
441        '<button class="tano-fb-x tano-fb-cancel" type="button" aria-label="閉じる">&times;</button>' +
442      '</div>' +
443      '<div class="tano-fb-body">' +
444      '<p class="tano-fb-lede">移住や下川町のことなど、お気軽にご相談ください。</p>' +
445      // 話したほうが早い人には予約を先に出す。実測(3/17-8/6)で予約は45ユーザー、
446      // このフォームを開いたのは13ユーザーで、書かずに済む導線のほうが選ばれている。
447      '<a class="tano-fb-book" href="https://timerex.net/s/shimokawa.town.smoutsoudan/41595f50/" ' +
448      'target="_blank" rel="noopener" data-cta-loc="fab">' +
449      // 題は「オンライン移住相談」だけにする(2026-09-20 大東「オンライン移住相談で統一。
450      // なにもつけない」)。/support のボタン画像も同じ語にそろえた。操作の説明は下の補足が担う。
451      '<span class="tano-fb-book__t">オンライン移住相談</span>' +
452      '<span class="tano-fb-book__s">日程を選ぶだけ。書かずに直接話せます</span></a>' +
453      '<p class="tano-fb-or">または、メッセージを送る</p>' +
454      '<div data-hd-form="' + HD_INQUIRY_SLUG + '">' +
455        '<noscript><a href="https://focaccia.dev/f/' + HD_INQUIRY_SLUG + '">お問い合わせフォームを開く</a></noscript>' +
456      '</div>' +
457      '</div>' +
458      '<div class="tano-fb-foot">' +
459        '<p class="tano-fb-note">お急ぎの場合はお電話ください(01655-4-3511)。</p>' +
460      '</div></div>';
461    document.body.appendChild(ov);
462    ov.addEventListener('click', function (e) { if (e.target === ov || e.target.closest('.tano-fb-cancel')) closeInquiry(); });
463    document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && !ov.hidden) closeInquiry(); });
464    var hdContainer = ov.querySelector('[data-hd-form]');
465    // capture: f.js自身のsubmitハンドラ(preventDefault→非同期fetch→DOM書き換え)より
466    // 先に読む。bubbleで後から取ろうとすると、その時点でDOMが差し替わっている場合がある。
467    hdContainer.addEventListener('submit', function (e) {
468      var sel = e.target && e.target.querySelector && e.target.querySelector('[name="topic"]');
469      inquiryLastTopic = sel ? sel.value : '';
470    }, true);
471    hdContainer.addEventListener('hd-form-submitted', function () {
472      var params = { page_path: location.pathname };
473      if (inquiryLastTopic) params.topic = inquiryLastTopic;
474      track('inquiry_send', params);
475    });
476    inquiryOverlay = ov;
477    return ov;
478  }
479  function closeInquiry() { if (inquiryOverlay) inquiryOverlay.hidden = true; }
480  function openInquiry() {
481    closeOverlays();
482    var ov = buildInquiryModal();
483    ov.hidden = false;
484  }
485  function injectContactFab() {
486    if (document.querySelector('.tano-fab')) return; // 二重挿入防止
487    // アイコンは封筒。以前は吹き出し(message-circle)だったが、これは「今すぐチャットで話せる」の
488    // 慣用表現で、実際に開くのは折り返し連絡のフォームなので期待とずれる。
489    // 実測(3/17-8/6)でもモバイル845ユーザーに対し開いたのは1人だった。
490    var icon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 6 10-6"/></svg>';
491    var fab = document.createElement('button');
492    fab.type = 'button';
493    fab.className = 'tano-fab';
494    fab.setAttribute('aria-label', IS_EN ? 'Contact us' : 'お問い合わせ・ご相談');
495    // モバイルは幅が足りないので短いラベルに差し替える(CSSで出し分け)。
496    // ラベルを完全に隠すと何のボタンか分からず、押されã
496ªã„。
497    // 2026-09-17: ラベルを「お問い合わせ・ご相談」から「下川のことを相談する」へ。
498    // 実測(8/1〜9/15)で開いたのはPC536人中28人・モバイル681人中4人で、押した先が想像しにくい
499    // のが理由ではないかという仮説。モーダルの中の言葉(「移住や下川町のことなど、お気軽に
500    // ご相談ください」)に寄せて、何を相談できるかをボタンの側で言う。
501    fab.innerHTML = icon + (IS_EN
502      ? '<span class="tano-fab__lbl">Contact us</span>' +
503        '<span class="tano-fab__lbl-sm">Contact</span>'
504      : '<span class="tano-fab__lbl">下川のことを相談する</span>' +
505        '<span class="tano-fab__lbl-sm">相談する</span>');
506    fab.addEventListener('click', function () { track('inquiry_open', { page_path: location.pathname }); openInquiry(); });
507    document.body.appendChild(fab);
508    // モーダルの枠はここで(非表示のまま)作る。理由は上のコメントを参照。
509    buildInquiryModal();
510    bindInquiryTriggers();
511  }
512
513  // FAB以外の場所からモーダルを開く口。英語フッタの Contact のように、
514  // 行き先がページではなくこのフォームである導線がボタンとして置かれる。
515  // モーダルの markup を各所に複製せずに済むよう、data-tano-inquiry を持つ要素の
516  // クリックを委譲で受ける(属性値は計測上の置き場所名として使う)。
517  function bindInquiryTriggers() {
518    if (inquiryTriggersBound) return;
519    inquiryTriggersBound = true;
520    document.addEventListener('click', function (e) {
521      var t = e.target && e.target.closest ? e.target.closest('[data-tano-inquiry]') : null;
522      if (!t) return;
523      e.preventDefault();
524      track('inquiry_open', {
525        page_path: location.pathname,
526        cta_location: t.getAttribute('data-tano-inquiry') || 'trigger'
527      });
528      openInquiry();
529    });
530  }
531
532  // 共通フッタブロックだけを範囲指定で隠す(誤検出防止)。
533  function hideCommonFooterBlock(root) {
534    if (!root) return;
535    var NAV = ['/about', '/inquiry', '/termsofservice', '/privacypolicy', '/mediakit'];
536    var kids = Array.prototype.slice.call(root.children);
537    var anchorIdx = -1;
538    for (var i = 0; i < kids.length; i++) {
539      if (kids[i].querySelector && kids[i].querySelector('a[href="/termsofservice"]')) { anchorIdx = i; break; }
540    }
541    if (anchorIdx < 0) return; // 共通ブロックが無いページは何もしない
542    function navLink(el) { var a = el.querySelector && el.querySelector('a[href]'); return a && NAV.indexOf(a.getAttribute('href')) > -1; }
543    function deco(el) { return /notion-(divider|image|heading__anchor)/.test(el.className || ''); }
544    function tanoH3(el) { return el.tagName === 'H3' && el.textContent.trim() === 'タノシモ、しもかわ。'; }
545    function relH3(el) { return el.tagName === 'H3' && /関連サイト/.test(el.textContent); }
546    function extP(el) { return el.tagName === 'P' && el.querySelector && el.querySelector('a[href^="http"]'); }
547    var start = anchorIdx, end = anchorIdx;
548    while (start - 1 >= 0 && (navLink(kids[start - 1]) || deco(kids[start - 1]) || tanoH3(kids[start - 1]))) start--;
549    while (end + 1 < kids.length && (navLink(kids[end + 1]) || deco(kids[end + 1]) || relH3(kids[end + 1]) || extP(kids[end + 1]))) end++;
550    for (var j = start; j <= end; j++) kids[j].style.display = 'none';
551  }
552
553  /* ---------- フッター統合 ----------
554     記事本文末にある共通ブロック(タノシモ、しもかわ。+サイトナビ+関連サイト)を隠し、
555     その内容を財団コンタクト・SNSと重複排除してフッタ1ブロックに集約する。全ページ共通。 */
556  function buildFooter() {
557    // 1) 本文末の共通ブロック(タノシモ、しもかわ。+サイトナビ+関連サイト)を隠す。
558    //    ヒーロー見出し等の誤検出を避けるため、この区画にしか無い /termsofservice リンクを
559    //    起点に、共通ブロック構成要素(ナビ/装飾/関連サイトH3/外部リンク)だけを前後へ拡張して隠す。
560    hideCommonFooterBlock(document.querySelector('.notion-root'));
561    // 2) 統合フッタを描画。
562    //    通常は scripts/apply-footer.mjs がビルド時に描き込み済みなので、ここは何もしない
563    //    (リンク定義の正典は src/data/footer-links.json。JSを実行しないクローラにも届く)。
564    //    以下は、その処理を通っていない成果物に対するフォールバック。
565    //    リンクを増減するときは footer-links.json を直すこと。ここは触らなくてよい。
566    var footer = document.querySelector('.super-footer__content') || document.querySelector('.super-footer');
567    if (!footer || document.querySelector('.tano-footer-merged')) return;
568    var NAV = [
569      ['このWebサイトについて', '/about'], ['お問い合わせ', '/inquiry'], ['利用規約', '/termsofservice'],
570      ['プライバシーポリシー', '/privacypolicy'], ['メディアキット', '/mediakit']
571    ];
572    var REL = [
573      ['下川町', 'https://www.town.shimokawa.hokkaido.jp/'],
574      ['一般財団法人しもかわ地域振興機構', 'https://www.shimokawa-zaidan.jp/'],
575      ['しもかわ財団のnote', 'https://note.com/bears_lab'],
576      ['移住サポートYouTube', 'https://www.youtube.com/@%E5%8C%97%E6%B5%B7%E9%81%93%E4%B8%8B%E5%B7%9D%E7%94%BA%E7%A7%BB%E4%BD%8F%E3%82%B5%E3%83%9D%E3%83%BC%E3%83%88'],
577      ['しもかわ空き家バンク', 'https://shimokawa-akiyabank.net/'],
578      ['下川町公営住宅情報WEB', 'https://shimokawa-housing.info/'],
579      ['下川人財バンク', 'https://shimokawa-jinzai-bank.jp/'],
580      ['移住 生活費シミュレーション', 'https://haj.kurashigoto.hokkaido.jp/simulation/shimokawa?district_id=3&family_id=2&quantity=150000'],
581      ['DOORS.hokkaido X', 'https://x.com/2q1ot9opq2azrh6'],
582      ['DOORS.hokkaido note', 'https://note.com/doors_hokkaido']
583    ];
584    function links(arr, ext) {
585      return arr.map(function (x) {
586        return '<a href="' + x[1] + '"' + (ext ? ' target="_blank" rel="noopener"' : '') + '>' + x[0] + '</a>';
587      }).join('');
588    }
589    var box = document.createElement('div');
590    box.className = 'tano-footer-merged';
591    box.innerHTML =
592      '<div class="tano-fm-col"><p class="tano-fm-h">サイト</p>' + links(NAV, false) + '</div>' +
593      '<div class="tano-fm-col tano-fm-wide"><p class="tano-fm-h">関連サイト</p><div class="tano-fm-rel">' + links(REL, true) + '</div></div>' +
594      '<div class="tano-fm-col"><p class="tano-fm-h">お問い合わせ</p>' +
595        '<p class="tano-fm-org">一般財団法人しもかわ地域振興機構(しもかわ財団)</p>' +
596        '<a href="tel:0165543511">電話 01655-4-3511</a>' +
597        '<a href="mailto:[email protected]">[email protected]</a>' +
598        '<a href="https://line.me/R/ti/p/@371dzkvg?oat_content=url&ts=02171606" target="_blank" rel="noopener">公式LINE登録</a>' +
599      '</div>';
600    footer.appendChild(box);
601  }
602
603  /* ---------- 求人カード → 詳細モーダル(/recruit の下川人財バンク求人) ---------- */
604  var JINZAI_LOGIN = 'https://shimokawa-jinzai-bank.jp/login.php';
605  function injectJobModal() {
606    var grid = document.querySelector('.tano-jobs');
607    if (!grid) return; // /recruit 以外では何もしない
608    grid.addEventListener('click', function (e) {
609      var card = e.target && e.target.closest ? e.target.closest('.tano-jobcard') : null;
610      if (!card) return;
611      var raw = card.querySelector('.tano-jobdata');
612      if (!raw) return;
613      var j;
614      try { j = JSON.parse(raw.textContent); } catch (err) { return; }
615      openJobModal(j);
616    });
617  }
618  function openJobModal(j) {
619    closeOverlays();
620    function row(label, val) {
621      return val ? '<div class="tano-jobmodal__row"><span class="tano-jobmodal__k">' + esc(label) + '</span><span class="tano-jobmodal__v">' + esc(val) + '</span></div>' : '';
622    }
623    // detail_url は fetch-jobs.mjs が https 固定で生成するが、万一 javascript: 等の
624    // 危険スキームが混じっても href に流さないよう、http(s) 以外は無効化する(XSS防御)。
625    var safeDetail = /^https?:\/\//i.test(j.detail_url || '') ? j.detail_url : '#';
626    // thumb はローカル資産のみ許容(外部URLを誤って混ぜても img に流さない・XSS/hotlink防御)。
627    var safeThumb = /^\/assets\//.test(j.thumb || '') ? j.thumb : '';
628    // 外部サイト遷移を明示する外部リンクアイコン(CollectionLayout の tano-collextlink と同じ形)。
629    var extIcon = '<svg class="tano-extico" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
630    var ov = document.createElement('div');
631    ov.className = 'tano-overlay tano-jobmodal-ov';
632    ov.innerHTML =
633      '<div class="tano-jobmodal" role="dialog" aria-modal="true" aria-label="' + esc(j.title) + '">' +
634        '<button class="tano-close" aria-label="閉じる">×</button>' +
635        (safeThumb ? '<img class="tano-jobmodal__thumb" src="' + esc(safeThumb) + '" alt="" loading="lazy">' : '') +
636        '<p class="tano-jobmodal__company">' + esc(j.company) + '</p>' +
637        '<h2 class="tano-jobmodal__title">' + esc(j.title) + '</h2>' +
638        (j.posted_date ? '<p class="tano-jobmodal__date">掲載日 ' + esc(j.posted_date) + '</p>' : '') +
639        '<div class="tano-jobmodal__info">' +
640          row('給与', j.salary) + row('勤務地', j.location) + row('勤務時間', j.hours) + row('雇用形態', j.employment_type) +
641        '</div>' +
642        '<div class="tano-jobmodal__desc">' + esc(j.description).replace(/\n/g, '<br>') + '</div>' +
643        '<div class="tano-jobmodal__actions">' +
644          '<a class="tano-jobmodal__detail" href="' + esc(safeDetail) + '" target="_blank" rel="noopener" data-job-detail="' + esc(j.job_id) + '">元の求人ページを見る' + extIcon + '</a>' +
645          '<a class="tano-jobmodal__apply" href="' + JINZAI_LOGIN + '" target="_blank" rel="noopener" data-job-apply="' + esc(j.job_id) + '">ログインして応募' + extIcon + '</a>' +
646        '</div>' +
647      '</div>';
648    document.body.appendChild(ov);
649    ov.addEventListener('click', function (e) {
650      if (e.target === ov || e.target.closest('.tano-close')) ov.remove();
651    });
652    document.addEventListener('keydown', function esc3(ev) { if (ev.key === 'Escape') { ov.remove(); document.removeEventListener('keydown', esc3); } });
653  }
654
655  /* ---------- 空き家バンク物件モーダル(/akiya) ---------- */
656  function injectAkiyaModal() {
657    var grid = document.querySelector('.tano-akiya');
658    if (!grid) return; // /akiya 以外では何もしない
659    grid.addEventListener('click', function (e) {
660      var card = e.target && e.target.closest ? e.target.closest('.tano-propcard') : null;
661      if (!card) return;
662      var raw = card.querySelector('.tano-propdata');
663      if (!raw) return;
664      var p;
665      try { p = JSON.parse(raw.textContent); } catch (err) { return; }
666      openPropModal(p);
667    });
668  }
669  function openPropModal(p) {
670    closeOverlays();
671    // detail_url は fetch-akiya.mjs が https 固定で生成するが、危険スキームは href に流さない(XSS防御)。
672    var safeDetail = /^https?:\/\//i.test(p.detail_url || '') ? p.detail_url : '#';
673    var safeThumb = /^\/assets\//.test(p.thumb || '') ? p.thumb : '';
674    var extIcon = '<svg class="tano-extico" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
675    var specRows = (p.specs || []).map(function (s) {
676      return '<div class="tano-jobmodal__row"><span class="tano-jobmodal__k">' + esc(s.label) + '</span><span class="tano-jobmodal__v">' + esc(s.value) + '</span></div>';
677    }).join('');
678    var badges = '<span class="tano-propbadge">' + (p.category === 'rent' ? '賃貸' : '売買') + '</span>' +
679      (p.sold ? '<span class="tano-propbadge tano-propbadge--sold">成約済</span>' : '');
680    // アクションは1つだけ(遷移先は物件ページで共通)。募集中はボタン、成約済は
681    // 控えめなテキストリンクで出し分ける。ラベルは「物件の詳細を見る」で統一。
682    var actCls = p.sold ? 'tano-jobmodal__detail' : 'tano-jobmodal__apply';
683    var actions = '<a class="' + actCls + '" href="' + esc(safeDetail) + '" target="_blank" rel="noopener" data-prop-detail="' + esc(p.prop_id) + '">物件の詳細を見る' + extIcon + '</a>';
684    var ov = document.createElement('div');
685    ov.className = 'tano-overlay tano-jobmodal-ov';
686    ov.innerHTML =
687      '<div class="tano-jobmodal" role="dialog" aria-modal="true" aria-label="' + esc(p.title) + '">' +
688        '<button class="tano-close" aria-label="閉じる">×</button>' +
689        (safeThumb ? '<img class="tano-jobmodal__thumb" src="' + esc(safeThumb) + '" alt="" loading="lazy">' : '') +
690        '<p class="tano-jobmodal__company tano-propmodal__badges">' + badges + '</p>' +
691        '<h2 class="tano-jobmodal__title">' + esc(p.title) + '</h2>' +
692        '<div class="tano-jobmodal__info">' + specRows + '</div>' +
693        (p.description ? '<div class="tano-jobmodal__desc">' + esc(p.description).replace(/\n/g, '<br>') + '</div>' : '') +
694        '<div class="tano-jobmodal__actions">' + actions + '</div>' +
695      '</div>';
696    document.body.appendChild(ov);
697    ov.addEventListener('click', function (e) {
698      if (e.target === ov || e.target.closest('.tano-close')) ov.remove();
699    });
700    document.addEventListener('keydown', function esc4(ev) { if (ev.key === 'Escape') { ov.remove(); document.removeEventListener('keydown', esc4); } });
701  }
702
703  /* ---------- init ---------- */
704  // 「テーマから探す」バナーは大東指示で撤去(2026-07-23)。入口タイルとヒト・モノ・コトで代替。
705  // 呼び出しだけ外して関数が残っていたので、2026-09-02 に関数本体とCSSも消した。
706  function init() {
707    // 英語ページは日本語の器を出さない(IS_EN の宣言のところに理由)。ナビの道具
708    // (文字サイズ・★ブックマーク)・記事検索・フッタの組み直しは日本語ページだけ。
709    // 英語のフッタは EnFooter.astro がビルド時に描いており、JSで足すものは無い。
710    if (IS_EN) { injectMobileMenu(); injectContactFab(); return; }
711    injectNavbar(); injectMobileMenu(); injectSearch(); buildFooter(); injectJobModal(); injectAkiyaModal(); injectContactFab();
712    initReadEnd();
713  }
714
715  /* ---------- 読了時の「つながる入口」パネル ---------- */
716  // ReadEndPanel.astro が本文の直後に置く目印(.tano-readend-mark)が画面に入ったら、
717  // 同じコンポーネントが描いたパネル(.tano-readend)を出す。文言と行き先は cta.mjs が決め、
718  // ここは「出す/閉じる/頻度」だけを持つ。
719  // 頻度: 1回の訪問で3回まで/閉じたら3日出さない(2026-09-14 大東指定)。
720  // 「訪問」は sessionStorage(タブを閉じるまで)で数える。
721  // LINEを押した人を以後ずっと止める規則は 2026-09-17 に撤回した(大東「最大3回のルールに合わせる」)。
722  // 押した回も1回として数え、そのページでは閉じるだけにする。
723  var RE_UNTIL_KEY = 'tano:readend:until';
724  var RE_COUNT_KEY = 'tano:readend:count';
725  var RE_MAX_PER_VISIT = 3;
726  var RE_SNOOZE_MS = 3 * 24 * 60 * 60 * 1000;
727  var RE_PANEL_W = 300; // overrides.css の .tano-readend.is-side の幅と一致させる
728  function reStore(session) { try { return session ? window.sessionStorage : window.localStorage; } catch (e) { return null; } }
729  function reGet(session, k) { var s = reStore(session); try { return s ? s.getItem(k) : null; } catch (e) { return null; } }
730  function reSet(session, k, v) { var s = reStore(session); try { if (s) s.setItem(k, v); } catch (e) {} }
731  function readEndAllowed() {
732    var until = parseInt(reGet(false, RE_UNTIL_KEY), 10);
733    if (until && Date.now() < until) return false;
734    return (parseInt(reGet(true, RE_COUNT_KEY), 10) || 0) < RE_MAX_PER_VISIT;
735  }
736  function showReadEnd(panel, mark) {
737    // 本文の右に「パネル幅+左右の余白」が空いていれば右から、無ければ下から出す。
738    var side = window.innerWidth - mark.getBoundingClientRect().right >= RE_PANEL_W + 44;
739    panel.classList.toggle('is-side', side);
740    panel.classList.toggle('is-sheet', !side);
741    panel.hidden = false;
742    if (!side) document.body.classList.add('tano-readend-sheet');
743    // hidden を外したのと同じフレームでは transition が走らないので、2フレーム待ってから動かす。
744    requestAnimationFrame(function () { requestAnimationFrame(function () { panel.classList.add('is-in'); }); });
745  }
746  function hideReadEnd(panel) {
747    panel.classList.remove('is-in');
748    document.body.classList.remove('tano-readend-sheet');
749    setTimeout(function () { if (!panel.classList.contains('is-in')) panel.hidden = true; }, 360);
750  }
751  function initReadEnd() {
752    var mark = document.querySelector('.tano-readend-mark');
753    var panel = document.querySelector('.tano-readend');
754    if (!mark || !panel || !('IntersectionObserver' in window)) return;
755    // 祖先に transform などが付くと position:fixed が画面基準でなくなるので、body 直下へ移す。
756    document.body.appendChild(panel);
757    var kind = panel.getAttribute('data-kind') || '';
758    var closeBtn = panel.querySelector('.tano-readend__close');
759    closeBtn.addEventListener('click', function () {
760      reSet(false, RE_UNTIL_KEY, String(Date.now() + RE_SNOOZE_MS));
761      track('readend_panel_dismiss', { kind: kind });
762      hideReadEnd(panel);
763    });
764    // パネルのリンク(LINE・移住相談)を押したときは、このページでは閉じる。
765    // 以後の出し方は変えない(3回までの規則に従う。2026-09-17 大東指示でLINEの永久停止は撤回)。
766    panel.addEventListener('click', function (e) {
767      if (e.target.closest('a')) hideReadEnd(panel);
768    });
769    document.addEventListener('keydown', function (e) {
770      if (e.key === 'Escape' && !panel.hidden) closeBtn.click();
771    });
772    var seen = false;
773    var io = new IntersectionObserver(function (entries) {
774      if (seen || !entries.some(function (en) { return en.isIntersecting; })) return;
775      // 開いた時点で本文の終わりまで見えている短いページは、読み終えたとは言えないので出さない。
776      if ((window.scrollY || document.documentElement.scrollTop) < window.innerHeight * 0.5) return;
777      seen = true;
778      io.disconnect();
779      // 本文の終わりに届いたこと自体も記録する(article_scroll_75 はページ全体の75%で、
780      // 記事によって本文の途中で発火するため、読了の指標としてはこちらを使う)。
781      track('article_read_end', { page: location.pathname });
782      if (!readEndAllowed()) return;
783      reSet(true, RE_COUNT_KEY, String((parseInt(reGet(true, RE_COUNT_KEY), 10) || 0) + 1));
784      showReadEnd(panel, mark);
785      track('readend_panel_view', { kind: kind });
786    });
787    io.observe(mark);
788  }
789  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
790
791  // articles.json は日本語記事の一覧。英語ページでは保存/共有バーも関連記事も出さないので取りに行かない。
792  if (!IS_EN) {
793    fetch('/articles.json').then(function (r) { return r.json(); }).then(function (d) {
794      ARTICLES = d; updateBmCount(); injectArticleBar(); injectRelated();
795    }).catch(function () { injectArticleBar(); });
796  }
797})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.