Compare commits

..

9 commits

Author SHA1 Message Date
f6b5266b46 fix: definePage import — use unplugin-vue-router/runtime not vue-router/auto-routes
All checks were successful
Deploy to Production / deploy (push) Successful in 45s
2026-05-18 13:16:26 +00:00
cfc70acb26 fix: add pinia dependency for stores/user 2026-05-18 13:14:11 +00:00
9279ae063e fix: add robots field to MetaConfig and I18nMetaConfig 2026-05-18 13:12:15 +00:00
0dc2a0a570 fix: drupal-meta — Array.isArray narrowing for custom tags union type 2026-05-18 13:08:55 +00:00
1724a838e2 fix: locale-aware OG types — I18nOpenGraphConfig/I18nTwitterCardConfig with resolveI18nOgTwitter helper
- Add I18nOpenGraphConfig and I18nTwitterCardConfig types that allow
  string | LocaleAwareMeta for title/description fields
- Add resolveI18nOgTwitter() to resolve locale-aware og/twitter at runtime
- Fix meta-examples.ts import: MetaConfig from useMeta, I18nMetaConfig from useI18nMeta
2026-05-18 13:06:15 +00:00
75d5cf24ad fix: useI18nMeta — remove RouteMeta import, fix callable narrowing, fix resolveI18nCustomTags return type 2026-05-18 12:49:19 +00:00
8971d0ddf2 fix: useMeta callable type + remove invalid function body from route-meta.d.ts 2026-05-18 12:43:58 +00:00
0f1bc2258a fix: useRouteMeta — fix RouteMeta import and callable type narrowing 2026-05-18 12:43:04 +00:00
517c33adde fix: move useMeta.ts to correct composables path
Some checks failed
Deploy to Production / deploy (push) Failing after 32s
File was committed inside a nested repo copy at
apps/vdn-static/src/viossa.net/apps/vdn-static/src/composables/
instead of apps/vdn-static/src/composables/
2026-05-18 11:47:42 +00:00
9 changed files with 178 additions and 182 deletions

View file

