Skip to content

Commit f79789d

Browse files
anchildress1claude
andauthored
feat(analytics): opt-in Firebase Analytics on generated pages (#93)
* feat(analytics): add opt-in Firebase Analytics to generated pages Injects the Firebase Analytics module snippet into every generated page (index, posts, comments) via a shared `firebase_analytics()` Jinja global. Gated on the FIREBASE_WEB_CONFIG env var (Firebase web config JSON): the snippet is emitted only when the config is present and carries a measurementId. Forks leave it unset and ship no analytics — mirroring the existing owner-only Firebase deploy guard. Wired through the generate-site action input and the FIREBASE_WEB_CONFIG repo variable in publish.yaml. The embedded JSON is owner-controlled repo config; '<' is escaped to neutralize any </script> breakout. Generated-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ci): pass FIREBASE_WEB_CONFIG to renderer step renderer.py is the last writer of index.html; without the env var the deployed homepage shipped without the analytics snippet while posts and comments had it. Closes a gap flagged in PR review. Generated-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ci(lefthook): run full ai-checks on pre-push pre-push only ran a subset (test + detect-secrets + validate-site), letting commits push without format/lint/complexity. Collapse it to `make ai-checks` so the push gate matches the manual gate exactly; detect-secrets and validate-site already run inside ai-checks. Generated-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ashley Childress <anchildress1@gmail.com> * ci(lefthook): run long tests on pre-push, not pre-commit's checks pre-push now runs the slow checks not done at commit time — full tests, complexity, site validation — instead of redoing format/lint/security that pre-commit already covers. Drops detect-secrets here since pre-commit's `make security` already runs it. Generated-by: Claude Opus 4.8 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Ashley Childress <anchildress1@gmail.com> --------- Signed-off-by: Ashley Childress <anchildress1@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 08eb167 commit f79789d

7 files changed

Lines changed: 101 additions & 3 deletions

File tree

