Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ products/**/*.json
src/**/blogs.json
src/**/feeds.json

# localized Medium feed images (regenerated at build by scripts/feed2json.js)
static/img/blog-feed/

# playwright
.playwright-storage.json
.pw-user-data/
Expand Down
37 changes: 37 additions & 0 deletions docusaurus-plugin-gtm/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,50 @@
module.exports = function (context, options) {
const isProd = process.env.NODE_ENV === "production";
const storageKey = options.storageKey || "cookie-consent-preferences";
const waitForUpdate = options.waitForUpdate || 500;

return {
name: "docusaurus-plugin-gtm",
injectHtmlTags() {
if (!isProd) {
return {};
}

const consentDefault = `
(function(){
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
window.gtag = window.gtag || gtag;
gtag('consent','default',{
ad_storage:'denied',
ad_user_data:'denied',
ad_personalization:'denied',
analytics_storage:'denied',
functionality_storage:'denied',
personalization_storage:'denied',
security_storage:'granted',
wait_for_update:${waitForUpdate}
});
gtag('set','ads_data_redaction',true);
try {
var c = JSON.parse(localStorage.getItem(${JSON.stringify(storageKey)}));
if (c && c.consentGiven) {
gtag('consent','update',{
ad_storage: c.marketing?'granted':'denied',
ad_user_data: c.marketing?'granted':'denied',
ad_personalization: c.marketing?'granted':'denied',
analytics_storage: c.analytics?'granted':'denied',
functionality_storage: c.functional?'granted':'denied',
personalization_storage: c.functional?'granted':'denied'
});
}
} catch (e) {}
})();`;

return {
headTags: [
// Consent Mode defaults must run before the GTM loader below.
{ tagName: "script", innerHTML: consentDefault },
`<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
Expand Down
4 changes: 4 additions & 0 deletions docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,10 @@ const config = {
require.resolve("./docusaurus-plugin-gtm/index.js"),
{
gtm: "GTM-PLXD79N",
// Must match STORAGE_KEY in src/components/CookieConsent/consent.js —
// the head script reads this to replay a returning visitor's choice
// before GTM loads.
storageKey: "cookie-consent-preferences",
},
],
tailwindPlugin,
Expand Down
129 changes: 129 additions & 0 deletions scripts/feed2json.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const fs = require("fs");
const https = require("https");
const http = require("http");
const zlib = require("zlib");
const path = require("path");
const crypto = require("crypto");
const { URL } = require("url");
const xml2js = require("xml2js");

Expand Down Expand Up @@ -421,10 +423,137 @@ async function parseRSS(source) {
}
}

// ---------------- Localize feed images (build-time) ----------------
// Medium feed content references images and a tracking pixel on medium.com
// domains. BlogCard both scrapes content_html for a card image and injects it
// via innerHTML, so those remote assets get requested by the browser and set
// third-party cookies before consent. To avoid that, at build time we download
// the hero image locally and strip all remote <img> tags (including the
// medium.com/_/stat pixel) so the client never requests anything off-site.
const IMG_DIR = path.join(__dirname, "..", "static", "img", "blog-feed");
const IMG_PUBLIC_BASE = "/img/blog-feed";
const STAT_PIXEL_RE = /medium\.com\/_\/stat/i;
const EXT_BY_TYPE = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
"image/avif": ".avif",
};

function fetchImageBuffer(fileUrl, redirectsLeft = 5) {
return new Promise((resolve, reject) => {
let url;
try {
url = new URL(fileUrl);
} catch (e) {
return reject(e);
}
const client = url.protocol === "https:" ? https : http;
const req = client.get(
fileUrl,
{
headers: {
"User-Agent": "Mozilla/5.0 (compatible; pan.dev-feed-image-fetch)",
Accept: "image/*,*/*",
},
},
(res) => {
const status = res.statusCode || 0;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
if (redirectsLeft <= 0)
return reject(new Error("Too many redirects"));
const next = new URL(res.headers.location, fileUrl).toString();
return resolve(fetchImageBuffer(next, redirectsLeft - 1));
}
if (status !== 200) {
res.resume();
return reject(
new Error(`Image request failed with status ${status}`)
);
}
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () =>
resolve({
buffer: Buffer.concat(chunks),
contentType: (res.headers["content-type"] || "")
.split(";")[0]
.trim()
.toLowerCase(),
})
);
res.on("error", reject);
}
);
req.on("error", reject);
req.setTimeout(20000, () =>
req.destroy(new Error("Image request timed out"))
);
});
}

async function downloadImage(imageUrl) {
const { buffer, contentType } = await fetchImageBuffer(imageUrl);
const ext =
EXT_BY_TYPE[contentType] ||
path.extname(new URL(imageUrl).pathname) ||
".jpg";
const name =
crypto.createHash("sha1").update(imageUrl).digest("hex").slice(0, 16) + ext;
fs.mkdirSync(IMG_DIR, { recursive: true });
fs.writeFileSync(path.join(IMG_DIR, name), buffer);
return `${IMG_PUBLIC_BASE}/${name}`;
}

function firstContentImage(html) {
const re = /<img[^>]+src="([^"]+)"/gi;
let m;
while ((m = re.exec(html))) {
const src = m[1];
if (STAT_PIXEL_RE.test(src)) continue;
if (/^https?:\/\//i.test(src)) return src;
}
return null;
}

function stripImages(html) {
return (html || "").replace(/<img[^>]*>/gi, "");
}

async function localizeFeedImages(feed) {
if (!feed || !Array.isArray(feed.items)) return feed;
for (const item of feed.items) {
const candidate =
item.thumbnail || firstContentImage(item.content_html || "");
if (
candidate &&
/^https?:\/\//i.test(candidate) &&
!STAT_PIXEL_RE.test(candidate)
) {
try {
item.thumbnail = await downloadImage(candidate);
} catch (err) {
// Leave thumbnail unset so BlogCard falls back to the local stock image.
console.error(
`[feed] Failed to localize image ${candidate}: ${err.message}`
);
}
}
// Remove remote images (incl. the Medium stat pixel) so the client never
// requests them, regardless of consent.
item.content_html = stripImages(item.content_html || "");
}
return feed;
}

// ---------------- Example usage ----------------
(async () => {
const source = process.argv[2] || "example.xml";
const jsonOutput = await parseRSS(source);
await localizeFeedImages(jsonOutput);
console.log(JSON.stringify(jsonOutput, null, 2));
})();

Expand Down
58 changes: 58 additions & 0 deletions src/components/CookieConsent/consent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Shared consent helpers. The storage key and category -> Google signal mapping
// must stay in sync with docusaurus-plugin-gtm/index.js, which reads the same
// key on page load to replay the stored choice before GTM fires.
export const STORAGE_KEY = "cookie-consent-preferences";

function ensureGtag() {
window.dataLayer = window.dataLayer || [];
if (typeof window.gtag !== "function") {
window.gtag = function () {
window.dataLayer.push(arguments);
};
}
}

export function readConsent() {
if (typeof window === "undefined") return null;
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY));
} catch (e) {
return null;
}
}

export function updateConsent({
analytics = false,
marketing = false,
functional = false,
} = {}) {
const prefs = {
necessary: true,
analytics,
marketing,
functional,
consentGiven: true,
timestamp: Date.now(),
};

try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
} catch (e) {
// localStorage unavailable (private mode, etc.) — signal consent anyway.
}

ensureGtag();
window.gtag("consent", "update", {
ad_storage: marketing ? "granted" : "denied",
ad_user_data: marketing ? "granted" : "denied",
ad_personalization: marketing ? "granted" : "denied",
analytics_storage: analytics ? "granted" : "denied",
functionality_storage: functional ? "granted" : "denied",
personalization_storage: functional ? "granted" : "denied",
});

window.dispatchEvent(
new CustomEvent("cookieConsentChange", { detail: prefs })
);
return prefs;
}
54 changes: 54 additions & 0 deletions src/components/CookieConsent/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useEffect, useState } from "react";
import Link from "@docusaurus/Link";
import { readConsent, updateConsent } from "./consent";
import "./styles.scss";

export default function CookieConsent() {
const [show, setShow] = useState(false);

// Runs only on the client, so the banner never renders during SSR and can't
// cause a hydration mismatch.
useEffect(() => {
if (!readConsent()?.consentGiven) {
setShow(true);
}
}, []);

if (!show) return null;

const choose = (prefs) => {
updateConsent(prefs);
setShow(false);
};

return (
<div className="cookie-consent" role="dialog" aria-label="Cookie consent">
<p className="cookie-consent__text">
We use cookies to analyze site traffic and improve your experience. See
our{" "}
<Link to="https://www.paloaltonetworks.com/legal-notices/privacy">
Privacy Policy
</Link>
.
</p>
<div className="cookie-consent__actions">
<button
type="button"
className="cookie-consent__btn cookie-consent__btn--secondary"
onClick={() => choose({})}
>
Reject optional
</button>
<button
type="button"
className="cookie-consent__btn cookie-consent__btn--primary"
onClick={() =>
choose({ analytics: true, marketing: true, functional: true })
}
>
Accept all
</button>
</div>
</div>
);
}
Loading
Loading