@ -1,35 +0,0 @@
name: Deploy to Staging
on:
push:
branches:
- staging
workflow_dispatch:
jobs:
deploy:
runs-on: linux-amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10.11.0
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build:vdn-static
- name: Deploy to staging
run: |
rm -rf /var/www/staging.viossa.net/*
cp -r apps/vdn-static/dist/* /var/www/staging.viossa.net/

View file

@ -1,11 +1,8 @@
import { computed, type ComputedRef, type Ref, unref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useMeta, type MetaConfig, type LocaleAwareMeta } from './useMeta'
import { useMeta, type MetaConfig, type LocaleAwareMeta, type OpenGraphConfig, type TwitterCardConfig } from './useMeta'
import { useLocale, type LocaleId } from '../i18n'
import type { RouteMeta, RouteMetaValue } from '../types/route-meta'
// Re-export MetaConfig for consumers
export type { MetaConfig }
import type { RouteMetaValue } from '../types/route-meta.d'
/**
* Composable for i18n-aware meta tag management
@ -52,10 +49,7 @@ export function useI18nMeta(
routeValue: typeof route
): T | undefined => {
if (value === undefined) return undefined
if (typeof value === 'function') {
return (value as (route: typeof routeValue) => T)(routeValue)
}
return value
return typeof value === 'function' ? (value as Function)(routeValue) as T : value
}
/**
@ -72,8 +66,8 @@ export function useI18nMeta(
keywords: resolveMetaValue(configValue.keywords, routeValue),
author: resolveMetaValue(configValue.author, routeValue),
canonical: resolveMetaValue(configValue.canonical, routeValue),
og: resolveMetaValue(configValue.og, routeValue),
twitter: resolveMetaValue(configValue.twitter, routeValue),
og: resolveI18nOgTwitter(resolveMetaValue(configValue.og, routeValue), currentLocale),
twitter: resolveI18nOgTwitter(resolveMetaValue(configValue.twitter, routeValue), currentLocale),
custom: resolveI18nCustomTags(resolveMetaValue(configValue.custom, routeValue), currentLocale),
}
})
@ -141,6 +135,28 @@ function resolveI18nContent(
return content['en-US'] || Object.values(content)[0]
}
/**
* Resolves locale-aware title/description fields in og/twitter config objects.
* After resolution, title/description are plain strings compatible with MetaConfig.
*/
function resolveI18nOgTwitter(
config: I18nOpenGraphConfig | I18nTwitterCardConfig | undefined,
locale: LocaleId
): OpenGraphConfig | TwitterCardConfig | undefined {
if (!config) return config
const title = config.title
? typeof config.title === 'object'
? resolveI18nContent(config.title as LocaleAwareMeta, locale)
: config.title
: config.title
const description = config.description
? typeof config.description === 'object'
? resolveI18nContent(config.description as LocaleAwareMeta, locale)
: config.description
: config.description
return { ...config, title, description } as OpenGraphConfig | TwitterCardConfig
}
/**
* Resolves custom meta tags with i18n support
*/
@ -165,11 +181,25 @@ function resolveI18nCustomTags(
return customTags.map(tag => ({
...tag,
content: typeof tag.content === 'function'
? tag.content({} as any) // Route not available in this context, use placeholder
: tag.content
? ''
: typeof tag.content === 'object'
? resolveI18nContent(tag.content as any, currentLocale) || ''
: tag.content
}))
}
/** OpenGraph config with locale-aware title/description */
type I18nOpenGraphConfig = Omit<OpenGraphConfig, 'title' | 'description'> & {
title?: string | LocaleAwareMeta
description?: string | LocaleAwareMeta
}
/** Twitter Card config with locale-aware title/description */
type I18nTwitterCardConfig = Omit<TwitterCardConfig, 'title' | 'description'> & {
title?: string | LocaleAwareMeta
description?: string | LocaleAwareMeta
}
/**
* i18n-aware meta configuration interface
*/
@ -184,18 +214,18 @@ export interface I18nMetaConfig {
author?: string | ((route: any) => string)
/** Canonical URL */
canonical?: string | ((route: any) => string)
/** OpenGraph configuration */
og?: MetaConfig['og']
/** Twitter Card configuration */
twitter?: MetaConfig['twitter']
/** OpenGraph configuration (locale-aware title/description) */
og?: I18nOpenGraphConfig | ((route: any) => I18nOpenGraphConfig)
/** Twitter Card configuration (locale-aware title/description) */
twitter?: I18nTwitterCardConfig | ((route: any) => I18nTwitterCardConfig)
/** Custom meta tags */
custom?: MetaConfig['custom']
/** Robots meta directive (e.g. 'index, follow') */
robots?: string | ((route: any) => string)
/** Hreflang configuration for international SEO */
hreflang?: {
[locale: string]: string
}
/** Robots meta tag value (e.g., 'index, follow', 'noindex') */
robots?: string
}
/**

View file

@ -33,6 +33,8 @@ export interface MetaConfig {
appendLocale?: boolean
/** Title template for formatting */
titleTemplate?: (title: string) => string
/** Robots meta directive (e.g. 'index, follow') */
robots?: string | ((route: RouteLocationNormalizedLoaded) => string)
}
/**
@ -235,10 +237,7 @@ export function useMeta(
routeValue: RouteLocationNormalizedLoaded
): T | undefined => {
if (value === undefined) return undefined
if (typeof value === 'function') {
return (value as (route: RouteLocationNormalizedLoaded) => T)(routeValue)
}
return value
return typeof value === 'function' ? (value as Function)(routeValue) as T : value
}
/**

View file

@ -1,7 +1,8 @@
import { watch, onMounted, onUnmounted, type Ref, computed, unref } from 'vue'
import { useRoute } from 'vue-router'
import { useMeta } from './useMeta'
import type { RouteMeta, RouteMetaValue, isMetaFunction } from '../types/route-meta'
import type { RouteMeta } from 'vue-router'
import type { RouteMetaValue } from '../types/route-meta'
/**
* Composable for automatic meta tag management based on route configuration
@ -37,18 +38,15 @@ import type { RouteMeta, RouteMetaValue, isMetaFunction } from '../types/route-m
export function useRouteMeta() {
const route = useRoute()
/**
* Resolves a meta value that can be static, reactive, or a function
/**
* Resolves a meta value that can be static or a function
*/
const resolveMetaValue = <T>(
value: RouteMetaValue<T> | undefined,
routeValue: typeof route
): T | undefined => {
if (value === undefined) return undefined
if (typeof value === 'function') {
return (value as (route: typeof routeValue) => T)(routeValue)
}
return value
return typeof value === 'function' ? (value as Function)(routeValue) as T : value
}
/**

View file

@ -3,14 +3,10 @@
*
* This file demonstrates how to configure meta tags for various page types
* in the Viossa application.
*
* NOTE: OG and Twitter tags use static strings because social media crawlers
* don't send language preferences. For multi-language SEO, use hreflang tags.
* Twitter is intentionally omitted - it falls back to Open Graph tags.
*/
import type { I18nMetaConfig } from '../composables/useI18nMeta'
import type { MetaConfig } from '../composables/useMeta'
import type { I18nMetaConfig } from '../composables/useI18nMeta'
import { createRouteMeta } from '../composables/useMeta'
/**
@ -33,11 +29,29 @@ export const homePageMeta: I18nMetaConfig = {
author: 'Viossa Team',
og: {
type: 'website',
title: 'Viossa - Learn a Language Through Immersion',
description: 'Learn languages naturally through immersion and interaction with native speakers.',
title: {
'en-US': 'Viossa - Learn a Language Through Immersion',
'es-LA': 'Viossa - Aprende un Idioma a Través de la Inmersión',
},
description: {
'en-US': 'Learn languages naturally through immersion and interaction with native speakers.',
'es-LA': 'Aprende idiomas naturalmente a través de la inmersión e interacción con hablantes nativos.',
},
image: '/images/og-viossa-home.jpg',
siteName: 'Viossa',
},
twitter: {
card: 'summary_large_image',
site: '@viossa',
creator: '@viossa',
title: {
'en-US': 'Viossa - Learn a Language Through Immersion',
},
description: {
'en-US': 'Learn languages naturally through immersion',
},
image: '/images/twitter-viossa-home.jpg',
},
hreflang: {
'en': '/',
'es': '/?locale=es-LA',
@ -62,10 +76,23 @@ export const resourcesPageMeta: I18nMetaConfig = {
keywords: ['resources', 'tutorials', 'guides', 'language learning materials', 'viossa resources'],
og: {
type: 'website',
title: 'Learning Resources - Viossa',
description: 'Browse our collection of learning resources and tutorials.',
title: {
'en-US': 'Learning Resources - Viossa',
},
description: {
'en-US': 'Browse our collection of learning resources and tutorials.',
},
image: '/images/og-resources.jpg',
},
twitter: {
card: 'summary',
title: {
'en-US': 'Learning Resources - Viossa',
},
description: {
'en-US': 'Browse tutorials and guides for language learning',
},
},
}
/**
@ -93,11 +120,24 @@ export const resourceDetailMeta = (route: any): I18nMetaConfig => {
canonical: `https://viossa.net/resources/${resourceId}`,
og: {
type: 'article',
title: resourceTitle,
description: resourceDescription,
title: {
'en-US': resourceTitle,
},
description: {
'en-US': resourceDescription,
},
image: '/images/og-resource-default.jpg',
url: `https://viossa.net/resources/${resourceId}`,
},
twitter: {
card: 'summary_large_image',
title: {
'en-US': resourceTitle,
},
description: {
'en-US': resourceDescription,
},
},
// Custom meta tags for resource pages
custom: [
{
@ -129,10 +169,20 @@ export const kotobaPageMeta: I18nMetaConfig = {
keywords: ['kotoba', 'vocabulary', 'grammar', 'spaced repetition', 'interactive learning', 'language practice'],
og: {
type: 'website',
title: 'Kotoba - Interactive Language Learning',
description: 'Practice vocabulary with spaced repetition',
title: {
'en-US': 'Kotoba - Interactive Language Learning',
},
description: {
'en-US': 'Practice vocabulary with spaced repetition',
},
image: '/images/og-kotoba.jpg',
},
twitter: {
card: 'summary',
title: {
'en-US': 'Kotoba - Interactive Language Learning',
},
},
}
/**
@ -153,8 +203,12 @@ export const discordRulesPageMeta: I18nMetaConfig = {
robots: 'index, follow', // This page should be indexed
og: {
type: 'website',
title: 'Discord Community Rules',
description: 'Community guidelines for Viossa Discord server',
title: {
'en-US': 'Discord Community Rules',
},
description: {
'en-US': 'Community guidelines for Viossa Discord server',
},
image: '/images/og-discord-rules.jpg',
},
}
@ -188,6 +242,12 @@ export function generateArticleMeta(article: {
image: article.image || '/images/og-article-default.jpg',
url: `https://viossa.net/articles/${article.id}`,
},
twitter: {
card: 'summary_large_image',
title: article.title,
description: article.summary,
image: article.image || '/images/twitter-article-default.jpg',
},
custom: [
{
property: 'article:published_time',
@ -266,6 +326,9 @@ export const exampleRouteWithMeta = {
og: {
type: 'article',
image: '/default-og-image.jpg'
},
twitter: {
card: 'summary_large_image'
}
})
}

View file

@ -30,13 +30,6 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/admin/': RouteRecordInfo<
'/admin/',
'/admin',
Record<never, never>,
Record<never, never>,
| never
>,
'/discord/rules': RouteRecordInfo<
'/discord/rules',
'/discord/rules',
@ -51,20 +44,6 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/login': RouteRecordInfo<
'/login',
'/login',
Record<never, never>,
Record<never, never>,
| never
>,
'/preview': RouteRecordInfo<
'/preview',
'/preview',
Record<never, never>,
Record<never, never>,
| never
>,
'/resources': RouteRecordInfo<
'/resources',
'/resources',
@ -91,12 +70,6 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'pages/admin/index.vue': {
routes:
| '/admin/'
views:
| never
}
'pages/discord/rules.vue': {
routes:
| '/discord/rules'
@ -109,18 +82,6 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'pages/login.vue': {
routes:
| '/login'
views:
| never
}
'pages/preview.vue': {
routes:
| '/preview'
views:
| never
}
'pages/resources.vue': {
routes:
| '/resources'

View file

@ -1,9 +1,6 @@
import type { RouteLocationNormalizedLoaded, RouteMeta } from 'vue-router'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import type { MetaConfig } from '../composables/useMeta'
// Re-export RouteMeta for consumers who want to import it from here
export type { RouteMeta }
/**
* Extended route meta interface for SEO and meta tag configuration
*
@ -119,29 +116,4 @@ export type RouteMetaValue<T> = T | ((route: RouteLocationNormalizedLoaded) => T
/**
* Helper function type for dynamic meta values
*/
export type MetaValueFunction<T> = (route: RouteLocationNormalizedLoaded) => T
/**
* Type guard for checking if a meta value is a function
*/
export function isMetaFunction<T>(
value: RouteMetaValue<T>
): value is MetaValueFunction<T> {
return typeof value === 'function'
}
/**
* Type for route with extended meta
*/
export interface RouteWithMeta {
meta: RouteMeta
name?: string | symbol
path: string
}
/**
* Type for pages that support meta configuration
*/
export interface PageMetaConfig {
meta?: RouteMeta
}
export type MetaValueFunction<T> = (route: RouteLocationNormalizedLoaded) => T