.github/actions/generate-site/action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ inputs:
3434
description: Branch holding incremental state (last_run.txt, posts_data.json)
3535
required: false
3636
default: gh-pages
37+
firebase_web_config:
38+
description: Firebase web app config as JSON; enables Analytics injection when set
39+
required: false
40+
default: ""
3741
fallback_site_url:
3842
description: >-
3943
Base URL (with trailing slash) for robots.txt/sitemap when site_domain is
@@ -108,6 +112,7 @@ runs:
108112
DEVTO_KEY: ${{ inputs.devto_key }}
109113
PAGES_REPO: ${{ inputs.pages_repo }}
110114
FORCE_FULL_REGEN: ${{ inputs.force_full_regen }}
115+
FIREBASE_WEB_CONFIG: ${{ inputs.firebase_web_config }}
111116
run: |
112117
{
113118
echo "## 🏗️ Site Generation"
@@ -162,6 +167,10 @@ runs:
162167
- name: Generate sitemap and index
163168
if: steps.generate.outputs.no_new_posts != 'true'
164169
shell: bash
170+
# renderer.py is the last writer of index.html, so it needs the analytics
171+
# config too — otherwise the homepage ships without the snippet.
172+
env:
173+
FIREBASE_WEB_CONFIG: ${{ inputs.firebase_web_config }}
165174
run: |
166175
echo "## 📄 Generating Sitemap" >> "$GITHUB_STEP_SUMMARY"
167176
uv run python -m devto_mirror.site_generation.renderer

.github/workflows/publish.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ name: Generate and Publish Dev.to Mirror Site
1010
# - DEVTO_USERNAME: Your Dev.to username (required)
1111
# - GH_USERNAME: Your GitHub username (required if SITE_DOMAIN not set)
1212
# - SITE_DOMAIN: Custom domain for the site (optional)
13+
# - FIREBASE_WEB_CONFIG: Firebase web app config JSON (optional). When set,
14+
# the generated pages include the Firebase Analytics snippet. Forks leave
15+
# this unset and ship no analytics. This is public web config, not a secret.
1316
# - GCP_WORKLOAD_IDENTITY_PROVIDER: Full WIF provider resource name
1417
# (projects/<num>/locations/global/workloadIdentityPools/<pool>/providers/<provider>)
1518
# - GCP_DEPLOY_SERVICE_ACCOUNT: Email of the deploy service account
@@ -77,6 +80,7 @@ jobs:
7780
force_full_regen: ${{ github.event.inputs.force_full_regen || 'false' }}
7881
state_branch: mirror-state
7982
fallback_site_url: https://${{ vars.FIREBASE_PROJECT_ID || 'anchildress1' }}.web.app/
83+
firebase_web_config: ${{ vars.FIREBASE_WEB_CONFIG }}
8084

8185
- name: Authenticate to Google Cloud
8286
if: steps.site.outputs.no_new_posts != 'true'

lefthook.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ pre-commit:
2525
pre-push:
2626
parallel: true
2727
commands:
28+
# The long checks not run at commit time. format/lint/security already
29+
# ran in pre-commit, so don't redo them here.
2830
tests:
2931
run: make test
30-
detect-secrets:
31-
run: uv run python scripts/check_detect_secrets.py
32+
check-complexity:
33+
run: make check-complexity
3234
validate-site:
3335
run: uv run python scripts/validate_site_generation.py
3436
actionlint:

src/devto_mirror/core/utils.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22
Shared utilities for devto-mirror scripts
33
"""
44

5+
import json
6+
import os
57
import pathlib
68
from datetime import datetime, timezone
79
from email.utils import parsedate_to_datetime
810

911
from jinja2 import Environment, FileSystemLoader, select_autoescape
12+
from markupsafe import Markup
1013

1114
# Use a Jinja environment with autoescape enabled for HTML/XML templates
1215
# Also add FileSystemLoader to load templates from files
@@ -16,6 +19,42 @@
1619
autoescape=select_autoescape(["html", "xml"]),
1720
)
1821

22+
FIREBASE_SDK_VERSION = "12.15.0"
23+
24+
_FIREBASE_ANALYTICS_TMPL = """<!-- Firebase Analytics -->
25+
<script type="module">
26+
import {{ initializeApp }} from "https://www.gstatic.com/firebasejs/{version}/firebase-app.js";
27+
import {{ getAnalytics }} from "https://www.gstatic.com/firebasejs/{version}/firebase-analytics.js";
28+
const app = initializeApp({config});
29+
getAnalytics(app);
30+
</script>"""
31+
32+
33+
def firebase_analytics_snippet():
34+
"""Build the Firebase Analytics script tag from FIREBASE_WEB_CONFIG.
35+
36+
Returns safe markup for the snippet, or empty markup when the env var is
37+
unset/invalid or carries no measurementId. Forks without their own
38+
configured project therefore ship no analytics.
39+
"""
40+
raw = os.getenv("FIREBASE_WEB_CONFIG", "").strip()
41+
if not raw:
42+
return Markup("")
43+
try:
44+
config = json.loads(raw)
45+
except (ValueError, TypeError):
46+
return Markup("")
47+
if not isinstance(config, dict) or not config.get("measurementId"):
48+
return Markup("")
49+
# Owner-controlled repo variable, not user input; escaping "<" neutralizes
50+
# any </script> breakout so the embedded JSON is inert regardless of source.
51+
config_json = json.dumps(config).replace("<", "\\u003c")
52+
return Markup(_FIREBASE_ANALYTICS_TMPL.format(version=FIREBASE_SDK_VERSION, config=config_json)) # nosec B704
53+
54+
55+
# Expose to all templates rendered through this env; evaluated at render time.
56+
env.globals["firebase_analytics"] = firebase_analytics_snippet
57+
1958

2059
# Load post template from file if available, otherwise use inline template
2160
def get_post_template():
@@ -88,6 +127,7 @@ def get_post_template():
88127
</script>
89128
{% endfor %}
90129
{% endif %}
130+
{{ firebase_analytics() }}
91131
</head><body>
92132
<main>
93133
<h1><a href="{{ canonical }}">{{ title }}</a></h1>
@@ -191,6 +231,7 @@ def get_post_template():
191231
<!-- Additional Social Meta -->
192232
<meta name="image" content="{{ social_image }}">
193233
<meta name="author" content="{{ username }}">
234+
{{ firebase_analytics() }}
194235
</head><body>
195236
<main>
196237
<h1>{{ username }}—Dev.to Mirror</h1>

src/devto_mirror/site_generation/generator.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@
1818
from devto_mirror.core.path_utils import sanitize_filename, sanitize_slug, validate_safe_path
1919
from devto_mirror.core.run_state import get_last_run_timestamp, mark_no_new_posts, set_last_run_timestamp
2020
from devto_mirror.core.url_utils import build_site_urls
21-
from devto_mirror.core.utils import INDEX_TMPL, SITEMAP_TMPL, dedupe_posts_by_link, get_post_template
21+
from devto_mirror.core.utils import (
22+
INDEX_TMPL,
23+
SITEMAP_TMPL,
24+
dedupe_posts_by_link,
25+
firebase_analytics_snippet,
26+
get_post_template,
27+
)
2228

2329
# Import AI optimization components
2430
try:
@@ -79,6 +85,7 @@
7985
# Templates (posts + index)
8086
# ----------------------------
8187
env = Environment(autoescape=select_autoescape(["html", "xml"]))
88+
env.globals["firebase_analytics"] = firebase_analytics_snippet
8289

8390
# Get the post template (from file or inline fallback)
8491
PAGE_TMPL = get_post_template()
@@ -129,6 +136,7 @@
129136
</script>
130137
{% endfor %}
131138
{% endif %}
139+
{{ firebase_analytics() }}
132140
</head><body>
133141
<main>
134142
<h1>{{ title }}</h1>

src/devto_mirror/templates/post_template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
</script>
5353
{% endfor %}
5454
{% endif %}
55+
{{ firebase_analytics() }}
5556
</head><body>
5657
<main>
5758
<h1><a href="{{ canonical }}">{{ title }}</a></h1>

tests/test_core_utils.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,5 +309,38 @@ def test_sorted_newest_first(self):
309309
self.assertGreaterEqual(activity_0, activity_1)
310310

311311

312+
class TestFirebaseAnalyticsSnippet(unittest.TestCase):
313+
VALID_CONFIG = '{"apiKey": "abc", "projectId": "demo", "measurementId": "G-TEST123"}'
314+
315+
def test_empty_when_env_unset(self):
316+
with patch.dict("os.environ", {}, clear=False):
317+
import os
318+
319+
os.environ.pop("FIREBASE_WEB_CONFIG", None)
320+
self.assertEqual(str(utils_module.firebase_analytics_snippet()), "")
321+
322+
def test_empty_on_invalid_json(self):
323+
with patch.dict("os.environ", {"FIREBASE_WEB_CONFIG": "not json"}):
324+
self.assertEqual(str(utils_module.firebase_analytics_snippet()), "")
325+
326+
def test_empty_without_measurement_id(self):
327+
with patch.dict("os.environ", {"FIREBASE_WEB_CONFIG": '{"apiKey": "abc"}'}):
328+
self.assertEqual(str(utils_module.firebase_analytics_snippet()), "")
329+
330+
def test_renders_snippet_when_configured(self):
331+
with patch.dict("os.environ", {"FIREBASE_WEB_CONFIG": self.VALID_CONFIG}):
332+
snippet = str(utils_module.firebase_analytics_snippet())
333+
self.assertIn("getAnalytics", snippet)
334+
self.assertIn("G-TEST123", snippet)
335+
self.assertIn(utils_module.FIREBASE_SDK_VERSION, snippet)
336+
337+
def test_injected_into_index_template(self):
338+
with patch.dict("os.environ", {"FIREBASE_WEB_CONFIG": self.VALID_CONFIG}):
339+
html = utils_module.INDEX_TMPL.render(
340+
username="u", posts=[], comments=[], canonical="https://x", home="https://x", site_description=""
341+
)
342+
self.assertIn("G-TEST123", html)
343+
344+
312345
if __name__ == "__main__":
313346
unittest.main()

0 commit comments

Comments
 (0)