feat: prerender public site pages
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && vite build --ssr src/entry-public-ssr.js --outDir dist-ssr && node scripts/prerender-public.mjs && node scripts/write-public-seo.mjs",
|
||||
"preview": "vite preview",
|
||||
"api:schema": "node scripts/fetch-openapi.mjs",
|
||||
"api:types": "openapi-typescript ../shared/openapi/openapi.json -o src/api/schema.d.ts",
|
||||
|
||||
34
frontend/scripts/prerender-public.mjs
Normal file
34
frontend/scripts/prerender-public.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const publicRoutes = ['/', '/product', '/pricing', '/blogs', '/guides'];
|
||||
|
||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const distDir = resolve(rootDir, 'dist');
|
||||
const ssrEntry = resolve(rootDir, 'dist-ssr/entry-public-ssr.js');
|
||||
const templatePath = resolve(distDir, 'index.html');
|
||||
|
||||
const template = await readFile(templatePath, 'utf8');
|
||||
const { render } = await import(pathToFileURL(ssrEntry));
|
||||
const baseTemplate = template.replace('<title>Socialize</title>', '');
|
||||
|
||||
function outputPathForRoute(route) {
|
||||
if (route === '/') {
|
||||
return resolve(distDir, 'index.html');
|
||||
}
|
||||
|
||||
return resolve(distDir, route.replace(/^\//, ''), 'index.html');
|
||||
}
|
||||
|
||||
for (const route of publicRoutes) {
|
||||
const { appHtml, headTags } = await render(route);
|
||||
const html = baseTemplate
|
||||
.replace('</head>', `${headTags.headTags}\n</head>`)
|
||||
.replace('<div id="app"></div>', `<div id="app">${appHtml}</div>`);
|
||||
|
||||
const outputPath = outputPathForRoute(route);
|
||||
await mkdir(dirname(outputPath), { recursive: true });
|
||||
await writeFile(outputPath, html);
|
||||
console.log(`Prerendered ${route}`);
|
||||
}
|
||||
61
frontend/scripts/write-public-seo.mjs
Normal file
61
frontend/scripts/write-public-seo.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const publicRoutes = [
|
||||
{ path: '/', changefreq: 'weekly', priority: '1.0' },
|
||||
{ path: '/product', changefreq: 'weekly', priority: '0.8' },
|
||||
{ path: '/pricing', changefreq: 'monthly', priority: '0.7' },
|
||||
{ path: '/blogs', changefreq: 'weekly', priority: '0.6' },
|
||||
{ path: '/guides', changefreq: 'weekly', priority: '0.6' },
|
||||
];
|
||||
|
||||
const disallowedRoutes = [
|
||||
'/app/',
|
||||
'/login',
|
||||
'/register',
|
||||
'/forgot-password',
|
||||
'/reset-password',
|
||||
'/verify-email',
|
||||
];
|
||||
|
||||
const siteUrl = (process.env.VITE_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? 'http://localhost:5173')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const distDir = resolve(process.cwd(), 'dist');
|
||||
|
||||
function absoluteUrl(path) {
|
||||
return new URL(path, `${siteUrl}/`).toString();
|
||||
}
|
||||
|
||||
const robots = [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
...disallowedRoutes.map(route => `Disallow: ${route}`),
|
||||
'',
|
||||
`Sitemap: ${absoluteUrl('/sitemap.xml')}`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const sitemapEntries = publicRoutes
|
||||
.map(route => [
|
||||
' <url>',
|
||||
` <loc>${absoluteUrl(route.path)}</loc>`,
|
||||
` <changefreq>${route.changefreq}</changefreq>`,
|
||||
` <priority>${route.priority}</priority>`,
|
||||
' </url>',
|
||||
].join('\n'))
|
||||
.join('\n');
|
||||
|
||||
const sitemap = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
sitemapEntries,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await mkdir(distDir, { recursive: true });
|
||||
await writeFile(resolve(distDir, 'robots.txt'), robots);
|
||||
await writeFile(resolve(distDir, 'sitemap.xml'), sitemap);
|
||||
|
||||
console.log(`Wrote public SEO files for ${siteUrl}`);
|
||||
59
frontend/src/entry-public-ssr.js
Normal file
59
frontend/src/entry-public-ssr.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createSSRApp, h } from 'vue';
|
||||
import { createMemoryHistory, createRouter, RouterView } from 'vue-router';
|
||||
import { createHead, renderHeadToString } from '@vueuse/head';
|
||||
import { renderToString } from '@vue/server-renderer';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import en from '@/locales/en.json';
|
||||
import fr from '@/locales/fr.json';
|
||||
import Landing from '@/features/landing/views/Landing.vue';
|
||||
import ProductPage from '@/features/landing/views/ProductPage.vue';
|
||||
import PricingPage from '@/features/landing/views/PricingPage.vue';
|
||||
import BlogsPage from '@/features/landing/views/BlogsPage.vue';
|
||||
import GuidesPage from '@/features/landing/views/GuidesPage.vue';
|
||||
import './assets/main.css';
|
||||
|
||||
const publicRoutes = [
|
||||
{ path: '/', component: Landing },
|
||||
{ path: '/product', component: ProductPage },
|
||||
{ path: '/pricing', component: PricingPage },
|
||||
{ path: '/blogs', component: BlogsPage },
|
||||
{ path: '/guides', component: GuidesPage },
|
||||
{ path: '/login', component: { render: () => null } },
|
||||
{ path: '/register', component: { render: () => null } },
|
||||
];
|
||||
|
||||
export async function render(routePath) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: publicRoutes,
|
||||
});
|
||||
|
||||
const head = createHead();
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
fr,
|
||||
},
|
||||
});
|
||||
|
||||
const app = createSSRApp({
|
||||
render: () => h(RouterView),
|
||||
});
|
||||
|
||||
app.use(router);
|
||||
app.use(head);
|
||||
app.use(i18n);
|
||||
|
||||
await router.push(routePath);
|
||||
await router.isReady();
|
||||
|
||||
const appHtml = await renderToString(app);
|
||||
const headTags = await renderHeadToString(head);
|
||||
|
||||
return {
|
||||
appHtml,
|
||||
headTags,
|
||||
};
|
||||
}
|
||||
51
frontend/src/features/landing/publicPageMeta.js
Normal file
51
frontend/src/features/landing/publicPageMeta.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useHead } from '@vueuse/head';
|
||||
|
||||
function getCanonicalUrl(path) {
|
||||
const configuredSiteUrl = import.meta.env.VITE_PUBLIC_SITE_URL
|
||||
?? (typeof process !== 'undefined' ? process.env.VITE_PUBLIC_SITE_URL : undefined)
|
||||
?? (typeof process !== 'undefined' ? process.env.SITE_URL : undefined);
|
||||
|
||||
if (configuredSiteUrl) {
|
||||
return new URL(path, `${configuredSiteUrl.replace(/\/$/, '')}/`).toString();
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return path;
|
||||
}
|
||||
|
||||
return new URL(path, window.location.origin).toString();
|
||||
}
|
||||
|
||||
export function usePublicPageMeta({ title, description, path }) {
|
||||
useHead({
|
||||
title,
|
||||
meta: [
|
||||
{
|
||||
name: 'description',
|
||||
content: description,
|
||||
},
|
||||
{
|
||||
name: 'robots',
|
||||
content: 'index,follow',
|
||||
},
|
||||
{
|
||||
property: 'og:title',
|
||||
content: title,
|
||||
},
|
||||
{
|
||||
property: 'og:description',
|
||||
content: description,
|
||||
},
|
||||
{
|
||||
property: 'og:type',
|
||||
content: 'website',
|
||||
},
|
||||
],
|
||||
link: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: getCanonicalUrl(path),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup>
|
||||
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
|
||||
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
|
||||
|
||||
usePublicPageMeta({
|
||||
title: 'Blogs | Socialize',
|
||||
description: 'Practical articles on content review workflows, client approval, revision tracking, and publication handoff.',
|
||||
path: '/blogs',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup>
|
||||
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
|
||||
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
|
||||
|
||||
usePublicPageMeta({
|
||||
title: 'Guides | Socialize',
|
||||
description: 'Reusable guides for content intake, review rounds, approval decisions, and delivery readiness.',
|
||||
path: '/guides',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
|
||||
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
|
||||
|
||||
usePublicPageMeta({
|
||||
title: 'Socialize | Social media approval workflow',
|
||||
description: 'Socialize helps teams manage social media content review, revisions, approval decisions, and publication handoff in one workflow.',
|
||||
path: '/',
|
||||
});
|
||||
|
||||
const pillars = computed(() => [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup>
|
||||
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
|
||||
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
|
||||
|
||||
usePublicPageMeta({
|
||||
title: 'Pricing | Socialize',
|
||||
description: 'Socialize workspace pricing for teams managing social media content approvals with clients.',
|
||||
path: '/pricing',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script setup>
|
||||
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
|
||||
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
|
||||
|
||||
usePublicPageMeta({
|
||||
title: 'Product | Socialize',
|
||||
description: 'Socialize keeps content items, assets, revisions, comments, approval decisions, and publishing handoff details in one workspace.',
|
||||
path: '/product',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useNotificationsStore } from '@/features/notifications/stores/notificat
|
||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||
import { i18n } from '@/plugins/i18n.js';
|
||||
import config from '@/config.js';
|
||||
import { createHead } from '@vueuse/head';
|
||||
|
||||
const vuetify = createVuetify({
|
||||
components: {
|
||||
@@ -78,9 +79,11 @@ const vuetify = createVuetify({
|
||||
});
|
||||
|
||||
const pinia = createPinia();
|
||||
const head = createHead();
|
||||
|
||||
const app = createApp(App)
|
||||
.use(pinia)
|
||||
.use(head)
|
||||
.use(vuetify)
|
||||
.use(router)
|
||||
.use(i18n)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { resolve } from 'path'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ isSsrBuild }) => ({
|
||||
plugins: [
|
||||
visualizer({
|
||||
filename: './dist/stats.html',
|
||||
@@ -37,7 +37,7 @@ export default defineConfig({
|
||||
build: {
|
||||
sourcemap: true, // Enable source maps for debugging
|
||||
rollupOptions: {
|
||||
output: {
|
||||
output: isSsrBuild ? undefined : {
|
||||
manualChunks: {
|
||||
vue: ['vue'],
|
||||
vuetify: ['vuetify'],
|
||||
@@ -59,4 +59,4 @@ export default defineConfig({
|
||||
json: {
|
||||
stringify: false
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user