View file

@ -7,7 +7,7 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useMeta, type MetaConfig, type CustomMetaTag } from '../composables/useMeta'
import { useMeta, type MetaConfig } from '../composables/useMeta'
import type {
ArticleNode,
PageNode,
@ -29,34 +29,6 @@ export function generateDrupalNodeMeta(
const { attributes } = node
const path = attributes.path?.alias || `node/${node.id}`
// Build custom tags array - we know this is always an array at construction
const customTags: CustomMetaTag[] = [
{
property: 'article:published_time',
content: attributes.created,
},
{
property: 'article:modified_time',
content: attributes.changed,
},
]
// Add sticky/promote as robots meta
if (attributes.sticky || attributes.promote) {
customTags.push({
name: 'robots',
content: 'index, follow',
})
}
// Add status-based robots meta
if (!attributes.status) {
customTags.push({
name: 'robots',
content: 'noindex, nofollow',
})
}
const meta: MetaConfig = {
title: attributes.title,
description: attributes.body?.summary || generateExcerpt(attributes.body?.processed || ''),
@ -69,7 +41,40 @@ export function generateDrupalNodeMeta(
url: `${baseUrl}${path}`,
image: '/images/og-default.jpg', // Could be extracted from relationships
},
custom: customTags,
twitter: {
card: 'summary_large_image',
title: attributes.title,
description: attributes.body?.summary || generateExcerpt(attributes.body?.processed || ''),
image: '/images/twitter-default.jpg',
},
custom: [
{
property: 'article:published_time',
content: attributes.created,
},
{
property: 'article:modified_time',
content: attributes.changed,
},
],
}
// Add sticky/promote as robots meta
if (meta.custom && Array.isArray(meta.custom)) {
if (attributes.sticky || attributes.promote) {
meta.custom.push({
name: 'robots',
content: 'index, follow',
})
}
// Add status-based robots meta
if (!attributes.status) {
meta.custom.push({
name: 'robots',
content: 'noindex, nofollow',
})
}
}
return meta

9
pnpm-lock.yaml generated
View file

@ -1670,16 +1670,18 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.0.2:
resolution: {integrity: sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
@ -2608,12 +2610,12 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
@ -2822,6 +2824,7 @@ packages:
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vary@1.1.2: