diff --git a/.forgejo/workflows/staging.yml b/.forgejo/workflows/staging.yml new file mode 100644 index 0000000..af09bee --- /dev/null +++ b/.forgejo/workflows/staging.yml @@ -0,0 +1,35 @@ +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/ \ No newline at end of file diff --git a/apps/vdn-static/src/composables/useI18nMeta.ts b/apps/vdn-static/src/composables/useI18nMeta.ts index 00f7d1d..0b09de7 100644 --- a/apps/vdn-static/src/composables/useI18nMeta.ts +++ b/apps/vdn-static/src/composables/useI18nMeta.ts @@ -1,8 +1,11 @@ import { computed, type ComputedRef, type Ref, unref, watch } from 'vue' import { useRoute } from 'vue-router' -import { useMeta, type MetaConfig, type LocaleAwareMeta, type OpenGraphConfig, type TwitterCardConfig } from './useMeta' +import { useMeta, type MetaConfig, type LocaleAwareMeta } from './useMeta' import { useLocale, type LocaleId } from '../i18n' -import type { RouteMetaValue } from '../types/route-meta.d' +import type { RouteMeta, RouteMetaValue } from '../types/route-meta' + +// Re-export MetaConfig for consumers +export type { MetaConfig } /** * Composable for i18n-aware meta tag management @@ -49,7 +52,10 @@ export function useI18nMeta( routeValue: typeof route ): T | undefined => { if (value === undefined) return undefined - return typeof value === 'function' ? (value as Function)(routeValue) as T : value + if (typeof value === 'function') { + return (value as (route: typeof routeValue) => T)(routeValue) + } + return value } /** @@ -66,8 +72,8 @@ export function useI18nMeta( keywords: resolveMetaValue(configValue.keywords, routeValue), author: resolveMetaValue(configValue.author, routeValue), canonical: resolveMetaValue(configValue.canonical, routeValue), - og: resolveI18nOgTwitter(resolveMetaValue(configValue.og, routeValue), currentLocale), - twitter: resolveI18nOgTwitter(resolveMetaValue(configValue.twitter, routeValue), currentLocale), + og: resolveMetaValue(configValue.og, routeValue), + twitter: resolveMetaValue(configValue.twitter, routeValue), custom: resolveI18nCustomTags(resolveMetaValue(configValue.custom, routeValue), currentLocale), } }) @@ -135,28 +141,6 @@ 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 */ @@ -181,25 +165,11 @@ function resolveI18nCustomTags( return customTags.map(tag => ({ ...tag, content: typeof tag.content === 'function' - ? '' - : typeof tag.content === 'object' - ? resolveI18nContent(tag.content as any, currentLocale) || '' - : tag.content + ? tag.content({} as any) // Route not available in this context, use placeholder + : tag.content })) } -/** OpenGraph config with locale-aware title/description */ -type I18nOpenGraphConfig = Omit & { - title?: string | LocaleAwareMeta - description?: string | LocaleAwareMeta -} - -/** Twitter Card config with locale-aware title/description */ -type I18nTwitterCardConfig = Omit & { - title?: string | LocaleAwareMeta - description?: string | LocaleAwareMeta -} - /** * i18n-aware meta configuration interface */ @@ -214,18 +184,18 @@ export interface I18nMetaConfig { author?: string | ((route: any) => string) /** Canonical URL */ canonical?: string | ((route: any) => string) - /** OpenGraph configuration (locale-aware title/description) */ - og?: I18nOpenGraphConfig | ((route: any) => I18nOpenGraphConfig) - /** Twitter Card configuration (locale-aware title/description) */ - twitter?: I18nTwitterCardConfig | ((route: any) => I18nTwitterCardConfig) + /** OpenGraph configuration */ + og?: MetaConfig['og'] + /** Twitter Card configuration */ + twitter?: MetaConfig['twitter'] /** 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 } /** diff --git a/apps/vdn-static/src/composables/useMeta.ts b/apps/vdn-static/src/composables/useMeta.ts index 89cfff9..2d6c4e9 100644 --- a/apps/vdn-static/src/composables/useMeta.ts +++ b/apps/vdn-static/src/composables/useMeta.ts @@ -33,8 +33,6 @@ 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) } /** @@ -237,7 +235,10 @@ export function useMeta( routeValue: RouteLocationNormalizedLoaded ): T | undefined => { if (value === undefined) return undefined - return typeof value === 'function' ? (value as Function)(routeValue) as T : value + if (typeof value === 'function') { + return (value as (route: RouteLocationNormalizedLoaded) => T)(routeValue) + } + return value } /** diff --git a/apps/vdn-static/src/composables/useRouteMeta.ts b/apps/vdn-static/src/composables/useRouteMeta.ts index d530137..6639d6a 100644 --- a/apps/vdn-static/src/composables/useRouteMeta.ts +++ b/apps/vdn-static/src/composables/useRouteMeta.ts @@ -1,8 +1,7 @@ import { watch, onMounted, onUnmounted, type Ref, computed, unref } from 'vue' import { useRoute } from 'vue-router' import { useMeta } from './useMeta' -import type { RouteMeta } from 'vue-router' -import type { RouteMetaValue } from '../types/route-meta' +import type { RouteMeta, RouteMetaValue, isMetaFunction } from '../types/route-meta' /** * Composable for automatic meta tag management based on route configuration @@ -38,15 +37,18 @@ import type { RouteMetaValue } from '../types/route-meta' export function useRouteMeta() { const route = useRoute() - /** - * Resolves a meta value that can be static or a function +/** + * Resolves a meta value that can be static, reactive, or a function */ const resolveMetaValue = ( value: RouteMetaValue | undefined, routeValue: typeof route ): T | undefined => { if (value === undefined) return undefined - return typeof value === 'function' ? (value as Function)(routeValue) as T : value + if (typeof value === 'function') { + return (value as (route: typeof routeValue) => T)(routeValue) + } + return value } /** diff --git a/apps/vdn-static/src/config/meta-examples.ts b/apps/vdn-static/src/config/meta-examples.ts index 9ccd319..ee2aacd 100644 --- a/apps/vdn-static/src/config/meta-examples.ts +++ b/apps/vdn-static/src/config/meta-examples.ts @@ -3,10 +3,14 @@ * * 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 { MetaConfig } from '../composables/useMeta' import type { I18nMetaConfig } from '../composables/useI18nMeta' +import type { MetaConfig } from '../composables/useMeta' import { createRouteMeta } from '../composables/useMeta' /** @@ -29,29 +33,11 @@ export const homePageMeta: I18nMetaConfig = { author: 'Viossa Team', og: { type: 'website', - 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.', - }, + title: 'Viossa - Learn a Language Through Immersion', + description: 'Learn languages naturally through immersion and interaction with native speakers.', 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', @@ -76,23 +62,10 @@ export const resourcesPageMeta: I18nMetaConfig = { keywords: ['resources', 'tutorials', 'guides', 'language learning materials', 'viossa resources'], og: { type: 'website', - title: { - 'en-US': 'Learning Resources - Viossa', - }, - description: { - 'en-US': 'Browse our collection of learning resources and tutorials.', - }, + title: 'Learning Resources - Viossa', + description: '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', - }, - }, } /** @@ -120,24 +93,11 @@ export const resourceDetailMeta = (route: any): I18nMetaConfig => { canonical: `https://viossa.net/resources/${resourceId}`, og: { type: 'article', - title: { - 'en-US': resourceTitle, - }, - description: { - 'en-US': resourceDescription, - }, + title: resourceTitle, + description: 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: [ { @@ -169,20 +129,10 @@ export const kotobaPageMeta: I18nMetaConfig = { keywords: ['kotoba', 'vocabulary', 'grammar', 'spaced repetition', 'interactive learning', 'language practice'], og: { type: 'website', - title: { - 'en-US': 'Kotoba - Interactive Language Learning', - }, - description: { - 'en-US': 'Practice vocabulary with spaced repetition', - }, + title: 'Kotoba - Interactive Language Learning', + description: 'Practice vocabulary with spaced repetition', image: '/images/og-kotoba.jpg', }, - twitter: { - card: 'summary', - title: { - 'en-US': 'Kotoba - Interactive Language Learning', - }, - }, } /** @@ -203,12 +153,8 @@ export const discordRulesPageMeta: I18nMetaConfig = { robots: 'index, follow', // This page should be indexed og: { type: 'website', - title: { - 'en-US': 'Discord Community Rules', - }, - description: { - 'en-US': 'Community guidelines for Viossa Discord server', - }, + title: 'Discord Community Rules', + description: 'Community guidelines for Viossa Discord server', image: '/images/og-discord-rules.jpg', }, } @@ -242,12 +188,6 @@ 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', @@ -326,9 +266,6 @@ export const exampleRouteWithMeta = { og: { type: 'article', image: '/default-og-image.jpg' - }, - twitter: { - card: 'summary_large_image' } }) } \ No newline at end of file diff --git a/apps/vdn-static/src/typed-router.d.ts b/apps/vdn-static/src/typed-router.d.ts index 8b91bc5..b1dd6e5 100644 --- a/apps/vdn-static/src/typed-router.d.ts +++ b/apps/vdn-static/src/typed-router.d.ts @@ -30,6 +30,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/admin/': RouteRecordInfo< + '/admin/', + '/admin', + Record, + Record, + | never + >, '/discord/rules': RouteRecordInfo< '/discord/rules', '/discord/rules', @@ -44,6 +51,20 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/login': RouteRecordInfo< + '/login', + '/login', + Record, + Record, + | never + >, + '/preview': RouteRecordInfo< + '/preview', + '/preview', + Record, + Record, + | never + >, '/resources': RouteRecordInfo< '/resources', '/resources', @@ -70,6 +91,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'pages/admin/index.vue': { + routes: + | '/admin/' + views: + | never + } 'pages/discord/rules.vue': { routes: | '/discord/rules' @@ -82,6 +109,18 @@ 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' diff --git a/apps/vdn-static/src/types/route-meta.d.ts b/apps/vdn-static/src/types/route-meta.ts similarity index 81% rename from apps/vdn-static/src/types/route-meta.d.ts rename to apps/vdn-static/src/types/route-meta.ts index 06e20c5..782bdf6 100644 --- a/apps/vdn-static/src/types/route-meta.d.ts +++ b/apps/vdn-static/src/types/route-meta.ts @@ -1,6 +1,9 @@ -import type { RouteLocationNormalizedLoaded } from 'vue-router' +import type { RouteLocationNormalizedLoaded, RouteMeta } 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 * @@ -116,4 +119,29 @@ export type RouteMetaValue = T | ((route: RouteLocationNormalizedLoaded) => T /** * Helper function type for dynamic meta values */ -export type MetaValueFunction = (route: RouteLocationNormalizedLoaded) => T \ No newline at end of file +export type MetaValueFunction = (route: RouteLocationNormalizedLoaded) => T + +/** + * Type guard for checking if a meta value is a function + */ +export function isMetaFunction( + value: RouteMetaValue +): value is MetaValueFunction { + 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 +} \ No newline at end of file diff --git a/apps/vdn-static/src/utils/drupal-meta.ts b/apps/vdn-static/src/utils/drupal-meta.ts index 1475417..898a745 100644 --- a/apps/vdn-static/src/utils/drupal-meta.ts +++ b/apps/vdn-static/src/utils/drupal-meta.ts @@ -7,7 +7,7 @@ import { computed } from 'vue' import { useRoute } from 'vue-router' -import { useMeta, type MetaConfig } from '../composables/useMeta' +import { useMeta, type MetaConfig, type CustomMetaTag } from '../composables/useMeta' import type { ArticleNode, PageNode, @@ -29,6 +29,34 @@ 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 || ''), @@ -41,40 +69,7 @@ export function generateDrupalNodeMeta( url: `${baseUrl}${path}`, image: '/images/og-default.jpg', // Could be extracted from relationships }, - 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', - }) - } + custom: customTags, } return meta diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64b1919..27efe25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1670,18 +1670,16 @@ 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: 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 + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -2610,12 +2608,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 exorbitant 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 exhorbitant 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 exorbitant 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 exhorbitant rates) by contacting i@izs.me tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} @@ -2824,7 +2822,6 @@ 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: