diff --git a/gitlinq/docs/index.html b/gitlinq/docs/index.html
index 3f9b478..04a9eb9 100644
--- a/gitlinq/docs/index.html
+++ b/gitlinq/docs/index.html
@@ -2,6 +2,13 @@
+
+
+
GitLinq 설명서가 이동했습니다
@@ -20,6 +27,10 @@
+
+
+
DevsLab / GitLinq
GitLinq 설명서가 이동했습니다
설치부터 변경 조회·취소와 서버 업데이트까지, 초보자 설명서를 회사 홈페이지에서 확인하세요.
diff --git a/gitlinq/index.html b/gitlinq/index.html
index 743d6a2..53937ea 100644
--- a/gitlinq/index.html
+++ b/gitlinq/index.html
@@ -2,6 +2,13 @@
+
+
+
GitLinq 소개 페이지가 이동했습니다
@@ -20,6 +27,10 @@
+
+
+
DevsLab / GitLinq
GitLinq 소개 페이지가 이동했습니다
GitLinq 소개와 다운로드 안내는 DevsLab 회사 홈페이지에서 제공합니다.
diff --git a/index.html b/index.html
index 536d62a..2e3584f 100644
--- a/index.html
+++ b/index.html
@@ -2,6 +2,13 @@
+
+
+
데브스랩(DevsLab) 오픈소스 — 라이브 데모 허브
+
+
+
@@ -596,6 +608,18 @@ devslab-examples
'pt-BR': 'Open source por DevsLab', fr: 'Open source par DevsLab', de: 'Open Source von DevsLab',
es: 'Código abierto por DevsLab', ar: 'مصدر مفتوح من DevsLab'
})[lang];
+ // The company policy (devslab.kr/privacy) covers this hub too. Its
+ // label follows devslab.kr's footer; ?lang= carries the reader's
+ // language across (devslab.kr shows Korean, or English otherwise).
+ var privacy = document.getElementById('privacy-link');
+ privacy.textContent = ({
+ ko: '개인정보처리방침', en: 'Privacy Policy', ja: 'プライバシーポリシー',
+ 'zh-HK': '私隱政策', 'zh-TW': '隱私權政策', hi: 'गोपनीयता नीति',
+ vi: 'Chính sách quyền riêng tư', id: 'Kebijakan Privasi', th: 'นโยบายความเป็นส่วนตัว',
+ 'pt-BR': 'Política de Privacidade', fr: 'Politique de confidentialité', de: 'Datenschutzerklärung',
+ es: 'Política de privacidad', ar: 'سياسة الخصوصية'
+ })[lang];
+ privacy.href = 'https://devslab.kr/privacy/?lang=' + encodeURIComponent(lang);
setAll('[data-i18n="live"]', t.live);
setAll('[data-i18n="docs"]', t.docs);
for (var key in t.cards) {
diff --git a/tests/gtm.test.mjs b/tests/gtm.test.mjs
new file mode 100644
index 0000000..22f011c
--- /dev/null
+++ b/tests/gtm.test.mjs
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import { readdirSync, readFileSync } from 'node:fs';
+import { join, relative } from 'node:path';
+import test from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { runInNewContext } from 'node:vm';
+
+const CONTAINER = 'GTM-5WGTTWSF';
+const root = fileURLToPath(new URL('..', import.meta.url));
+const SKIP = new Set(['.git', 'node_modules', '.worktrees', '.playwright-cli']);
+
+// Every HTML file Pages serves, found by walking the tree rather than listed by
+// hand, so a page added later fails here until it carries the container too.
+function htmlFiles(dir) {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ if (SKIP.has(entry.name)) return [];
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) return htmlFiles(path);
+ return entry.name.endsWith('.html') ? [path] : [];
+ });
+}
+
+const pages = htmlFiles(root);
+
+test('the walk finds the hub and its move pages', () => {
+ const found = pages.map((p) => relative(root, p).replaceAll('\\', '/')).sort();
+ for (const expected of ['index.html', 'gitlinq/index.html', 'gitlinq/docs/index.html']) {
+ assert.ok(found.includes(expected), `${expected} was not found`);
+ }
+});
+
+for (const page of pages) {
+ const name = relative(root, page).replaceAll('\\', '/');
+
+ test(`${name} loads GTM once, first thing after `, () => {
+ const html = readFileSync(page, 'utf8');
+ assert.equal(html.split('googletagmanager.com/gtm.js?id=').length - 1, 1, 'exactly one GTM loader');
+ const head = html.slice(html.indexOf(''), html.indexOf(''));
+ const afterCharset = head.slice(head.indexOf('') + ''.length);
+ assert.ok(head.includes(''), 'the page declares its charset');
+ // Only whitespace and Google's own marker comment may sit between them.
+ assert.match(afterCharset, /^\s*\s*