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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ EMAIL_FROM='"Topiqu" <notifications@example.com>'
# xAI — Grok visibility monitoring with native web search
XAI_API_KEY="your_xai_api_key"

# Anthropic — Claude visibility monitoring with native web search
# Anthropic — Claude Haiku 5.5 for every text task (writing, research, translation), plus Claude visibility monitoring
ANTHROPIC_API_KEY="your_anthropic_api_key"

# Google Generative AI — Gemini visibility monitoring with search grounding
GOOGLE_GENERATIVE_AI_API_KEY="your_google_ai_api_key"

# OpenAI — GPT-5.6 Luna editorial tasks, GPT-6 Luna routine tasks, images and embeddings
# OpenAI — images, embeddings and ChatGPT visibility monitoring
OPENAI_API_KEY="your_openai_api_key"

# Meta Model API — Muse Spark visibility monitoring with search grounding
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:

- name: Typecheck
run: bun run typecheck
# vue-tsc outgrew Node's default heap on the runner (OOM since 2026-10-09).
env:
NODE_OPTIONS: --max-old-space-size=6144

test:
name: Test
Expand Down Expand Up @@ -191,6 +194,9 @@ jobs:

- name: Build production output
run: bun run build:docker
# `nuxt build` runs on Node here (the image builds on Bun) and sits near the default heap.
env:
NODE_OPTIONS: --max-old-space-size=6144

- name: Install browser engines
run: bunx playwright install --with-deps chromium webkit
Expand Down
10 changes: 5 additions & 5 deletions MAP.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ ZenStack generates ignored schema metadata and TypeScript types in `generated/ze
| **State & queries** | Pinia · Pinia Colada |
| **Data** | ZenStack ORM 3 · PostgreSQL · pgvector |
| **Editor** | Tiptap 3 with custom extensions |
| **AI** | AI SDK 7 · OpenAI Responses API |
| **AI** | AI SDK 7 · Claude Haiku 5.5 · OpenAI |
| **Runtime** | Bun 1.4.2 · Node 24.21.0 |

<details>
Expand Down
20 changes: 13 additions & 7 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@
<UApp :locale="uiLocale">
<NuxtLoadingIndicator class="z-top" :color="loadingColor" />
<NuxtRouteAnnouncer />
<NetworkIndicator />
<AppOptimisticStatus />
<NetworkIndicator v-if="!isWidgetPagePreview" />
<AppOptimisticStatus v-if="!isWidgetPagePreview" />

<NuxtLayout>
<NuxtPage />
</NuxtLayout>

<ConsentManager :site="clientSite" :enabled="isPublicationSurface" />
<AdSenseLoader :enabled="isPublicationSurface && platformAdsEnabledForPlan(clientSite?.plan) && marketingGranted" />
<ConsentManager v-if="!isWidgetPagePreview" :site="liveClientSite" :enabled="isPublicationSurface" />
<AdSenseLoader
v-if="!isWidgetPagePreview"
:enabled="isPublicationSurface && platformAdsEnabledForPlan(liveClientSite?.plan) && marketingGranted"
/>

<DevOnly v-if="!isBrowserTest">
<DevOnly v-if="!isBrowserTest && !isWidgetPagePreview">
<DevConsole />
</DevOnly>

<ClientOnly>
<ClientOnly v-if="!isWidgetPagePreview">
<ClientVersion v-if="consentLauncher === 'client-version'" />
<ConsentSettingsButton v-else-if="consentLauncher === 'cookie-button'" />
<ArticleGenerationActivity />
Expand All @@ -36,6 +39,9 @@ import { activeBrandGradient, gradientCss } from '~~/shared/utils/publicationBra

const reqUrl = useRequestURL()
const route = useRoute()
const { active: isWidgetPagePreview } = useWidgetPagePreview()
useWidgetPagePreviewBridge()
useHead(() => ({ meta: isWidgetPagePreview.value ? [{ name: 'robots', content: 'noindex, nofollow' }] : [] }))
const clientSite = await useClientSite()
const liveClientSite = await useLiveClientSite()
const i18nHead = useLocaleHead()
Expand All @@ -45,7 +51,7 @@ const { data: auth } = useAuth()
const uiLocales = { cs, de, en, fr }
const uiLocale = computed(() => uiLocales[locale.value as keyof typeof uiLocales] ?? en)
const isBrowserTest = Boolean(useRuntimeConfig().public.browserTest)
const { marketingGranted } = useConsent(() => clientSite)
const { marketingGranted } = useConsent(() => liveClientSite.value)

const isArticleRoute = computed(() => String(route.name ?? '').startsWith('clanky-slug'))
const isPublicationSurface = computed(() => resolvePageShell(route.meta.shell) === 'publication')
Expand Down
4 changes: 4 additions & 0 deletions app/components/AdSenseLoader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ watch(
status.value = 'loading'
if (!ready) return

const adWindow = window as unknown as { adsbygoogle?: unknown[] & { requestNonPersonalizedAds?: number } }
const queue = (adWindow.adsbygoogle ??= [])
queue.requestNonPersonalizedAds = 1

// AdSense validates its head tag. Unhead adds data-hid and event marker attributes,
// so insert a plain script only after hydration and attach listeners through the DOM.
const element = document.createElement('script')
Expand Down
6 changes: 1 addition & 5 deletions app/components/Article/Feedback.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,14 @@ const votedCookie = useCookie<boolean | string>(`feedback_voted_${props.articleI
sameSite: 'lax',
})

