import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { JSDOM } from 'jsdom' const currentDirectory = dirname(fileURLToPath(import.meta.url)) const distDirectory = join(currentDirectory, '..', 'dist') const siteUrl = process.env.VITE_SITE_URL?.trim().replace(/\/+$/u, '') || '' const SITE_NAME = 'Xdrop' const DEFAULT_OG_IMAGE_PATH = '/brand-lockup-horizontal.png' const DEFAULT_LOGO_PATH = '/brand-symbol-512.png' const DEFAULT_OG_IMAGE_ALT = 'Open source end-to-end encrypted file transfer for humans and agents. Plaintext file names, contents, and keys stay off the server.' const DEFAULT_OG_TYPE = 'website' const REPOSITORY_URL = 'https://github.com/xixu-me/xdrop' const DEFAULT_ROBOTS = 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1' const PRIVATE_ROBOTS = 'noindex, nofollow, noarchive, nosnippet' const STRUCTURED_DATA_ID = 'xdrop-structured-data-static' const HOME_TITLE = 'Open Source End-to-End Encrypted File Transfer for Humans and Agents | Xdrop' const HOME_DESCRIPTION = 'Xdrop is an open source end-to-end encrypted file transfer app for humans and agents, keeping plaintext file names, contents, and keys off the server.' const HISTORY_PAGE_TITLE = 'Manage Transfers on This Device | Xdrop' const HISTORY_PAGE_DESCRIPTION = 'Manage encrypted transfers stored in this browser on this device. There is no account or cross-device history.' const SHARE_PAGE_TITLE = 'Share the Full Link | Xdrop' const SHARE_PAGE_DESCRIPTION = 'Review upload status and copy the full share link for a transfer staged in this browser on this device.' const RECEIVE_PAGE_TITLE = 'Download and Decrypt in the Browser | Xdrop' const RECEIVE_PAGE_DESCRIPTION = 'Download files from this transfer and decrypt them in the browser. The decryption key stays in the share link fragment.' const NOT_FOUND_PAGE_TITLE = 'Page Not Found | Xdrop' const NOT_FOUND_PAGE_DESCRIPTION = 'The address does not map to a page in Xdrop. If this came from a shared transfer, ask for the full URL, including the #k=... decryption fragment.' const routeShells = [ { outputPath: 'index.html', pagePath: '/', title: HOME_TITLE, description: HOME_DESCRIPTION, robots: DEFAULT_ROBOTS, structuredData: getHomeStructuredData(), }, { outputPath: 'transfers/index.html', pagePath: '/transfers', title: HISTORY_PAGE_TITLE, description: HISTORY_PAGE_DESCRIPTION, robots: PRIVATE_ROBOTS, exposeUrl: false, }, { outputPath: 'share/index.html', pagePath: '/share/', title: SHARE_PAGE_TITLE, description: SHARE_PAGE_DESCRIPTION, robots: PRIVATE_ROBOTS, exposeUrl: false, }, { outputPath: 't/index.html', pagePath: '/t/', title: RECEIVE_PAGE_TITLE, description: RECEIVE_PAGE_DESCRIPTION, robots: PRIVATE_ROBOTS, exposeUrl: false, }, { outputPath: 'not-found/index.html', title: NOT_FOUND_PAGE_TITLE, description: NOT_FOUND_PAGE_DESCRIPTION, robots: PRIVATE_ROBOTS, exposeUrl: false, }, ] const robotsLines = [ 'User-agent: *', 'Allow: /', 'Disallow: /share/', 'Disallow: /t/', 'Disallow: /transfers', ] if (siteUrl) { robotsLines.push(`Sitemap: ${siteUrl}/sitemap.xml`) } await writeFile(join(distDirectory, 'robots.txt'), `${robotsLines.join('\n')}\n`, 'utf8') if (!siteUrl) { await rm(join(distDirectory, 'sitemap.xml'), { force: true }) console.warn('[seo] Skipped sitemap.xml generation because VITE_SITE_URL is not set.') } const sitemap = siteUrl ? ` ${siteUrl}/ weekly 1.0 ` : '' if (sitemap) { await writeFile(join(distDirectory, 'sitemap.xml'), sitemap, 'utf8') } const template = await readFile(join(distDirectory, 'index.html'), 'utf8') for (const shell of routeShells) { const outputFile = join(distDirectory, shell.outputPath) await mkdir(dirname(outputFile), { recursive: true }) await writeFile(outputFile, renderRouteShell(template, shell), 'utf8') } console.info( `[seo] Generated route HTML shells${siteUrl ? `, robots.txt, and sitemap.xml for ${siteUrl}` : ' and robots.txt'}.`, ) function renderRouteShell(template, shell) { const dom = new JSDOM(template) const { document } = dom.window const canonicalUrl = shell.exposeUrl === false || !shell.pagePath ? undefined : toAbsoluteUrl(shell.pagePath) const imageUrl = toAbsoluteUrl(DEFAULT_OG_IMAGE_PATH) document.title = shell.title setMetaByName(document, 'description', shell.description) setMetaByName(document, 'robots', shell.robots) setMetaByName(document, 'application-name', SITE_NAME) setMetaByName(document, 'apple-mobile-web-app-title', SITE_NAME) setMetaByName(document, 'twitter:card', 'summary_large_image') setMetaByName(document, 'twitter:title', shell.title) setMetaByName(document, 'twitter:description', shell.description) setMetaByName(document, 'twitter:image', imageUrl) setMetaByProperty(document, 'og:site_name', SITE_NAME) setMetaByProperty(document, 'og:type', DEFAULT_OG_TYPE) setMetaByProperty(document, 'og:title', shell.title) setMetaByProperty(document, 'og:description', shell.description) setMetaByProperty(document, 'og:image', imageUrl) setMetaByProperty(document, 'og:image:alt', DEFAULT_OG_IMAGE_ALT) if (canonicalUrl) { setMetaByProperty(document, 'og:url', canonicalUrl) ensureCanonicalLink(document, canonicalUrl) } else { removeCanonicalLink(document) removeMetaByProperty(document, 'og:url') } syncStructuredData(document, shell.structuredData) return `\n${document.documentElement.outerHTML}\n` } function setMetaByName(document, name, content) { let meta = document.head.querySelector(`meta[name="${name}"]`) if (!meta) { meta = document.createElement('meta') meta.setAttribute('name', name) document.head.append(meta) } meta.setAttribute('content', content) } function setMetaByProperty(document, property, content) { let meta = document.head.querySelector(`meta[property="${property}"]`) if (!meta) { meta = document.createElement('meta') meta.setAttribute('property', property) document.head.append(meta) } meta.setAttribute('content', content) } function removeMetaByProperty(document, property) { document.head.querySelector(`meta[property="${property}"]`)?.remove() } function ensureCanonicalLink(document, href) { let link = document.head.querySelector('link[rel="canonical"]') if (!link) { link = document.createElement('link') link.setAttribute('rel', 'canonical') document.head.append(link) } link.setAttribute('href', href) } function removeCanonicalLink(document) { document.head.querySelector('link[rel="canonical"]')?.remove() } function syncStructuredData(document, structuredData) { const existing = document.getElementById(STRUCTURED_DATA_ID) if (!structuredData) { existing?.remove() return } const script = existing ?? document.createElement('script') script.id = STRUCTURED_DATA_ID script.setAttribute('type', 'application/ld+json') script.textContent = JSON.stringify(structuredData) if (!existing) { document.head.append(script) } } function getHomeStructuredData() { const homeUrl = toAbsoluteUrl('/') const organizationId = `${homeUrl}#organization` const logoUrl = toAbsoluteUrl(DEFAULT_LOGO_PATH) const imageUrl = toAbsoluteUrl(DEFAULT_OG_IMAGE_PATH) return [ { '@context': 'https://schema.org', '@type': 'WebSite', name: SITE_NAME, url: homeUrl, description: HOME_DESCRIPTION, image: imageUrl, publisher: { '@id': organizationId, }, }, { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: SITE_NAME, applicationCategory: 'UtilitiesApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD', }, description: HOME_DESCRIPTION, url: homeUrl, image: imageUrl, publisher: { '@id': organizationId, }, sameAs: [REPOSITORY_URL], }, { '@context': 'https://schema.org', '@id': organizationId, '@type': 'Organization', name: SITE_NAME, url: homeUrl, sameAs: [REPOSITORY_URL], image: imageUrl, logo: { '@type': 'ImageObject', url: logoUrl, width: 512, height: 512, }, }, ] } function toAbsoluteUrl(path) { if (!siteUrl) { return path } return new URL(path, `${siteUrl}/`).toString() }