254 lines
9.7 KiB
JavaScript
254 lines
9.7 KiB
JavaScript
// Express middleware to inject dynamic SEO meta tags
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import axios from 'axios';
|
|
import { dirname } from 'path';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
const seoMiddleware = async (req, res, next) => {
|
|
// Skip non-HTML requests and static assets (only skip if it has a file extension that's not .html)
|
|
if (req.path.includes('.') && !req.path.endsWith('.html') && !req.path.endsWith('/')) {
|
|
return next();
|
|
}
|
|
|
|
// Skip if it's an API route or static asset
|
|
if (req.path.startsWith('/api/') || req.path.startsWith('/assets/') || req.path.startsWith('/home/assets/')) {
|
|
return next();
|
|
}
|
|
|
|
try {
|
|
console.log('SEO Middleware triggered for:', req.path);
|
|
|
|
const normalizedPath = normalizePath(mapToHomeNamespace(req.path));
|
|
|
|
// Attempt dynamic SEO fetch by path/slug first, fallback to pageId map
|
|
const routeToPageId = {
|
|
'/home/': 1,
|
|
'/home/blog': 5,
|
|
'/home/blog/': 5,
|
|
'/home/pricing-pozoapp': 3,
|
|
'/home/pricing-pozoapp/': 3,
|
|
'/home/contact-us': 4,
|
|
'/home/contact-us/': 4,
|
|
'/home/signin': 2,
|
|
'/home/signin/': 2
|
|
};
|
|
|
|
const pageId = routeToPageId[normalizedPath] || 1;
|
|
|
|
const fullUrl = buildFullUrl(req);
|
|
|
|
const dynamicSeo = await fetchSEOByPathOrSlug(normalizedPath);
|
|
let dbSeoData = dynamicSeo || (await fetchSEOFromDB(pageId));
|
|
|
|
const fallbackData = {
|
|
1: { title: 'Retail ERP & POS for Indian MSMEs | POZO', description: 'Fast billing, smart inventory, GST-ready POS. POZO helps kirana, mini-supermarkets & retail chains speed checkout, connect weighing scales, and manage multi-store ops.', image: 'https://www.pozo.app/og/home.jpg', url: fullUrl },
|
|
2: { title: 'Sign In to PozoApp', description: 'Access your business dashboard', image: 'https://www.pozo.app/og/Signin-og.jpg', url: fullUrl },
|
|
3: { title: 'Pricing - Retail ERP & POS Plans | POZO', description: 'Simple plans for MSMEs. Fast billing, inventory, GST e-invoice, weighing-scale integration, WhatsApp e-bills & multi-store controls. Book a demo.', image: 'https://www.pozo.app/og/pricing-og.jpg', url: fullUrl },
|
|
4: { title: 'Contact PozoApp', description: 'Get support and sales information', image: 'https://www.pozo.app/og/contact-og.jpg', url: fullUrl },
|
|
5: { title: 'POZO Blog — Retail ERP, POS & Grocery Billing Guides', description: 'Practical guides on POS billing, weighing-scale integration, GST e-invoices, multi-store ERP & inventory control for Indian retailers.', image: 'https://www.pozo.app/og/blog-og.jpg', url: fullUrl }
|
|
};
|
|
|
|
const chosenFallback = fallbackData[routeToPageId[normalizedPath]] || fallbackData[1];
|
|
const seoData = dbSeoData ? {
|
|
title: dbSeoData.MetaTitle || dbSeoData.title || chosenFallback.title,
|
|
description: dbSeoData.MetaDesc || dbSeoData.description || chosenFallback.description,
|
|
image: dbSeoData.ImageUrl || dbSeoData.image || chosenFallback.image,
|
|
url: dbSeoData.CanonicalUrl || dbSeoData.url || fullUrl
|
|
} : chosenFallback;
|
|
|
|
// Read HTML for route or fallback to index.html
|
|
// For dist deployment: __dirname is dist/server/, so go up to dist/
|
|
let htmlPath = path.join(
|
|
__dirname,
|
|
'..',
|
|
normalizedPath.endsWith('/') ? normalizedPath.slice(0, -1) : normalizedPath,
|
|
'index.html'
|
|
);
|
|
if (!fs.existsSync(htmlPath)) {
|
|
htmlPath = path.join(__dirname, '..', 'index.html');
|
|
}
|
|
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
|
|
// Ensure <head> exists
|
|
if (!/<head[\s\S]*<\/head>/i.test(html)) {
|
|
html = html.replace(/<html[^>]*>/i, match => `${match}\n<head></head>`);
|
|
}
|
|
|
|
// Title
|
|
html = replaceOrInsert(html, /<title>[^<]*<\/title>/i, `<title>${escapeHtml(seoData.title)}</title>`);
|
|
|
|
// Description
|
|
html = replaceOrInsert(
|
|
html,
|
|
/<meta\s+name="description"[^>]*>/i,
|
|
`<meta name="description" content="${escapeHtml(seoData.description)}">`
|
|
);
|
|
|
|
// Canonical
|
|
html = replaceOrInsert(
|
|
html,
|
|
/<link\s+rel="canonical"[^>]*>/i,
|
|
`<link rel="canonical" href="${escapeAttribute(seoData.url)}">`
|
|
);
|
|
|
|
// Open Graph
|
|
html = replaceOrInsert(html, /<meta\s+property="og:title"[^>]*>/i, `<meta property="og:title" content="${escapeAttribute(seoData.title)}">`);
|
|
html = replaceOrInsert(html, /<meta\s+property="og:description"[^>]*>/i, `<meta property="og:description" content="${escapeAttribute(seoData.description)}">`);
|
|
html = replaceOrInsert(html, /<meta\s+property="og:image"[^>]*>/i, `<meta property="og:image" content="${escapeAttribute(seoData.image)}">`);
|
|
html = replaceOrInsert(html, /<meta\s+property="og:url"[^>]*>/i, `<meta property="og:url" content="${escapeAttribute(seoData.url)}">`);
|
|
html = replaceOrInsert(html, /<meta\s+property="og:type"[^>]*>/i, `<meta property="og:type" content="article">`);
|
|
|
|
// Twitter Cards
|
|
html = replaceOrInsert(html, /<meta\s+name="twitter:card"[^>]*>/i, `<meta name="twitter:card" content="summary_large_image">`);
|
|
html = replaceOrInsert(html, /<meta\s+name="twitter:title"[^>]*>/i, `<meta name="twitter:title" content="${escapeAttribute(seoData.title)}">`);
|
|
html = replaceOrInsert(html, /<meta\s+name="twitter:description"[^>]*>/i, `<meta name="twitter:description" content="${escapeAttribute(seoData.description)}">`);
|
|
html = replaceOrInsert(html, /<meta\s+name="twitter:image"[^>]*>/i, `<meta name="twitter:image" content="${escapeAttribute(seoData.image)}">`);
|
|
|
|
res.send(html);
|
|
} catch (error) {
|
|
console.error('SEO middleware error:', error);
|
|
next();
|
|
}
|
|
};
|
|
|
|
// Function to fetch SEO data from your database
|
|
async function fetchSEOFromDB(pageId) {
|
|
try {
|
|
const API_URL = process.env.API_URL || 'https://api.pozo.app';
|
|
const response = await axios.get(`${API_URL}/Seo?PageId=${pageId}`);
|
|
if (response.data?.statusCode === 1 && response.data?.data?.length > 0) {
|
|
return response.data.data[0];
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
console.error('Failed to fetch SEO data from database:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Try to fetch SEO by exact path or blog slug, with graceful fallbacks
|
|
async function fetchSEOByPathOrSlug(normalizedPath) {
|
|
try {
|
|
const API_URL = process.env.API_URL || 'https://api.pozo.app';
|
|
|
|
// 1) Try path-based SEO: /Seo?Path=/home/blog/my-post
|
|
const byPath = await safeGet(`${API_URL}/Seo`, { Path: normalizedPath });
|
|
if (byPath) return byPath;
|
|
|
|
// 2) If looks like blog detail: /home/blog/slug
|
|
if (/^\/home\/blog\//.test(normalizedPath)) {
|
|
const slug = normalizedPath.replace(/^\/home\/blog\//, '').replace(/\/$/, '');
|
|
if (slug) {
|
|
// Try common blog SEO endpoints
|
|
const blogSeo = await safeGet(`${API_URL}/Seo/Blog`, { slug })
|
|
|| await safeGet(`${API_URL}/Blog/Seo`, { slug })
|
|
|| await safeGet(`${API_URL}/Blog`, { slug });
|
|
if (blogSeo) return blogSeo;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function safeGet(baseUrl, queryObj) {
|
|
try {
|
|
const qs = new URLSearchParams(queryObj).toString();
|
|
const url = `${baseUrl}?${qs}`;
|
|
const response = await axios.get(url);
|
|
const data = response.data;
|
|
if (data?.statusCode === 1 && Array.isArray(data?.data) && data.data.length > 0) {
|
|
return data.data[0];
|
|
}
|
|
// Some APIs return object directly
|
|
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
|
|
return null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizePath(p) {
|
|
if (!p) return '/';
|
|
try {
|
|
// Remove query/hash, ensure leading slash, collapse duplicate slashes
|
|
const onlyPath = p.split('?')[0].split('#')[0] || '/';
|
|
let normalized = onlyPath.replace(/\\+/g, '/');
|
|
if (!normalized.startsWith('/')) normalized = `/${normalized}`;
|
|
// Remove trailing slash except for root
|
|
if (normalized.length > 1 && normalized.endsWith('/')) {
|
|
normalized = normalized.slice(0, -1);
|
|
}
|
|
return normalized;
|
|
} catch {
|
|
return '/';
|
|
}
|
|
}
|
|
|
|
// Map routes without the "/home" prefix into the SPA's "/home" namespace
|
|
function mapToHomeNamespace(p) {
|
|
if (!p) return '/home/';
|
|
const raw = p.split('?')[0].split('#')[0] || '/';
|
|
// Already in /home namespace
|
|
if (raw === '/' || raw.startsWith('/home/')) return raw;
|
|
|
|
// Known top-level marketing routes to map into /home/*
|
|
const known = ['pricing-pozoapp', 'contact-us', 'signin', 'blog'];
|
|
const parts = raw.replace(/^\/+/, '').split('/');
|
|
const head = parts[0];
|
|
|
|
if (known.includes(head)) {
|
|
// blog detail: /blog/slug -> /home/blog/slug
|
|
if (head === 'blog' && parts.length > 1) {
|
|
return `/home/${parts.join('/')}`;
|
|
}
|
|
// simple mapping: /pricing-pozoapp -> /home/pricing-pozoapp
|
|
return `/home/${raw.replace(/^\/+/, '')}`;
|
|
}
|
|
|
|
// default: keep as-is
|
|
return raw;
|
|
}
|
|
|
|
function buildFullUrl(req) {
|
|
const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'https').split(',')[0];
|
|
const host = req.headers['x-forwarded-host'] || req.headers.host || 'www.pozo.app';
|
|
const pathOnly = normalizePath(req.path);
|
|
return `${proto}://${host}${pathOnly}`;
|
|
}
|
|
|
|
function replaceOrInsert(html, regex, newTagHtml) {
|
|
if (regex.test(html)) {
|
|
return html.replace(regex, newTagHtml);
|
|
}
|
|
// Insert before </head>
|
|
if (/<\/head>/i.test(html)) {
|
|
return html.replace(/<\/head>/i, `${newTagHtml}\n</head>`);
|
|
}
|
|
// As a last resort, prepend to document
|
|
return `${newTagHtml}\n${html}`;
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
function escapeAttribute(str) {
|
|
return String(str || '')
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
export default seoMiddleware;
|