| | |
| | | import { FilePath, FullSlug, joinSegments } from "../../util/path" |
| | | import { FullSlug, joinSegments } from "../../util/path" |
| | | import { QuartzEmitterPlugin } from "../types" |
| | | |
| | | // @ts-ignore |
| | |
| | | import styles from "../../styles/custom.scss" |
| | | import popoverStyle from "../../components/styles/popover.scss" |
| | | import { BuildCtx } from "../../util/ctx" |
| | | import { StaticResources } from "../../util/resources" |
| | | import { QuartzComponent } from "../../components/types" |
| | | import { googleFontHref, joinStyles } from "../../util/theme" |
| | | import { |
| | | googleFontHref, |
| | | googleFontSubsetHref, |
| | | joinStyles, |
| | | processGoogleFonts, |
| | | } from "../../util/theme" |
| | | import { Features, transform } from "lightningcss" |
| | | import { transform as transpile } from "esbuild" |
| | | import { write } from "./helpers" |
| | | import DepGraph from "../../depgraph" |
| | | |
| | | type ComponentResources = { |
| | | css: string[] |
| | |
| | | function getComponentResources(ctx: BuildCtx): ComponentResources { |
| | | const allComponents: Set<QuartzComponent> = new Set() |
| | | for (const emitter of ctx.cfg.plugins.emitters) { |
| | | const components = emitter.getQuartzComponents(ctx) |
| | | const components = emitter.getQuartzComponents?.(ctx) ?? [] |
| | | for (const component of components) { |
| | | allComponents.add(component) |
| | | } |
| | |
| | | afterDOMLoaded: new Set<string>(), |
| | | } |
| | | |
| | | function normalizeResource(resource: string | string[] | undefined): string[] { |
| | | if (!resource) return [] |
| | | if (Array.isArray(resource)) return resource |
| | | return [resource] |
| | | } |
| | | |
| | | for (const component of allComponents) { |
| | | const { css, beforeDOMLoaded, afterDOMLoaded } = component |
| | | if (css) { |
| | | componentResources.css.add(css) |
| | | } |
| | | if (beforeDOMLoaded) { |
| | | componentResources.beforeDOMLoaded.add(beforeDOMLoaded) |
| | | } |
| | | if (afterDOMLoaded) { |
| | | componentResources.afterDOMLoaded.add(afterDOMLoaded) |
| | | } |
| | | const normalizedCss = normalizeResource(css) |
| | | const normalizedBeforeDOMLoaded = normalizeResource(beforeDOMLoaded) |
| | | const normalizedAfterDOMLoaded = normalizeResource(afterDOMLoaded) |
| | | |
| | | normalizedCss.forEach((c) => componentResources.css.add(c)) |
| | | normalizedBeforeDOMLoaded.forEach((b) => componentResources.beforeDOMLoaded.add(b)) |
| | | normalizedAfterDOMLoaded.forEach((a) => componentResources.afterDOMLoaded.add(a)) |
| | | } |
| | | |
| | | return { |
| | |
| | | return res.code |
| | | } |
| | | |
| | | function addGlobalPageResources( |
| | | ctx: BuildCtx, |
| | | staticResources: StaticResources, |
| | | componentResources: ComponentResources, |
| | | ) { |
| | | function addGlobalPageResources(ctx: BuildCtx, componentResources: ComponentResources) { |
| | | const cfg = ctx.cfg.configuration |
| | | const reloadScript = ctx.argv.serve |
| | | |
| | | // popovers |
| | | if (cfg.enablePopovers) { |
| | |
| | | |
| | | if (cfg.analytics?.provider === "google") { |
| | | const tagId = cfg.analytics.tagId |
| | | staticResources.js.push({ |
| | | src: `https://www.googletagmanager.com/gtag/js?id=${tagId}`, |
| | | contentType: "external", |
| | | loadTime: "afterDOMReady", |
| | | }) |
| | | componentResources.afterDOMLoaded.push(` |
| | | window.dataLayer = window.dataLayer || []; |
| | | function gtag() { dataLayer.push(arguments); } |
| | | gtag("js", new Date()); |
| | | gtag("config", "${tagId}", { send_page_view: false }); |
| | | |
| | | document.addEventListener("nav", () => { |
| | | gtag("event", "page_view", { |
| | | page_title: document.title, |
| | | page_location: location.href, |
| | | const gtagScript = document.createElement('script'); |
| | | gtagScript.src = 'https://www.googletagmanager.com/gtag/js?id=${tagId}'; |
| | | gtagScript.defer = true; |
| | | gtagScript.onload = () => { |
| | | window.dataLayer = window.dataLayer || []; |
| | | function gtag() { |
| | | dataLayer.push(arguments); |
| | | } |
| | | gtag('js', new Date()); |
| | | gtag('config', '${tagId}', { send_page_view: false }); |
| | | gtag('event', 'page_view', { page_title: document.title, page_location: location.href }); |
| | | document.addEventListener('nav', () => { |
| | | gtag('event', 'page_view', { page_title: document.title, page_location: location.href }); |
| | | }); |
| | | });`) |
| | | }; |
| | | |
| | | document.head.appendChild(gtagScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "plausible") { |
| | | const plausibleHost = cfg.analytics.host ?? "https://plausible.io" |
| | | componentResources.afterDOMLoaded.push(` |
| | | const plausibleScript = document.createElement("script") |
| | | plausibleScript.src = "${plausibleHost}/js/script.manual.js" |
| | | plausibleScript.setAttribute("data-domain", location.hostname) |
| | | plausibleScript.defer = true |
| | | document.head.appendChild(plausibleScript) |
| | | const plausibleScript = document.createElement('script'); |
| | | plausibleScript.src = '${plausibleHost}/js/script.manual.js'; |
| | | plausibleScript.setAttribute('data-domain', location.hostname); |
| | | plausibleScript.defer = true; |
| | | plausibleScript.onload = () => { |
| | | window.plausible = window.plausible || function () { (window.plausible.q = window.plausible.q || []).push(arguments); }; |
| | | plausible('pageview'); |
| | | document.addEventListener('nav', () => { |
| | | plausible('pageview'); |
| | | }); |
| | | }; |
| | | |
| | | window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) } |
| | | |
| | | document.addEventListener("nav", () => { |
| | | plausible("pageview") |
| | | }) |
| | | document.head.appendChild(plausibleScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "umami") { |
| | | componentResources.afterDOMLoaded.push(` |
| | | const umamiScript = document.createElement("script") |
| | | umamiScript.src = ${cfg.analytics.host} ?? "https://analytics.umami.is/script.js" |
| | | umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}") |
| | | umamiScript.async = true |
| | | const umamiScript = document.createElement("script"); |
| | | umamiScript.src = "${cfg.analytics.host ?? "https://analytics.umami.is"}/script.js"; |
| | | umamiScript.setAttribute("data-website-id", "${cfg.analytics.websiteId}"); |
| | | umamiScript.setAttribute("data-auto-track", "true"); |
| | | umamiScript.defer = true; |
| | | |
| | | document.head.appendChild(umamiScript) |
| | | document.head.appendChild(umamiScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "goatcounter") { |
| | | componentResources.afterDOMLoaded.push(` |
| | | const goatcounterScriptPre = document.createElement('script'); |
| | | goatcounterScriptPre.textContent = \` |
| | | window.goatcounter = { no_onload: true }; |
| | | \`; |
| | | document.head.appendChild(goatcounterScriptPre); |
| | | |
| | | const endpoint = "https://${cfg.analytics.websiteId}.${cfg.analytics.host ?? "goatcounter.com"}/count"; |
| | | const goatcounterScript = document.createElement('script'); |
| | | goatcounterScript.src = "${cfg.analytics.scriptSrc ?? "https://gc.zgo.at/count.js"}"; |
| | | goatcounterScript.defer = true; |
| | | goatcounterScript.setAttribute('data-goatcounter', endpoint); |
| | | goatcounterScript.onload = () => { |
| | | window.goatcounter.endpoint = endpoint; |
| | | goatcounter.count({ path: location.pathname }); |
| | | document.addEventListener('nav', () => { |
| | | goatcounter.count({ path: location.pathname }); |
| | | }); |
| | | }; |
| | | |
| | | document.head.appendChild(goatcounterScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "posthog") { |
| | | componentResources.afterDOMLoaded.push(` |
| | | const posthogScript = document.createElement("script"); |
| | | posthogScript.innerHTML= \`!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]); |
| | | posthog.init('${cfg.analytics.apiKey}', { |
| | | api_host: '${cfg.analytics.host ?? "https://app.posthog.com"}', |
| | | capture_pageview: false, |
| | | }); |
| | | document.addEventListener('nav', () => { |
| | | posthog.capture('$pageview', { path: location.pathname }); |
| | | })\` |
| | | |
| | | document.head.appendChild(posthogScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "tinylytics") { |
| | | const siteId = cfg.analytics.siteId |
| | | componentResources.afterDOMLoaded.push(` |
| | | const tinylyticsScript = document.createElement('script'); |
| | | tinylyticsScript.src = 'https://tinylytics.app/embed/${siteId}.js?spa'; |
| | | tinylyticsScript.defer = true; |
| | | tinylyticsScript.onload = () => { |
| | | window.tinylytics.triggerUpdate(); |
| | | document.addEventListener('nav', () => { |
| | | window.tinylytics.triggerUpdate(); |
| | | }); |
| | | }; |
| | | |
| | | document.head.appendChild(tinylyticsScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "cabin") { |
| | | componentResources.afterDOMLoaded.push(` |
| | | const cabinScript = document.createElement("script") |
| | | cabinScript.src = "${cfg.analytics.host ?? "https://scripts.withcabin.com"}/hello.js" |
| | | cabinScript.defer = true |
| | | document.head.appendChild(cabinScript) |
| | | `) |
| | | } else if (cfg.analytics?.provider === "clarity") { |
| | | componentResources.afterDOMLoaded.push(` |
| | | const clarityScript = document.createElement("script") |
| | | clarityScript.innerHTML= \`(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; |
| | | t=l.createElement(r);t.defer=1;t.src="https://www.clarity.ms/tag/"+i; |
| | | y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y); |
| | | })(window, document, "clarity", "script", "${cfg.analytics.projectId}");\` |
| | | document.head.appendChild(clarityScript) |
| | | `) |
| | | } else if (cfg.analytics?.provider === "matomo") { |
| | | componentResources.afterDOMLoaded.push(` |
| | | const matomoScript = document.createElement("script"); |
| | | matomoScript.innerHTML = \` |
| | | let _paq = window._paq = window._paq || []; |
| | | |
| | | // Track SPA navigation |
| | | // https://developer.matomo.org/guides/spa-tracking |
| | | document.addEventListener("nav", () => { |
| | | _paq.push(['setCustomUrl', location.pathname]); |
| | | _paq.push(['setDocumentTitle', document.title]); |
| | | _paq.push(['trackPageView']); |
| | | }); |
| | | |
| | | _paq.push(['trackPageView']); |
| | | _paq.push(['enableLinkTracking']); |
| | | (function() { |
| | | const u="//${cfg.analytics.host}/"; |
| | | _paq.push(['setTrackerUrl', u+'matomo.php']); |
| | | _paq.push(['setSiteId', ${cfg.analytics.siteId}]); |
| | | const d=document, g=d.createElement('script'), s=d.getElementsByTagName |
| | | ('script')[0]; |
| | | g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); |
| | | })(); |
| | | \` |
| | | document.head.appendChild(matomoScript); |
| | | `) |
| | | } else if (cfg.analytics?.provider === "vercel") { |
| | | /** |
| | | * script from {@link https://vercel.com/docs/analytics/quickstart?framework=html#add-the-script-tag-to-your-site|Vercel Docs} |
| | | */ |
| | | componentResources.beforeDOMLoaded.push(` |
| | | window.va = window.va || function () { (window.vaq = window.vaq || []).push(arguments); }; |
| | | `) |
| | | componentResources.afterDOMLoaded.push(` |
| | | const vercelInsightsScript = document.createElement("script") |
| | | vercelInsightsScript.src = "/_vercel/insights/script.js" |
| | | vercelInsightsScript.defer = true |
| | | document.head.appendChild(vercelInsightsScript) |
| | | `) |
| | | } |
| | | |
| | |
| | | document.dispatchEvent(event) |
| | | `) |
| | | } |
| | | |
| | | let wsUrl = `ws://localhost:${ctx.argv.wsPort}` |
| | | |
| | | if (ctx.argv.remoteDevHost) { |
| | | wsUrl = `wss://${ctx.argv.remoteDevHost}:${ctx.argv.wsPort}` |
| | | } |
| | | |
| | | if (reloadScript) { |
| | | staticResources.js.push({ |
| | | loadTime: "afterDOMReady", |
| | | contentType: "inline", |
| | | script: ` |
| | | const socket = new WebSocket('${wsUrl}') |
| | | // reload(true) ensures resources like images and scripts are fetched again in firefox |
| | | socket.addEventListener('message', () => document.location.reload(true)) |
| | | `, |
| | | }) |
| | | } |
| | | } |
| | | |
| | | interface Options { |
| | | fontOrigin: "googleFonts" | "local" |
| | | } |
| | | |
| | | const defaultOptions: Options = { |
| | | fontOrigin: "googleFonts", |
| | | } |
| | | |
| | | export const ComponentResources: QuartzEmitterPlugin<Options> = (opts?: Partial<Options>) => { |
| | | const { fontOrigin } = { ...defaultOptions, ...opts } |
| | | // This emitter should not update the `resources` parameter. If it does, partial |
| | | // rebuilds may not work as expected. |
| | | export const ComponentResources: QuartzEmitterPlugin = () => { |
| | | return { |
| | | name: "ComponentResources", |
| | | getQuartzComponents() { |
| | | return [] |
| | | }, |
| | | async getDependencyGraph(ctx, content, _resources) { |
| | | // This emitter adds static resources to the `resources` parameter. One |
| | | // important resource this emitter adds is the code to start a websocket |
| | | // connection and listen to rebuild messages, which triggers a page reload. |
| | | // The resources parameter with the reload logic is later used by the |
| | | // ContentPage emitter while creating the final html page. In order for |
| | | // the reload logic to be included, and so for partial rebuilds to work, |
| | | // we need to run this emitter for all markdown files. |
| | | const graph = new DepGraph<FilePath>() |
| | | |
| | | for (const [_tree, file] of content) { |
| | | const sourcePath = file.data.filePath! |
| | | const slug = file.data.slug! |
| | | graph.addEdge(sourcePath, joinSegments(ctx.argv.output, slug + ".html") as FilePath) |
| | | } |
| | | |
| | | return graph |
| | | }, |
| | | async emit(ctx, _content, resources): Promise<FilePath[]> { |
| | | const promises: Promise<FilePath>[] = [] |
| | | async *emit(ctx, _content, _resources) { |
| | | const cfg = ctx.cfg.configuration |
| | | // component specific scripts and styles |
| | | const componentResources = getComponentResources(ctx) |
| | | // important that this goes *after* component scripts |
| | | // as the "nav" event gets triggered here and we should make sure |
| | | // that everyone else had the chance to register a listener for it |
| | | |
| | | let googleFontsStyleSheet = "" |
| | | if (fontOrigin === "local") { |
| | | if (cfg.theme.fontOrigin === "local") { |
| | | // let the user do it themselves in css |
| | | } else if (fontOrigin === "googleFonts") { |
| | | if (cfg.theme.cdnCaching) { |
| | | resources.css.push(googleFontHref(cfg.theme)) |
| | | } else { |
| | | let match |
| | | } else if (cfg.theme.fontOrigin === "googleFonts" && !cfg.theme.cdnCaching) { |
| | | // when cdnCaching is true, we link to google fonts in Head.tsx |
| | | const theme = ctx.cfg.configuration.theme |
| | | const response = await fetch(googleFontHref(theme)) |
| | | googleFontsStyleSheet = await response.text() |
| | | |
| | | const fontSourceRegex = /url\((https:\/\/fonts.gstatic.com\/s\/[^)]+\.(woff2|ttf))\)/g |
| | | if (theme.typography.title) { |
| | | const title = ctx.cfg.configuration.pageTitle |
| | | const response = await fetch(googleFontSubsetHref(theme, title)) |
| | | googleFontsStyleSheet += `\n${await response.text()}` |
| | | } |
| | | |
| | | googleFontsStyleSheet = await ( |
| | | await fetch(googleFontHref(ctx.cfg.configuration.theme)) |
| | | ).text() |
| | | if (!cfg.baseUrl) { |
| | | throw new Error( |
| | | "baseUrl must be defined when using Google Fonts without cfg.theme.cdnCaching", |
| | | ) |
| | | } |
| | | |
| | | while ((match = fontSourceRegex.exec(googleFontsStyleSheet)) !== null) { |
| | | // match[0] is the `url(path)`, match[1] is the `path` |
| | | const url = match[1] |
| | | // the static name of this file. |
| | | const [filename, ext] = url.split("/").pop()!.split(".") |
| | | const { processedStylesheet, fontFiles } = await processGoogleFonts( |
| | | googleFontsStyleSheet, |
| | | cfg.baseUrl, |
| | | ) |
| | | googleFontsStyleSheet = processedStylesheet |
| | | |
| | | googleFontsStyleSheet = googleFontsStyleSheet.replace(url, `/fonts/${filename}.ttf`) |
| | | |
| | | promises.push( |
| | | fetch(url) |
| | | .then((res) => { |
| | | if (!res.ok) { |
| | | throw new Error(`Failed to fetch font`) |
| | | } |
| | | return res.arrayBuffer() |
| | | }) |
| | | .then((buf) => |
| | | write({ |
| | | ctx, |
| | | slug: joinSegments("fonts", filename) as FullSlug, |
| | | ext: `.${ext}`, |
| | | content: Buffer.from(buf), |
| | | }), |
| | | ), |
| | | ) |
| | | // Download and save font files |
| | | for (const fontFile of fontFiles) { |
| | | const res = await fetch(fontFile.url) |
| | | if (!res.ok) { |
| | | throw new Error(`Failed to fetch font ${fontFile.filename}`) |
| | | } |
| | | |
| | | const buf = await res.arrayBuffer() |
| | | yield write({ |
| | | ctx, |
| | | slug: joinSegments("static", "fonts", fontFile.filename) as FullSlug, |
| | | ext: `.${fontFile.extension}`, |
| | | content: Buffer.from(buf), |
| | | }) |
| | | } |
| | | } |
| | | |
| | | addGlobalPageResources(ctx, resources, componentResources) |
| | | // important that this goes *after* component scripts |
| | | // as the "nav" event gets triggered here and we should make sure |
| | | // that everyone else had the chance to register a listener for it |
| | | addGlobalPageResources(ctx, componentResources) |
| | | |
| | | const stylesheet = joinStyles( |
| | | ctx.cfg.configuration.theme, |
| | | ...componentResources.css, |
| | | googleFontsStyleSheet, |
| | | ...componentResources.css, |
| | | styles, |
| | | ) |
| | | |
| | | const [prescript, postscript] = await Promise.all([ |
| | | joinScripts(componentResources.beforeDOMLoaded), |
| | | joinScripts(componentResources.afterDOMLoaded), |
| | | ]) |
| | | |
| | | promises.push( |
| | | write({ |
| | | ctx, |
| | | slug: "index" as FullSlug, |
| | | ext: ".css", |
| | | content: transform({ |
| | | filename: "index.css", |
| | | code: Buffer.from(stylesheet), |
| | | minify: true, |
| | | targets: { |
| | | safari: (15 << 16) | (6 << 8), // 15.6 |
| | | ios_saf: (15 << 16) | (6 << 8), // 15.6 |
| | | edge: 115 << 16, |
| | | firefox: 102 << 16, |
| | | chrome: 109 << 16, |
| | | }, |
| | | include: Features.MediaQueries, |
| | | }).code.toString(), |
| | | }), |
| | | write({ |
| | | ctx, |
| | | slug: "prescript" as FullSlug, |
| | | ext: ".js", |
| | | content: prescript, |
| | | }), |
| | | write({ |
| | | ctx, |
| | | slug: "postscript" as FullSlug, |
| | | ext: ".js", |
| | | content: postscript, |
| | | }), |
| | | ) |
| | | yield write({ |
| | | ctx, |
| | | slug: "index" as FullSlug, |
| | | ext: ".css", |
| | | content: transform({ |
| | | filename: "index.css", |
| | | code: Buffer.from(stylesheet), |
| | | minify: true, |
| | | targets: { |
| | | safari: (15 << 16) | (6 << 8), // 15.6 |
| | | ios_saf: (15 << 16) | (6 << 8), // 15.6 |
| | | edge: 115 << 16, |
| | | firefox: 102 << 16, |
| | | chrome: 109 << 16, |
| | | }, |
| | | include: Features.MediaQueries, |
| | | }).code.toString(), |
| | | }) |
| | | |
| | | return await Promise.all(promises) |
| | | yield write({ |
| | | ctx, |
| | | slug: "prescript" as FullSlug, |
| | | ext: ".js", |
| | | content: prescript, |
| | | }) |
| | | |
| | | yield write({ |
| | | ctx, |
| | | slug: "postscript" as FullSlug, |
| | | ext: ".js", |
| | | content: postscript, |
| | | }) |
| | | }, |
| | | async *partialEmit() {}, |
| | | } |
| | | } |