tracklint 0.1.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/LICENSE +21 -0
- package/README.md +94 -0
- package/action.yml +22 -0
- package/package.json +37 -0
- package/src/check.mjs +153 -0
- package/src/scan.mjs +273 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hyuga611
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# tracklint
|
|
2
|
+
|
|
3
|
+
**Did a redesign quietly stop your form from tracking conversions?**
|
|
4
|
+
A conversion-tracking **integrity** linter. It reads your HTML/JSX source and **fails the PR** (exit 1 + inline annotations) when a form or CTA isn't wired for tracking. Zero-dependency, language-agnostic, runs in CI.
|
|
5
|
+
|
|
6
|
+
**その改修、フォームのコンバージョン計測を静かに壊していませんか?**
|
|
7
|
+
送信ボタン・フォーム・サンクスページが計測に **配線されているか** を CI で検証するリンタ。表示は正常なのに計測だけ壊れる—を PR 時点で落とす。依存ゼロ・言語非依存。
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why / なぜ
|
|
12
|
+
|
|
13
|
+
Conversion tracking is the one layer that **looks fine when it breaks**. A redesign turns a `<button type="submit">` into a `<div>`, the GTM click trigger silently stops firing, unit tests still pass, visual review misses it — and nobody notices until the numbers don't add up months later (sometimes they even go *up*, because a thank-you page with no `noindex` double-counts). Runtime QA tools (ObservePoint, Trackingplan, browser debuggers) only catch this *after* it ships. `tracklint` checks the *source*, so it fails the build **before merge**. It checks facts, not prose — so it works in any language.
|
|
14
|
+
|
|
15
|
+
## Use as a GitHub Action / CIで使う(定着の本体)
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
# .github/workflows/tracklint.yml
|
|
19
|
+
name: tracklint
|
|
20
|
+
on: [push, pull_request]
|
|
21
|
+
jobs:
|
|
22
|
+
tracklint:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: <you>/tracklint@v1 # auto-detects files containing <form>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Findings show up as inline PR annotations, and the job fails (exit 1) so broken tracking can't be merged.
|
|
30
|
+
|
|
31
|
+
## Use as a CLI / ローカルで使う
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx tracklint # <form> を含むファイルを自動検出
|
|
35
|
+
npx tracklint src/ pages/ # ファイル/ディレクトリ指定も可
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What it catches / 検出するもの
|
|
39
|
+
|
|
40
|
+
| rule | severity | 何を落とすか |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `submit-not-button` | error | 送信コントロールが `<div>` / `<input type=submit>` で `<button type="submit">` でない → クリック計測が要素を特定できない |
|
|
43
|
+
| `submit-missing-tracking` | error | 送信ボタンに一意の `id` も計測属性(`data-gtm-event` 等)も無い |
|
|
44
|
+
| `submit-duplicate-id` | error | 送信ボタンの `id` が重複 → クリックの二重計上/取り違え |
|
|
45
|
+
| `submit-dynamic-id` | warn | `id` がテンプレート展開 → 実行時の値・一意性を静的に検証できない |
|
|
46
|
+
| `ajax-no-conversion` | warn | AJAX 送信なのに成功時の `dataLayer.push` / `gtag` / `analytics.track` が無い |
|
|
47
|
+
| `thankyou-unresolved` | error | フォームのサンクスページがリポジトリに存在しない |
|
|
48
|
+
| `thankyou-indexable` | error | サンクスページに `noindex` が無い → インデックス漏れ・CV 二重計上 |
|
|
49
|
+
|
|
50
|
+
Exit code 1 when any `error` is found = a CI gate.(`warn` は既定では CI を落としません)
|
|
51
|
+
|
|
52
|
+
## Config / 設定(任意)
|
|
53
|
+
|
|
54
|
+
`tracklint.config.json` をリポジトリ直下に置くと上書きできます(無くても動きます)。
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"trackingAttributes": ["data-gtm-event", "data-track"],
|
|
59
|
+
"conversionCalls": ["dataLayer.push", "gtag", "analytics.track"],
|
|
60
|
+
"rules": {
|
|
61
|
+
"ajax-no-conversion": "error",
|
|
62
|
+
"submit-missing-gtm-event-attr": "warn"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## What this can and cannot see / できること・できないこと
|
|
68
|
+
|
|
69
|
+
これは **静的解析** です。「配線が存在し正しく書かれている」ことを検証しますが、**GTM/GA4 が実行時に実際に発火したことは保証しません**。以下は構造的に見えないので、`warn`/`info` に留めるか設定で抑制します。動的検証(E2E)は置き換えではなく補完です。
|
|
70
|
+
|
|
71
|
+
- GTM のコンテナ側(UI)だけで組んだトリガーは見えない(ページ側に手掛かり=id/属性があるかだけを見る)
|
|
72
|
+
- 別ファイル/バンドル/フレームワークのハンドラに配線された計測は追い切れない
|
|
73
|
+
- CMS やサーバーテンプレートが実行時に注入する `dataLayer.push` は静的ソースに無い
|
|
74
|
+
- HTTP ヘッダ(`X-Robots-Tag`)で付けた `noindex` は `<meta>` が無いと拾えない
|
|
75
|
+
- `robots.txt` の `Disallow` は `noindex` とは別物(クロール禁止であってインデックス禁止ではない)
|
|
76
|
+
|
|
77
|
+
## Roadmap
|
|
78
|
+
|
|
79
|
+
- [x] Static core: submit control / tracking hook / AJAX conversion / thank-you page — zero-dep (`src/scan.mjs`)
|
|
80
|
+
- [x] **GitHub Action** (`action.yml`) + inline PR annotations + self-CI dogfood
|
|
81
|
+
- [ ] `--runtime` mode (Playwright): 実際に送信してイベント発火とサンクス遷移を検証
|
|
82
|
+
- [ ] SARIF 出力(GitHub code-scanning 連携)
|
|
83
|
+
- [ ] baseline/allowlist(既存リポは新規違反だけで落とす)
|
|
84
|
+
- [ ] presets: Shopify / HubSpot / Contact Form 7 / WPForms
|
|
85
|
+
|
|
86
|
+
## Dev
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
node --test # unit tests
|
|
90
|
+
npm run poc # 壊れたサンプルで検出デモ → exit 1
|
|
91
|
+
npm run selfcheck # 正しいサンプルの自己検査 → exit 0
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
MIT
|
package/action.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: 'tracklint'
|
|
2
|
+
description: "Fail CI when a form or CTA isn't wired for conversion tracking (submit button, dataLayer.push, noindex thank-you page)."
|
|
3
|
+
author: 'tracklint'
|
|
4
|
+
branding:
|
|
5
|
+
icon: 'check-circle'
|
|
6
|
+
color: 'green'
|
|
7
|
+
inputs:
|
|
8
|
+
files:
|
|
9
|
+
description: 'Space-separated files or directories to check. Default: auto-detect files containing <form> in the repo.'
|
|
10
|
+
required: false
|
|
11
|
+
default: ''
|
|
12
|
+
runs:
|
|
13
|
+
using: 'composite'
|
|
14
|
+
steps:
|
|
15
|
+
- name: Run tracklint
|
|
16
|
+
shell: bash
|
|
17
|
+
# Pass the caller input via env (NOT ${{ }} expanded into the script) to avoid
|
|
18
|
+
# GitHub Actions template/shell injection. $FILES is intentionally unquoted so
|
|
19
|
+
# multiple space-separated paths split into separate argv.
|
|
20
|
+
env:
|
|
21
|
+
FILES: ${{ inputs.files }}
|
|
22
|
+
run: node "$GITHUB_ACTION_PATH/src/check.mjs" $FILES
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tracklint",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Conversion-tracking integrity linter — fail CI when a form or CTA isn't wired for tracking (submit button, dataLayer.push, noindex thank-you page). Zero-dependency, language-agnostic, runs in CI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tracklint": "src/check.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"action.yml"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"poc": "node src/check.mjs examples/bad",
|
|
18
|
+
"selfcheck": "node src/check.mjs examples/good",
|
|
19
|
+
"test": "node --test"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"conversion-tracking",
|
|
23
|
+
"analytics",
|
|
24
|
+
"gtm",
|
|
25
|
+
"google-tag-manager",
|
|
26
|
+
"ga4",
|
|
27
|
+
"datalayer",
|
|
28
|
+
"linter",
|
|
29
|
+
"ci",
|
|
30
|
+
"github-action",
|
|
31
|
+
"html",
|
|
32
|
+
"jsx",
|
|
33
|
+
"noindex",
|
|
34
|
+
"martech"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
package/src/check.mjs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tracklint — conversion-tracking integrity linter (CLI).
|
|
3
|
+
//
|
|
4
|
+
// 送信ボタン・フォーム・サンクスページが計測に配線されているかを静的に検査し、
|
|
5
|
+
// 壊れていれば exit 1 で CI を落とす。依存ゼロ・言語非依存。
|
|
6
|
+
// CI(GitHub Action)で毎PR走らせるのが本体。
|
|
7
|
+
//
|
|
8
|
+
// node src/check.mjs [file|dir ...] # 省略時は <form> を含むファイルを自動検出
|
|
9
|
+
|
|
10
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { resolve, join, relative } from 'node:path';
|
|
12
|
+
import { pathToFileURL } from 'node:url';
|
|
13
|
+
import { scan, collectIds, DEFAULT_CONFIG } from './scan.mjs';
|
|
14
|
+
|
|
15
|
+
const EXT = /\.(html?|php|jsx|tsx|vue|svelte)$/i;
|
|
16
|
+
const IGNORE_DIRS = new Set(['node_modules', 'dist', 'build', 'vendor', '.git', '.svn', 'coverage']);
|
|
17
|
+
|
|
18
|
+
function loadConfig(root) {
|
|
19
|
+
try {
|
|
20
|
+
const cfg = JSON.parse(readFileSync(join(root, 'tracklint.config.json'), 'utf8'));
|
|
21
|
+
return {
|
|
22
|
+
...DEFAULT_CONFIG,
|
|
23
|
+
...cfg,
|
|
24
|
+
rules: { ...DEFAULT_CONFIG.rules, ...(cfg.rules || {}) },
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
return DEFAULT_CONFIG;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(root, dir, out) {
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const full = join(dir, e.name);
|
|
40
|
+
if (e.isDirectory()) {
|
|
41
|
+
if (!IGNORE_DIRS.has(e.name)) walk(root, full, out);
|
|
42
|
+
} else if (EXT.test(e.name)) {
|
|
43
|
+
let text;
|
|
44
|
+
try {
|
|
45
|
+
text = readFileSync(full, 'utf8');
|
|
46
|
+
} catch {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (text.includes('<form')) out.push(relative(root, full).replace(/\\/g, '/'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 引数(ファイル/ディレクトリ)を <form> を含む対象ファイル一覧に展開する。 */
|
|
55
|
+
export function collectTargets(root, args) {
|
|
56
|
+
const out = [];
|
|
57
|
+
if (args.length === 0) {
|
|
58
|
+
walk(root, root, out);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
for (const a of args) {
|
|
62
|
+
const full = resolve(root, a);
|
|
63
|
+
let st;
|
|
64
|
+
try {
|
|
65
|
+
st = statSync(full);
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (st.isDirectory()) walk(root, full, out);
|
|
70
|
+
else out.push(relative(root, full).replace(/\\/g, '/'));
|
|
71
|
+
}
|
|
72
|
+
return [...new Set(out)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function main(argv) {
|
|
76
|
+
const inActions = process.env.GITHUB_ACTIONS === 'true';
|
|
77
|
+
const root = process.cwd();
|
|
78
|
+
const args = argv.filter((a) => a !== '--' && !a.startsWith('-'));
|
|
79
|
+
const config = loadConfig(root);
|
|
80
|
+
|
|
81
|
+
// 明示指定されたパスが存在しない場合は「素通りで exit 0」にせず error にする
|
|
82
|
+
// (files: のタイプミスやリネームで CI が黙って緑になる=偽の安心を防ぐ)。
|
|
83
|
+
if (args.length) {
|
|
84
|
+
const missing = args.filter((a) => !existsSync(resolve(root, a)));
|
|
85
|
+
if (missing.length) {
|
|
86
|
+
console.error(`tracklint: 指定されたパスが見つかりません: ${missing.join(', ')}`);
|
|
87
|
+
return 2;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const targets = collectTargets(root, args);
|
|
92
|
+
if (targets.length === 0) {
|
|
93
|
+
console.log('tracklint: <form> を含む対象ファイルがありません。スキップ。');
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 全ファイルを読み、id の重複をファイル横断で集計する
|
|
98
|
+
const texts = new Map();
|
|
99
|
+
for (const f of targets) {
|
|
100
|
+
try {
|
|
101
|
+
texts.set(f, readFileSync(resolve(root, f), 'utf8'));
|
|
102
|
+
} catch {
|
|
103
|
+
console.error(`tracklint: ${f} を読めません`);
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const idCount = new Map();
|
|
108
|
+
for (const t of texts.values()) for (const id of collectIds(t)) idCount.set(id, (idCount.get(id) || 0) + 1);
|
|
109
|
+
const isDupId = (id) => (idCount.get(id) || 0) > 1;
|
|
110
|
+
|
|
111
|
+
const exists = (p) => existsSync(resolve(root, p));
|
|
112
|
+
const readText = (p) => {
|
|
113
|
+
try {
|
|
114
|
+
return readFileSync(resolve(root, p), 'utf8');
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
let errors = 0;
|
|
121
|
+
let warns = 0;
|
|
122
|
+
for (const f of targets) {
|
|
123
|
+
const findings = scan(texts.get(f), { filename: f, exists, readText, isDupId, config });
|
|
124
|
+
if (findings.length === 0) {
|
|
125
|
+
console.log(`✓ ${f} — 計測OK`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const e = findings.filter((x) => x.severity === 'error').length;
|
|
129
|
+
const w = findings.length - e;
|
|
130
|
+
errors += e;
|
|
131
|
+
warns += w;
|
|
132
|
+
console.error(`✗ ${f} — ${findings.length} 件 (error:${e} warn:${w})`);
|
|
133
|
+
for (const x of findings) {
|
|
134
|
+
console.error(` ${f}:${x.ln}\t[${x.rule}] ${x.msg}`);
|
|
135
|
+
if (inActions) {
|
|
136
|
+
const lvl = x.severity === 'error' ? 'error' : 'warning';
|
|
137
|
+
console.log(`::${lvl} file=${f},line=${x.ln}::[${x.rule}] ${x.msg.replace(/\r?\n/g, ' ')}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (errors > 0) {
|
|
143
|
+
console.error(`\ntracklint: ${errors} 件の error${warns ? ` / ${warns} 件の warn` : ''}`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
console.log(`\ntracklint: error 0 件${warns ? `(warn ${warns} 件)` : ''} — OK`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 直接実行された時だけ CLI として動く(import 時は関数だけ公開)
|
|
151
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
152
|
+
process.exit(main(process.argv.slice(2)));
|
|
153
|
+
}
|
package/src/scan.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// tracklint — conversion-tracking integrity linter (pure core).
|
|
2
|
+
//
|
|
3
|
+
// 「フォーム / CTA が計測に配線されているか」を HTML/JSX ソースから静的に検証する。
|
|
4
|
+
// 表示は正常なのに計測だけ静かに壊れる—を PR 時点で落とす。依存ゼロ・言語非依存。
|
|
5
|
+
//
|
|
6
|
+
// このファイルは副作用ゼロの純粋関数だけを公開する(fs/network は check.mjs 側で注入)。
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONFIG = {
|
|
9
|
+
// 明示的な「計測フック」とみなす属性(provider を足すだけで自社トラッカにも対応)
|
|
10
|
+
trackingAttributes: ['data-gtm-event', 'data-ga4-event', 'data-track'],
|
|
11
|
+
// 「コンバージョンが飛んだ」とみなす JS 呼び出し
|
|
12
|
+
conversionCalls: ['dataLayer.push', 'gtag', 'analytics.track'],
|
|
13
|
+
// ルールごとの severity: 'error' | 'warn' | 'off'
|
|
14
|
+
rules: {
|
|
15
|
+
'submit-not-button': 'error',
|
|
16
|
+
'submit-missing-tracking': 'error',
|
|
17
|
+
'submit-dynamic-id': 'warn',
|
|
18
|
+
'submit-duplicate-id': 'error',
|
|
19
|
+
'submit-missing-gtm-event-attr': 'off', // ノイズ抑制のため既定 off
|
|
20
|
+
'ajax-no-conversion': 'warn', // 誤検知が出やすいので既定 warn
|
|
21
|
+
'thankyou-unresolved': 'error',
|
|
22
|
+
'thankyou-indexable': 'error',
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ---- tokenizer(ゼロ依存・行番号付き。JSX の {expr} 属性値も許容) ----
|
|
27
|
+
|
|
28
|
+
// タグ属性部は「クオート文字列」「{JSX式}(1段ネストまで)」「その他非区切り文字」を許容する。
|
|
29
|
+
// これにより onClick={() => f()} の中の '>' や data-x={a > b} でタグが途中終了しない。
|
|
30
|
+
const BRACE = String.raw`\{(?:[^{}]|\{[^{}]*\})*\}`;
|
|
31
|
+
const TAG = new RegExp(String.raw`<(\/?)([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|${BRACE}|[^"'>])*)>`, 'g');
|
|
32
|
+
const ATTR = new RegExp(String.raw`([\w:@.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(${BRACE})|([^\s"'=<>\`]+)))?`, 'g');
|
|
33
|
+
|
|
34
|
+
// HTML コメントと <script>/<style> 本文を「長さ・改行を保ったまま」空白化する。
|
|
35
|
+
// これで行番号やオフセットを狂わせずに、コメント/スクリプト内のタグ的テキストを走査対象から外せる。
|
|
36
|
+
const COMMENT = /<!--[\s\S]*?-->/g;
|
|
37
|
+
const RAWTEXT = /<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
|
38
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
39
|
+
|
|
40
|
+
function parseAttrs(str) {
|
|
41
|
+
const attrs = new Map();
|
|
42
|
+
let m;
|
|
43
|
+
ATTR.lastIndex = 0;
|
|
44
|
+
while ((m = ATTR.exec(str)) !== null) {
|
|
45
|
+
if (!m[1]) continue;
|
|
46
|
+
const name = m[1].toLowerCase();
|
|
47
|
+
const val = m[2] ?? m[3] ?? m[4] ?? m[5] ?? '';
|
|
48
|
+
if (!attrs.has(name)) attrs.set(name, val);
|
|
49
|
+
}
|
|
50
|
+
return attrs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** HTML/JSX をタグ列に分解する(コメント・<script>/<style> 本文・doctype は無視される)。 */
|
|
54
|
+
export function tokenize(html) {
|
|
55
|
+
const clean = html.replace(COMMENT, blank).replace(RAWTEXT, blank);
|
|
56
|
+
const tokens = [];
|
|
57
|
+
let m;
|
|
58
|
+
let pos = 0;
|
|
59
|
+
let line = 1;
|
|
60
|
+
TAG.lastIndex = 0;
|
|
61
|
+
while ((m = TAG.exec(clean)) !== null) {
|
|
62
|
+
while (pos < m.index) {
|
|
63
|
+
if (clean.charCodeAt(pos) === 10) line++;
|
|
64
|
+
pos++;
|
|
65
|
+
}
|
|
66
|
+
let attrStr = m[3];
|
|
67
|
+
// 自己終了 '/' の誤検出を避ける: 直前が空白/クオート、または属性部先頭のときだけ自己終了とみなす
|
|
68
|
+
// (action=/thanks/ のような未クオート値の末尾スラッシュを自己終了と誤認しない)。
|
|
69
|
+
const selfClose = /(?:^|[\s"'`])\/\s*$/.test(attrStr);
|
|
70
|
+
if (selfClose) attrStr = attrStr.replace(/\/\s*$/, '');
|
|
71
|
+
tokens.push({
|
|
72
|
+
name: m[2].toLowerCase(),
|
|
73
|
+
isClose: m[1] === '/',
|
|
74
|
+
selfClose,
|
|
75
|
+
attrs: parseAttrs(attrStr),
|
|
76
|
+
line,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return tokens;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const isDynamic = (v) => /\{|<\?|\$\{|%[sd]|\{\{/.test(v);
|
|
83
|
+
|
|
84
|
+
/** 静的で一意判定に使える id を集める(動的 id は除外)。check.mjs の重複検出用。 */
|
|
85
|
+
export function collectIds(html) {
|
|
86
|
+
const ids = [];
|
|
87
|
+
for (const t of tokenize(html)) {
|
|
88
|
+
if (t.isClose) continue;
|
|
89
|
+
const id = t.attrs.get('id');
|
|
90
|
+
if (id && !isDynamic(id)) ids.push(id);
|
|
91
|
+
}
|
|
92
|
+
return ids;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** サンクスページの <head> に noindex meta があるか。 */
|
|
96
|
+
export function hasNoindex(html) {
|
|
97
|
+
for (const t of tokenize(html)) {
|
|
98
|
+
if (t.name !== 'meta' || t.isClose) continue;
|
|
99
|
+
const name = (t.attrs.get('name') || '').toLowerCase();
|
|
100
|
+
const content = t.attrs.get('content') || '';
|
|
101
|
+
if ((name === 'robots' || name === 'googlebot') && /\b(noindex|none)\b/i.test(content)) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- パス解決(fromFile からの相対 / ルート絶対 → リポジトリ相対パス) ----
|
|
107
|
+
|
|
108
|
+
export function resolveDest(fromFile, dest) {
|
|
109
|
+
let d = String(dest).split(/[?#]/)[0].trim();
|
|
110
|
+
if (!d) return null;
|
|
111
|
+
if (/^(?:[a-z][\w+.-]*:)?\/\//i.test(d) || /^(?:mailto|tel):/i.test(d) || d.startsWith('#')) return null;
|
|
112
|
+
const endsSlash = d.endsWith('/');
|
|
113
|
+
let rel;
|
|
114
|
+
if (d.startsWith('/')) rel = d.slice(1);
|
|
115
|
+
else {
|
|
116
|
+
const dir = fromFile.replace(/\\/g, '/').replace(/\/[^/]*$/, '');
|
|
117
|
+
rel = fromFile.includes('/') ? `${dir}/${d}` : d;
|
|
118
|
+
}
|
|
119
|
+
const parts = [];
|
|
120
|
+
for (const seg of rel.split('/')) {
|
|
121
|
+
if (seg === '' || seg === '.') continue;
|
|
122
|
+
if (seg === '..') parts.pop();
|
|
123
|
+
else parts.push(seg);
|
|
124
|
+
}
|
|
125
|
+
let out = parts.join('/');
|
|
126
|
+
const base = out.split('/').pop() || '';
|
|
127
|
+
if (endsSlash || !/\.[a-z0-9]+$/i.test(base)) {
|
|
128
|
+
out = out ? `${out}/index.html` : 'index.html';
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- rules ----
|
|
134
|
+
|
|
135
|
+
function submitControl(form) {
|
|
136
|
+
// 優先: 正しい <button type=submit> → だめな送信要素(input submit/image, JSで submit するボタン/div 等)
|
|
137
|
+
let proper = null;
|
|
138
|
+
let improper = null;
|
|
139
|
+
for (const c of form.controls) {
|
|
140
|
+
const onclick = c.attrs.get('onclick') || '';
|
|
141
|
+
const jsSubmits = /\.(?:request)?submit\s*\(/i.test(onclick);
|
|
142
|
+
if (c.name === 'button') {
|
|
143
|
+
const type = (c.attrs.get('type') || 'submit').toLowerCase();
|
|
144
|
+
if (type === 'submit') {
|
|
145
|
+
if (!proper) proper = c;
|
|
146
|
+
} else if (jsSubmits && !improper) {
|
|
147
|
+
improper = c; // type=button/reset なのに JS でフォームを submit している
|
|
148
|
+
}
|
|
149
|
+
} else if (c.name === 'input') {
|
|
150
|
+
const type = (c.attrs.get('type') || '').toLowerCase();
|
|
151
|
+
if (type === 'submit' || type === 'image') {
|
|
152
|
+
if (!improper) improper = c;
|
|
153
|
+
} else if (jsSubmits && !improper) {
|
|
154
|
+
improper = c;
|
|
155
|
+
}
|
|
156
|
+
} else if (jsSubmits && !improper) {
|
|
157
|
+
improper = c; // div/span/a が onclick で form を submit している
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { proper, improper };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 1ファイル分の HTML/JSX を走査して findings を返す(純粋・テスト可能)。
|
|
165
|
+
* @param opts.filename リポジトリ相対のファイル名(パス解決に使う)
|
|
166
|
+
* @param opts.exists `(relPath) => boolean` サンクスページの実在判定
|
|
167
|
+
* @param opts.readText `(relPath) => string|null` サンクスページ本文の取得
|
|
168
|
+
* @param opts.isDupId `(id) => boolean` id がファイル横断で重複しているか
|
|
169
|
+
* @param opts.config 設定(DEFAULT_CONFIG にマージ済み)
|
|
170
|
+
*/
|
|
171
|
+
export function scan(html, opts = {}) {
|
|
172
|
+
const {
|
|
173
|
+
filename = '',
|
|
174
|
+
exists = () => true,
|
|
175
|
+
readText = () => null,
|
|
176
|
+
isDupId = () => false,
|
|
177
|
+
config = DEFAULT_CONFIG,
|
|
178
|
+
} = opts;
|
|
179
|
+
const rules = { ...DEFAULT_CONFIG.rules, ...(config.rules || {}) };
|
|
180
|
+
const trackingAttributes = config.trackingAttributes || DEFAULT_CONFIG.trackingAttributes;
|
|
181
|
+
const conversionCalls = config.conversionCalls || DEFAULT_CONFIG.conversionCalls;
|
|
182
|
+
|
|
183
|
+
const findings = [];
|
|
184
|
+
const push = (rule, ln, msg) => {
|
|
185
|
+
const severity = rules[rule] ?? 'error';
|
|
186
|
+
if (severity === 'off') return;
|
|
187
|
+
findings.push({ ln, rule, severity, msg });
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// フォームのスコープを組み立てる
|
|
191
|
+
const tokens = tokenize(html);
|
|
192
|
+
const forms = [];
|
|
193
|
+
const stack = [];
|
|
194
|
+
for (const t of tokens) {
|
|
195
|
+
if (t.name === 'form' && !t.isClose && !t.selfClose) {
|
|
196
|
+
const f = { line: t.line, attrs: t.attrs, controls: [] };
|
|
197
|
+
stack.push(f);
|
|
198
|
+
forms.push(f);
|
|
199
|
+
} else if (t.name === 'form' && t.isClose) {
|
|
200
|
+
stack.pop();
|
|
201
|
+
} else if (stack.length && !t.isClose && ['button', 'input', 'div', 'span', 'a'].includes(t.name)) {
|
|
202
|
+
stack[stack.length - 1].controls.push(t);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ファイル単位の signal(R3 用)。コメントは除外し、AJAX の判定は
|
|
207
|
+
// 「実際の submit ハンドラの証拠」に限定する(type="submit" 属性の "submit" に反応しない)。
|
|
208
|
+
const code = html.replace(COMMENT, ' ');
|
|
209
|
+
const hasSubmitHandler = /addEventListener\s*\(\s*['"]submit['"]|onsubmit\s*=|\.(?:request)?submit\s*\(/i.test(code);
|
|
210
|
+
const hasAjax =
|
|
211
|
+
/\bfetch\s*\(|XMLHttpRequest|\baxios\b|\$\.ajax|\.ajax\s*\(/.test(code) ||
|
|
212
|
+
(/preventDefault\s*\(/.test(code) && hasSubmitHandler);
|
|
213
|
+
const hasConversion = conversionCalls.some((c) => code.includes(c));
|
|
214
|
+
|
|
215
|
+
for (const form of forms) {
|
|
216
|
+
// R1 / R2: 送信コントロール
|
|
217
|
+
const { proper, improper } = submitControl(form);
|
|
218
|
+
if (improper && !proper) {
|
|
219
|
+
const what =
|
|
220
|
+
improper.name === 'input'
|
|
221
|
+
? `<input type="${(improper.attrs.get('type') || '').toLowerCase()}">`
|
|
222
|
+
: `<${improper.name}>`;
|
|
223
|
+
push('submit-not-button', improper.line, `送信コントロールが ${what} です。<button type="submit"> にしてください(GTM/GA4 のクリック計測が要素を特定できません)`);
|
|
224
|
+
} else if (proper) {
|
|
225
|
+
const id = proper.attrs.get('id');
|
|
226
|
+
const hasId = id != null && id !== '';
|
|
227
|
+
const hasTrackAttr = trackingAttributes.some((a) => proper.attrs.has(a) && proper.attrs.get(a) !== '');
|
|
228
|
+
const onclick = proper.attrs.get('onclick') || '';
|
|
229
|
+
const inlineConv = conversionCalls.some((c) => onclick.includes(c));
|
|
230
|
+
if (!hasId && !hasTrackAttr && !inlineConv) {
|
|
231
|
+
push('submit-missing-tracking', proper.line, '送信ボタンに一意の id・計測属性(data-gtm-event 等)・インライン計測呼び出しがありません(クリックが計測不能)');
|
|
232
|
+
} else if (hasId && isDynamic(id)) {
|
|
233
|
+
push('submit-dynamic-id', proper.line, `送信ボタンの id がテンプレート展開されています("${id}")。実行時の値・一意性を静的に検証できません`);
|
|
234
|
+
} else if (hasId && isDupId(id)) {
|
|
235
|
+
push('submit-duplicate-id', proper.line, `送信ボタンの id "${id}" が複数箇所で使われています。クリックが二重計上/取り違えられます`);
|
|
236
|
+
}
|
|
237
|
+
if (hasId && !hasTrackAttr) {
|
|
238
|
+
push('submit-missing-gtm-event-attr', proper.line, 'id はありますが data-gtm-event が無く、コンバージョン名が GTM コンテナ側にしか存在しません');
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// R4: サンクスページ
|
|
243
|
+
let dest = form.attrs.get('data-thankyou') || form.attrs.get('data-success-url');
|
|
244
|
+
if (!dest) {
|
|
245
|
+
const action = (form.attrs.get('action') || '').split(/[?#]/)[0];
|
|
246
|
+
if (action && !/^(?:[a-z][\w+.-]*:)?\/\//i.test(action)) {
|
|
247
|
+
const base = (action.replace(/\/$/, '').split('/').pop()) || '';
|
|
248
|
+
const ext = (base.match(/\.([a-z0-9]+)$/i) || [])[1];
|
|
249
|
+
if (!ext || /^html?$/i.test(ext)) dest = action; // ページ or ディレクトリ(.php 等のハンドラは静的に追えないのでスキップ)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (dest) {
|
|
253
|
+
const resolved = resolveDest(filename, dest);
|
|
254
|
+
if (resolved) {
|
|
255
|
+
if (!exists(resolved)) {
|
|
256
|
+
push('thankyou-unresolved', form.line, `サンクスページ "${dest}" (${resolved}) がリポジトリに存在しません`);
|
|
257
|
+
} else {
|
|
258
|
+
const body = readText(resolved);
|
|
259
|
+
if (body != null && !hasNoindex(body)) {
|
|
260
|
+
push('thankyou-indexable', form.line, `サンクスページ "${dest}" に <meta name="robots" content="noindex"> がありません(インデックス漏れ・CV 二重計上のリスク)`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// R3: AJAX 送信なのに成功時の計測イベントが無い(ファイル単位・1回だけ)
|
|
268
|
+
if (forms.length && hasAjax && !hasConversion) {
|
|
269
|
+
push('ajax-no-conversion', forms[0].line, `AJAX 送信のようですが、成功時の計測呼び出し(${conversionCalls.join(' / ')})がファイル内に見当たりません(ページ遷移しないため計測が飛びません)`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return findings.sort((a, b) => a.ln - b.ln);
|
|
273
|
+
}
|