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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ jobs:
cache: 'npm'
- name: Install dependencies
run: npm ci --prefer-offline
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: E2E Test
run: npm run test:e2e
- if: failure()
Expand Down
19 changes: 18 additions & 1 deletion app/client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createClient } from 'honox/client'
import { setupAdControl } from './lib/adControl'
import { setupMakamujoBannerTracking } from './lib/makamujoBannerTracker'
import { type GtagFn, setupScrollDepthTracking } from './lib/scrollDepthTracker'

createClient()
Expand All @@ -17,7 +18,7 @@ const gtagFn: GtagFn = import.meta.env.PROD
}
}
: (command, name, params) => {
console.log('[scroll_depth]', command, name, params)
console.log('[gtag]', command, name, params)
}

setupScrollDepthTracking({
Expand Down Expand Up @@ -64,3 +65,19 @@ setupAdControl({
return 0
},
})

const whenReady = (fn: () => void) => {
if (document.readyState !== 'loading') {
fn()
} else {
document.addEventListener('DOMContentLoaded', fn, { once: true })
}
}

setupMakamujoBannerTracking({
gtagFn,
whenReady,
getAreaElements: () => Array.from(document.querySelectorAll<HTMLAreaElement>('area[data-gtag-event]')),
navigate: (href) => window.open(href, '_blank', 'noopener,noreferrer'),
maxDelayMs: 500,
})
4 changes: 4 additions & 0 deletions app/components/Article.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { css } from 'hono/css'
import { html } from 'hono/html'
import type { ArticleLink } from '../lib/articles'
import AdMax from './AdMax'
import MakamujoBanner from './MakamujoBanner'
import RelatedArticles from './RelatedArticles'

const articleClass = css`
Expand Down Expand Up @@ -156,9 +157,12 @@ ${'' /*<script type="text/javascript" charset="utf-8" src="https://adm.shinobi.j
children.children = newChildren
}

const isIndexPage = currentPath?.endsWith('/index.html') ?? false

return (
<article class={articleClass}>
{children}
{!isIndexPage && <MakamujoBanner />}
{relatedArticles && relatedArticles.length > 0 && currentPath && (
<>
<h2>他の記事</h2>
Expand Down
42 changes: 42 additions & 0 deletions app/components/MakamujoBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { css } from 'hono/css'

const wrap = css`
display: flex;
justify-content: center;
margin: 0.8rem 0;
`

export default function MakamujoBanner() {
return (
<div class={wrap}>
<img
src="https://www.nahcnuj.work/makamujo/banner.png"
alt="馬可無序(まか・むじょ)- AI-VTuber"
width="320"
height="100"
usemap="#makamujo-banner-map"
style="max-width: 100%; height: auto;"
/>
<map name="makamujo-banner-map">
{/* Banner is 320x100px. The "ニコニコ生放送で配信中" badge occupies the bottom strip at x:105-306, y:67-87. */}
<area
shape="rect"
coords="105,67,306,87"
href="https://live.nicovideo.jp/watch/user/14171889"
target="_blank"
rel="noopener noreferrer"
alt="ニコニコ生放送で配信中"
data-gtag-event="click_makamujo_nicovideo"
/>
<area
shape="default"
href="https://www.nahcnuj.work/makamujo/index.html"
target="_blank"
rel="noopener noreferrer"
alt="馬可無序プロジェクト"
data-gtag-event="click_makamujo_landing"
/>
</map>
</div>
)
}
218 changes: 218 additions & 0 deletions app/lib/makamujoBannerTracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setupMakamujoBannerTracking } from './makamujoBannerTracker'
import type { GtagFn } from './scrollDepthTracker'

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

type FakeArea = {
dataset: { gtagEvent?: string }
href: string
addEventListener: (event: string, handler: (e: Event) => void) => void
/** Simulate a user click on this area element. */
triggerClick: () => { preventDefault: ReturnType<typeof vi.fn> }
}

function makeFakeArea(gtagEvent: string | undefined, href: string): FakeArea {
let clickHandler: ((e: Event) => void) | undefined
return {
dataset: { gtagEvent },
href,
addEventListener: (event, handler) => {
if (event === 'click') clickHandler = handler
},
triggerClick: () => {
const e = { preventDefault: vi.fn() }
clickHandler?.(e as unknown as Event)
return e
},
}
}

function makeSetup(
areas: FakeArea[],
opts: {
navigate?: ReturnType<typeof vi.fn>
maxDelayMs?: number
gtag?: GtagFn
whenReady?: (fn: () => void) => void
} = {},
) {
const navigate = opts.navigate ?? vi.fn()
const gtag = opts.gtag ?? vi.fn()
const whenReady = opts.whenReady ?? ((fn) => fn())

setupMakamujoBannerTracking({
gtagFn: gtag as GtagFn,
whenReady,
getAreaElements: () => areas as unknown as HTMLAreaElement[],
navigate,
maxDelayMs: opts.maxDelayMs ?? 500,
})

return { navigate, gtag }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('setupMakamujoBannerTracking', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

describe('GA event name', () => {
it('fires "click_makamujo_nicovideo" when the NicoNico badge area is clicked', () => {
const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889')
const { gtag } = makeSetup([area])
area.triggerClick()
expect(gtag).toHaveBeenCalledWith('event', 'click_makamujo_nicovideo', expect.any(Object))
})

it('fires "click_makamujo_landing" when the landing-page area is clicked', () => {
const area = makeFakeArea('click_makamujo_landing', 'https://www.nahcnuj.work/makamujo/index.html')
const { gtag } = makeSetup([area])
area.triggerClick()
expect(gtag).toHaveBeenCalledWith('event', 'click_makamujo_landing', expect.any(Object))
})

it('passes event_timeout equal to maxDelayMs', () => {
const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889')
const { gtag } = makeSetup([area], { maxDelayMs: 300 })
area.triggerClick()
expect(gtag).toHaveBeenCalledWith(
'event',
'click_makamujo_nicovideo',
expect.objectContaining({ event_timeout: 300 }),
)
})
})

describe('navigation via event_callback', () => {
it('does not navigate immediately after click (waits for GA callback)', () => {
const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889')
const { navigate } = makeSetup([area])
area.triggerClick()
expect(navigate).not.toHaveBeenCalled()
})

it('navigates to the correct URL when event_callback fires', () => {
const href = 'https://live.nicovideo.jp/watch/user/14171889'
const area = makeFakeArea('click_makamujo_nicovideo', href)
let capturedCallback: (() => void) | undefined
const gtag = vi.fn().mockImplementation((_cmd, _name, params: Record<string, unknown>) => {
capturedCallback = params?.event_callback as () => void
})
const { navigate } = makeSetup([area], { gtag })
area.triggerClick()
capturedCallback?.()
expect(navigate).toHaveBeenCalledWith(href)
})
})

describe('navigation via timeout fallback', () => {
it('navigates after maxDelayMs when event_callback never fires', () => {
const href = 'https://www.nahcnuj.work/makamujo/index.html'
const area = makeFakeArea('click_makamujo_landing', href)
const gtag = vi.fn() // never calls event_callback
const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 })
area.triggerClick()
expect(navigate).not.toHaveBeenCalled()
vi.advanceTimersByTime(300)
expect(navigate).toHaveBeenCalledWith(href)
})

it('does not navigate before maxDelayMs elapses', () => {
const area = makeFakeArea('click_makamujo_landing', 'https://www.nahcnuj.work/makamujo/index.html')
const gtag = vi.fn()
const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 })
area.triggerClick()
vi.advanceTimersByTime(299)
expect(navigate).not.toHaveBeenCalled()
})
})

describe('no double navigation', () => {
it('navigates exactly once even when both event_callback and timeout fire', () => {
const href = 'https://live.nicovideo.jp/watch/user/14171889'
const area = makeFakeArea('click_makamujo_nicovideo', href)
let capturedCallback: (() => void) | undefined
const gtag = vi.fn().mockImplementation((_cmd, _name, params: Record<string, unknown>) => {
capturedCallback = params?.event_callback as () => void
})
const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 })
area.triggerClick()
capturedCallback?.() // fires callback first
vi.advanceTimersByTime(300) // then timeout fires
expect(navigate).toHaveBeenCalledTimes(1)
})

it('navigates exactly once when timeout fires before event_callback', () => {
const href = 'https://live.nicovideo.jp/watch/user/14171889'
const area = makeFakeArea('click_makamujo_nicovideo', href)
let capturedCallback: (() => void) | undefined
const gtag = vi.fn().mockImplementation((_cmd, _name, params: Record<string, unknown>) => {
capturedCallback = params?.event_callback as () => void
})
const { navigate } = makeSetup([area], { gtag, maxDelayMs: 300 })
area.triggerClick()
vi.advanceTimersByTime(300) // timeout fires first
capturedCallback?.() // then callback fires
expect(navigate).toHaveBeenCalledTimes(1)
})
})

describe('default prevention', () => {
it('calls preventDefault on the click event to stop native area navigation', () => {
const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889')
makeSetup([area])
const { preventDefault } = area.triggerClick()
expect(preventDefault).toHaveBeenCalled()
})
})

describe('edge cases', () => {
it('skips areas without a data-gtag-event attribute', () => {
const area = makeFakeArea(undefined, 'https://example.com')
const { gtag, navigate } = makeSetup([area])
area.triggerClick()
vi.advanceTimersByTime(500)
expect(gtag).not.toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})

it('does not attach listeners before whenReady fires', () => {
let readyFn: (() => void) | undefined
const area = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889')
const { gtag } = makeSetup([area], {
whenReady: (fn) => {
readyFn = fn
},
})
area.triggerClick()
expect(gtag).not.toHaveBeenCalled()
readyFn?.()
area.triggerClick()
expect(gtag).toHaveBeenCalledOnce()
})

it('handles multiple areas independently', () => {
const area1 = makeFakeArea('click_makamujo_nicovideo', 'https://live.nicovideo.jp/watch/user/14171889')
const area2 = makeFakeArea('click_makamujo_landing', 'https://www.nahcnuj.work/makamujo/index.html')
const gtag = vi.fn()
makeSetup([area1, area2], { gtag })
area1.triggerClick()
area2.triggerClick()
expect(gtag).toHaveBeenCalledTimes(2)
expect(gtag).toHaveBeenNthCalledWith(1, 'event', 'click_makamujo_nicovideo', expect.any(Object))
expect(gtag).toHaveBeenNthCalledWith(2, 'event', 'click_makamujo_landing', expect.any(Object))
})
})
})
60 changes: 60 additions & 0 deletions app/lib/makamujoBannerTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { GtagFn } from './scrollDepthTracker'

export interface MakamujoBannerTrackerOptions {
gtagFn: GtagFn
/** Calls `fn` once the DOM is ready to be queried (equivalent of DOMContentLoaded). */
whenReady: (fn: () => void) => void
/** Returns all `<area>` elements that carry a `data-gtag-event` attribute. */
getAreaElements: () => HTMLAreaElement[]
/** Opens the given URL (e.g. `window.open(href, '_blank', 'noopener,noreferrer')`). */
navigate: (href: string) => void
/** Maximum milliseconds to wait for the GA beacon before navigating anyway. */
maxDelayMs?: number
}

/**
* Intercepts clicks on Makamujo banner image-map `<area>` elements, fires a
* GA4 event named by their `data-gtag-event` attribute, and then opens the
* link. Navigation is delayed until the event is confirmed sent, or until
* `maxDelayMs` elapses — whichever comes first.
*/
export function setupMakamujoBannerTracking({
gtagFn,
whenReady,
getAreaElements,
navigate,
maxDelayMs = 500,
}: MakamujoBannerTrackerOptions): void {
whenReady(() => {
for (const area of getAreaElements()) {
const eventName = area.dataset.gtagEvent
if (!eventName) continue

area.addEventListener('click', (e) => {
e.preventDefault()
const href = area.href
if (!href) return

let navigated = false
const doNavigate = () => {
if (navigated) return
navigated = true
navigate(href)
}

// Fallback: navigate after maxDelayMs even if the GA beacon never fires.
const timeout = setTimeout(doNavigate, maxDelayMs)

gtagFn('event', eventName, {
// Called by gtag once the event has been dispatched.
event_callback: () => {
clearTimeout(timeout)
doNavigate()
},
// Tells gtag to call event_callback after at most maxDelayMs ms.
event_timeout: maxDelayMs,
})
})
}
})
}
Loading