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
199 changes: 100 additions & 99 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,129 +2,130 @@
"version": "0.2",
"language": "en",
"words": [
"stuar",
"stuartclark",
"Drupal",
"Nuxt",
"Acquia",
"Andrii",
"Archivo",
"JetBrains",
"Stylelint",
"Markdownlint",
"autodocs",
"autoplaying",
"avatar",
"Ballarat",
"behaviour",
"bichon",
"blockquotes",
"bubbleable",
"colour",
"commitlint",
"Playwright",
"Vitest",
"vue-tsc",
"vuejs",
"Druxt",
"DruxtJS",
"Giscus",
"giscus",
"composables",
"computeds",
"crispedges",
"cspell",
"DCSYD",
"DDEV",
"Decipher",
"Deciphered",
"LinkedIn",
"avatar",
"favicons",
"headshot",
"nojekyll",
"noopener",
"og-image",
"unlighthouse",
"knip",
"cspell",
"webmanifest",
"Ballarat",
"filefield",
"devconsole",
"devgrid",
"dockerised",
"Downunder",
"Drupal",
"drupalcode",
"DrupalCon",
"drupalcons",
"drupalgive",
"styleguide",
"Styleguide",
"hrefs",
"DDEV",
"dockerised",
"Drupalgov",
"drush",
"autoplaying",
"Storybook",
"storybook",
"autodocs",
"Storyobj",
"unmet",
"composables",
"Keychron",
"Druxt",
"DruxtJS",
"EDITMSG",
"endfor",
"favicons",
"favourites",
"filefield",
"frontpage",
"Giscus",
"giscus",
"githubsponsors",
"gtag",
"hacktheplanet",
"headshot",
"hrefs",
"imageapi",
"imagefield",
"JetBrains",
"Keychron",
"knip",
"langcode",
"Lightbox",
"linkcheck",
"LinkedIn",
"lpignore",
"lychee",
"maint",
"Markdownlint",
"mise",
"unmigrated",
"unhead",
"mistagged",
"networkidle",
"neworleans",
"nojekyll",
"noopener",
"normalisation",
"normalised",
"normalise",
"stabilise",
"neworleans",
"imagefield",
"npmjs",
"maint",
"unrecognised",
"normalised",
"normalises",
"noversion",
"unparseable",
"Acquia",
"bichon",
"DCSYD",
"Downunder",
"Drupalgov",
"organiser",
"npmjs",
"Nuxt",
"nuxtjs",
"nuxtlink",
"og-image",
"oklab",
"onecol",
"openspec",
"organisation",
"organising",
"organise",
"organiser",
"organising",
"pathauto",
"PHPCS",
"pixelwhip",
"Playwright",
"Podanenko",
"podarok",
"prefs",
"prerendered",
"prerendering",
"Roudakovov",
"WFCF",
"unleet",
"hacktheplanet",
"devconsole",
"devgrid",
"computeds",
"colour",
"lpignore",
"normalises",
"serialises",
"stabilise",
"stabilised",
"wrongpassword",
"prefs",
"unstub",
"imageapi",
"textimage",
"Storybook",
"storybook",
"Storyobj",
"stuar",
"Stuartc",
"openspec",
"wordmark",
"crispedges",
"mistagged",
"prerendered",
"prerendering",
"pathauto",
"langcode",
"onecol",
"twocol",
"blockquotes",
"behaviour",
"bubbleable",
"frontpage",
"githubsponsors",
"nuxtlink",
"nuxtjs",
"stuartclark",
"styleguide",
"Styleguide",
"Stylelint",
"textimage",
"trycloudflare",
"Lightbox",
"Andrii",
"Podanenko",
"podarok",
"endfor",
"favourites",
"PHPCS",
"oklab",
"pixelwhip",
"gtag"
"twocol",
"unhead",
"unimport",
"unleet",
"unlighthouse",
"unmet",
"unmigrated",
"unparseable",
"unrecognised",
"unstub",
"Vitest",
"vue-tsc",
"vuejs",
"webmanifest",
"WFCF",
"wordmark",
"wrongpassword"
],
"ignorePaths": [
"node_modules",
Expand Down
15 changes: 12 additions & 3 deletions nuxt/app/components/AppGiscusComments.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
defineProps<{ path: string }>()
const props = defineProps<{ path: string }>()

const colorMode = useColorMode()
const containerRef = ref<HTMLDivElement>()
Expand All @@ -8,7 +8,7 @@ function themeUrl(mode: string): string {
return `${window.location.origin}/giscus-theme-${mode === 'dark' ? 'dark' : 'light'}.css`
}

onMounted(() => {
function injectGiscus() {
const script = document.createElement('script')
script.src = 'https://giscus.app/client.js'
script.async = true
Expand All @@ -24,7 +24,16 @@ onMounted(() => {
script.setAttribute('data-theme', themeUrl(colorMode.value))
script.setAttribute('data-lang', 'en')
containerRef.value?.appendChild(script)
})
}

onMounted(injectGiscus)

// The container is keyed on `path`, so an article-to-article client-side
// navigation replaces it with an empty div while this component instance
// survives — onMounted does not fire again. Without re-injecting here the
// reader gets a blank comments block until a full page load. `flush: 'post'`
// waits for the replacement div to exist before appending to it.
watch(() => props.path, injectGiscus, { flush: 'post' })

// Giscus's iframe is cross-origin — it can't read the site's own dark-mode
// class, so theme switches have to be pushed in explicitly via its
Expand Down
23 changes: 17 additions & 6 deletions nuxt/app/composables/useSponsorTracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,16 @@ export function buildSponsorUrl(location: SponsorCtaLocation): string {
/**
* Composable for tracking sponsor CTA clicks in GA4.
*
* Pushes a ``sponsor_click`` event to ``window.dataLayer`` (the GA4 command
* queue that the gtag script processes). This is the same mechanism
* ``nuxt-gtag`` uses internally. No-ops automatically when GA4 is not active
* (dev, preview, SSR) because ``window.dataLayer`` is ``undefined`` in those
* contexts and the optional chaining (``?.``) makes the push a no-op.
* Dispatches through ``nuxt-gtag``'s ``gtag()`` helper rather than pushing to
* ``window.dataLayer`` directly. That distinction is load-bearing: gtag.js only
* processes queue entries that are genuine ``arguments`` objects, so a pushed
* array literal is enqueued and then silently ignored — the event never reaches
* GA4 and nothing anywhere reports an error.
*
* ``useGtag()`` is called here, during setup, so the returned ``trackClick`` is
* a plain function safe to invoke from a click handler outside Nuxt's context.
* ``gtag`` is a no-op on the server and a no-op when GA4 is inactive (dev,
* preview), because ``window.dataLayer`` is undefined there.
*
* @param location - Where on the site the CTA appears.
* @returns An object with:
Expand All @@ -42,13 +47,19 @@ export function useSponsorTracking(location: SponsorCtaLocation): {
trackClick: (target?: string) => void
sponsorUrl: ComputedRef<string>
} {
const { gtag } = useGtag()

/**
* Fire the ``sponsor_click`` GA4 event.
*
* The payload itself lives in ``app/utils/sponsorEvents.ts``, where it can be
* asserted directly; see the note there on why the auto-imported ``useGtag``
* cannot be spied on.
*
* @param target - The destination. Defaults to ``'github-sponsors'``.
*/
function trackClick(target = 'github-sponsors') {
window.dataLayer?.push(['event', 'sponsor_click', { location, target }])
trackSponsorClick(gtag, location, target)
}

return {
Expand Down
41 changes: 41 additions & 0 deletions nuxt/app/utils/sponsorEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* GA4 event payloads for the sponsor CTAs.
*
* Split out from ``useSponsorTracking`` so the dispatch can be asserted directly.
* The composable itself cannot be spied on: nuxt-gtag swaps in a no-op
* ``useGtagMock`` whenever ``gtag.enabled`` is false, which is every
* environment except production, and unimport injects that auto-import by
* absolute path so neither ``mockNuxtImport`` nor ``vi.mock`` can intercept it.
* Taking ``gtag`` as a parameter sidesteps all of that and follows the same
* util-plus-thin-wrapper split already used for ``interpolation`` and
* ``internalTraffic``.
*/

/**
* The gtag call signature used here. Narrowed to ``'event'`` rather than
* ``string`` so nuxt-gtag's generic ``Gtag``, whose command is constrained to
* its own union, stays assignable to it.
*/
export type SponsorGtagFn =
| ((command: 'event', name: string, params: Record<string, string>) => void)
| undefined

/**
* Fire the ``sponsor_click`` GA4 event.
*
* Dispatches via gtag rather than pushing to ``window.dataLayer`` directly:
* gtag.js only processes queue entries that are genuine ``arguments`` objects,
* so a pushed array literal is enqueued and then ignored. That is not
* theoretical, it is why this event recorded nothing for 400 days.
*
* @param gtag - nuxt-gtag's dispatcher; undefined when GA4 is inactive.
* @param location - Where on the site the CTA appears.
* @param target - The destination. Defaults to ``'github-sponsors'``.
*/
export function trackSponsorClick(
gtag: SponsorGtagFn,
location: string,
target = 'github-sponsors',
): void {
gtag?.('event', 'sponsor_click', { location, target })
}
35 changes: 35 additions & 0 deletions nuxt/tests/components/AppGiscusComments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,41 @@ describe('AppGiscusComments', () => {
expect(script?.getAttribute('data-repo')).toBe('Decipher/stuar.tc')
})

// These four attributes are what bind the embed to a real GitHub Discussions
// category. Every one of them fails silently if wrong — giscus renders an
// empty state rather than an error — so nothing else in the suite would
// notice a bad value. The IDs are opaque GitHub node IDs and cannot be
// derived from the repo name, which is exactly why they are pinned here.
it('pins the giscus repo, category and mapping that address the discussion', async () => {
colorState.mode = 'light'
const wrapper = await mountSuspended(AppGiscusComments, { props: { path: '/writing/example' } })
const script = wrapper.element.querySelector('script')
expect(script?.getAttribute('data-repo')).toBe('Decipher/stuar.tc')
expect(script?.getAttribute('data-repo-id')).toBe('R_kgDOGZt96w')
expect(script?.getAttribute('data-category')).toBe('General')
expect(script?.getAttribute('data-category-id')).toBe('DIC_kwDOGZt9684CAB_7')
// pathname mapping is what makes each article its own thread. Changing it
// silently orphans every existing discussion.
expect(script?.getAttribute('data-mapping')).toBe('pathname')
})

it('re-injects the client when navigating between articles', async () => {
colorState.mode = 'light'
const wrapper = await mountSuspended(AppGiscusComments, { props: { path: '/writing/first' } })
expect(wrapper.element.querySelector('script')).not.toBeNull()

// The container is keyed on path, so a client-side article-to-article
// navigation tears down the div holding the giscus iframe. onMounted has
// already run and will not run again, so without an explicit re-injection
// the reader is left with a permanently blank comments block.
await wrapper.setProps({ path: '/writing/second' })
await nextTick()

const script = wrapper.element.querySelector('script')
expect(script).not.toBeNull()
expect(script?.src).toBe('https://giscus.app/client.js')
})

it('uses the light theme stylesheet when color mode is light', async () => {
colorState.mode = 'light'
const wrapper = await mountSuspended(AppGiscusComments, { props: { path: '/writing/example' } })
Expand Down
Loading
Loading