const sessionCookie = useCookie('client_session_id', { maxAge: 60 * 60 * 24 * 365 })
if (!sessionCookie.value)
sessionCookie.value = crypto.randomUUID?.() ?? Math.random().toString(36).substring(2) + Date.now().toString(36)

const hasVoted = computed(() => !!votedCookie.value)

const submitVote = async (isHelpful: boolean) => {
isLoading.value = true
try {
await $fetch(`/api/articles/${props.articleId}/feedback`, {
method: 'POST',
body: { isHelpful, reason: isHelpful ? null : reasonText.value, sessionId: sessionCookie.value },
body: { isHelpful, reason: isHelpful ? null : reasonText.value },
})
votedCookie.value = isHelpful ? 'helpful' : 'unhelpful'
if (isHelpful) toast.add({ color: 'success', title: $t('feedback.thanks_short') })
Expand Down
209 changes: 76 additions & 133 deletions app/components/ConsentManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,161 +10,104 @@
<div class="flex items-start gap-3">
<UIcon name="mdi:cookie-outline" size="24" class="mt-0.5 shrink-0 text-primary" />
<div class="min-w-0 flex-1">
<h2 id="consent-title" class="font-bold text-highlighted">{{ $t('common.consent.title') }}</h2>
<p id="consent-description" class="mt-1 text-sm leading-6 text-muted">{{ $t('common.consent.banner') }}</p>
<h2 id="consent-title" class="font-semibold text-highlighted">{{ $t('common.consent.title') }}</h2>
<p id="consent-description" class="mt-1 text-sm leading-6 text-muted">{{ bannerText }}</p>
</div>
</div>
<div class="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton color="neutral" variant="ghost" @click="rejectAll">
<div class="mt-4 grid gap-2 sm:grid-cols-3">
<UButton
color="neutral"
variant="soft"
block
size="lg"
class="h-auto min-h-11 whitespace-normal text-center"
:ui="{ label: 'whitespace-normal' }"
@click="rejectAll"
>
{{ $t('common.consent.reject') }}
</UButton>
<UButton color="neutral" variant="soft" @click="settingsOpen = true">
<UButton
color="neutral"
variant="soft"
block
size="lg"
class="h-auto min-h-11 whitespace-normal text-center"
:ui="{ label: 'whitespace-normal' }"
@click="acceptAll"
>
{{ $t('common.consent.accept') }}
</UButton>
<UButton
color="neutral"
variant="ghost"
block
size="lg"
class="h-auto min-h-11 whitespace-normal text-center"
:ui="{ label: 'whitespace-normal' }"
@click="settingsOpen = true"
>
{{ $t('common.consent.customize') }}
</UButton>
<UButton @click="acceptAll">{{ $t('common.consent.accept') }}</UButton>
</div>
</div>

<UModal v-model:open="settingsOpen" :title="$t('common.consent.settingsTitle')" :ui="{ content: 'max-w-xl' }">
<template #body>
<div class="space-y-3">
<div class="flex items-start justify-between gap-4 rounded-[var(--ui-radius)] border border-default p-4">
<div>
<h3 class="font-semibold text-highlighted">{{ $t('common.consent.technicalTitle') }}</h3>
<p class="mt-1 text-sm leading-5 text-muted">{{ $t('common.consent.technicalDescription') }}</p>
</div>
<UBadge color="neutral" variant="soft">{{ $t('common.consent.alwaysOn') }}</UBadge>
</div>

<div
v-if="capabilities.analytics"
class="flex items-start justify-between gap-4 rounded-[var(--ui-radius)] border border-default p-4"
>
<label for="consent-analytics" class="min-w-0 cursor-pointer">
<span class="font-semibold text-highlighted">{{ $t('common.consent.analyticsTitle') }}</span>
<span class="mt-1 block text-sm leading-5 text-muted">{{ $t('common.consent.analyticsDescription') }}</span>
</label>
<USwitch id="consent-analytics" v-model="analyticsDraft" class="shrink-0" />
</div>

<div
v-if="capabilities.marketing"
class="flex items-start justify-between gap-4 rounded-[var(--ui-radius)] border border-default p-4"
>
<label for="consent-marketing" class="min-w-0 cursor-pointer">
<span class="font-semibold text-highlighted">{{ $t('common.consent.marketingTitle') }}</span>
<span class="mt-1 block text-sm leading-5 text-muted">{{ $t('common.consent.marketingDescription') }}</span>
</label>
<USwitch id="consent-marketing" v-model="marketingDraft" class="shrink-0" />
</div>
</div>
</template>

<template #footer>
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-between">
<UButton color="neutral" variant="ghost" @click="rejectAll">
{{ $t('common.consent.reject') }}
</UButton>
<div class="flex flex-col-reverse gap-2 sm:flex-row">
<UButton color="neutral" variant="soft" @click="saveDraft">
{{ $t('common.consent.save') }}
</UButton>
<UButton @click="acceptAll">{{ $t('common.consent.accept') }}</UButton>
</div>
</div>
</template>
</UModal>
<ConsentSettings
v-if="site"
v-model:open="settingsOpen"
:site="site"
:capabilities="capabilities"
:decision="decision"
@save="applyChoice"
@accept="acceptAll"
@reject="rejectAll"
/>
</template>

<script setup lang="ts">
import type { ConsentCapabilities } from '~~/shared/utils/consent'
import type { PublicClientSite } from '~~/shared/utils/clientSiteFields'

const props = defineProps<{
site: PublicClientSite | null | undefined
enabled: boolean
}>()
const props = defineProps<{ site: PublicClientSite | null | undefined; enabled: boolean }>()
const { t } = useI18n()
const { decision, settingsOpen, capabilities, needsDecision, analyticsGranted, marketingGranted, save } = useConsent(
() => props.site,
)
const { gtag, initialize, enableAnalytics, disableAnalytics } = useGtag()
const analyticsDraft = shallowRef(false)
const marketingDraft = shallowRef(false)
const currentCapabilities = useState<ConsentCapabilities | null>('consent-capabilities', () => null)
watch(capabilities, (value) => (currentCapabilities.value = value), { immediate: true, flush: 'sync' })
useGoogleAnalyticsConsent(
() => props.site,
() => props.enabled,
)

const showBanner = computed(() => props.enabled && needsDecision.value && !settingsOpen.value)
const analyticsActive = computed(() => props.enabled && analyticsGranted.value)
watch(
settingsOpen,
(open) => {
if (!open) return
analyticsDraft.value = decision.value?.analytics ?? false
marketingDraft.value = decision.value?.marketing ?? false
},
{ immediate: true },
const bannerText = computed(() =>
[
t('common.consent.banner'),
...(capabilities.value.analytics ? [t('common.consent.bannerAnalytics')] : []),
...(capabilities.value.marketing ? [t('common.consent.bannerMarketing')] : []),
].join(' '),
)

const applyChoice = async (choices: { analytics: boolean; marketing: boolean }) => {
const reloadWithoutMarketing = decision.value?.marketing === true && !choices.marketing
let cleanup: Promise<unknown> | undefined
const clearAnalytics = () => {
cleanup ??= $fetch('/api/consent/analytics', { method: 'DELETE', timeout: 3000 })
.catch(() => {})
.finally(() => {
cleanup = undefined
})
return cleanup
}
watch(analyticsGranted, (granted, previous) => {
if (previous && !granted) void clearAnalytics()
})
watch(marketingGranted, async (granted, previous) => {
if (!previous || granted || !props.enabled) return
if (!analyticsGranted.value) await clearAnalytics()
window.location.reload()
})
const applyChoice = (choices: { analytics: boolean; marketing: boolean }) => {
save(choices)
if (reloadWithoutMarketing) {
await nextTick()
window.location.reload()
}
if (!choices.analytics) void clearAnalytics()
}

const saveDraft = () =>
applyChoice({
analytics: analyticsDraft.value,
marketing: marketingDraft.value,
})
const acceptAll = () => applyChoice({ analytics: true, marketing: true })
const rejectAll = () => applyChoice({ analytics: false, marketing: false })

const removeAnalyticsCookies = () => {
const names = document.cookie
.split(';')
.map((part) => part.split('=')[0]?.trim())
.filter((name): name is string => Boolean(name && (/^_ga/.test(name) || /^_gid$/.test(name))))
const hostnameParts = window.location.hostname.split('.')
const domains = hostnameParts.map((_, index) => `.${hostnameParts.slice(index).join('.')}`)

for (const name of names) {
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
for (const domain of domains) {
document.cookie = `${name}=; Max-Age=0; Path=/; Domain=${domain}; SameSite=Lax`
}
}
}

if (import.meta.client) {
watch(
[analyticsActive, analyticsGranted, marketingGranted],
([active, consented, marketing]) => {
const id = capabilities.value.googleAnalytics ? props.site?.gtagId?.trim() : null
if (!id) return
const adConsent = marketing ? 'granted' : 'denied'

if (active) {
initialize(id)
enableAnalytics(id)
gtag('consent', 'update', {
analytics_storage: 'granted',
ad_storage: adConsent,
ad_user_data: adConsent,
ad_personalization: adConsent,
})
return
}

gtag('consent', 'update', {
analytics_storage: 'denied',
ad_storage: adConsent,
ad_user_data: adConsent,
ad_personalization: adConsent,
})
disableAnalytics(id)
if (!consented) removeAnalyticsCookies()
},
{ immediate: true },
)
}
</script>
Loading
Loading