chatapp-h5/scripts/build.mjs
2026-04-13 23:56:50 +08:00

553 lines
20 KiB
JavaScript
Executable File

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const distDir = path.join(root, 'dist');
const wwwDir = path.join(distDir, 'www');
const h5Dir = path.join(distDir, 'h5');
const siteContent = readJson(path.join(root, 'content', 'site.json'));
const legalContent = readJson(path.join(root, 'content', 'legal.json'));
const H5_PRIVACY_URL = `${siteContent.company.h5Website}/privacy.html`;
const H5_TERMS_URL = `${siteContent.company.h5Website}/terms.html`;
const H5_DELETE_ACCOUNT_URL = `${siteContent.company.h5Website}/delete-account.html`;
const H5_CHILD_SAFETY_URL = `${siteContent.company.h5Website}/child-safety.html`;
const landingPages = fs
.readdirSync(path.join(root, 'content', 'landing-pages'))
.filter((file) => file.endsWith('.json'))
.map((file) => readJson(path.join(root, 'content', 'landing-pages', file)))
.sort((a, b) => a.slug.localeCompare(b.slug));
build();
function build() {
fs.rmSync(distDir, { force: true, recursive: true });
ensureDir(path.join(wwwDir, 'assets'));
ensureDir(path.join(h5Dir, 'assets'));
copyFile(path.join(root, 'src', 'site.css'), path.join(wwwDir, 'assets', 'site.css'));
copyFile(path.join(root, 'src', 'site.css'), path.join(h5Dir, 'assets', 'site.css'));
writeHtml(path.join(wwwDir, 'index.html'), renderHomePage());
writeHtml(
path.join(wwwDir, 'privacy', 'index.html'),
renderRedirectPage({
title: 'Privacy Policy Redirect',
target: H5_PRIVACY_URL,
}),
);
writeHtml(
path.join(wwwDir, 'terms', 'index.html'),
renderRedirectPage({
title: 'Terms of Service Redirect',
target: H5_TERMS_URL,
}),
);
writeHtml(
path.join(wwwDir, 'delete-account', 'index.html'),
renderRedirectPage({
title: 'Delete Account Redirect',
target: H5_DELETE_ACCOUNT_URL,
}),
);
writeHtml(
path.join(wwwDir, 'child-safety', 'index.html'),
renderRedirectPage({
title: 'Child Safety Standards Redirect',
target: H5_CHILD_SAFETY_URL,
}),
);
writeText(path.join(wwwDir, 'robots.txt'), 'User-agent: *\nAllow: /\n');
writeText(path.join(wwwDir, 'sitemap.xml'), renderSiteMap());
writeHtml(path.join(h5Dir, 'index.html'), renderLandingIndexPage());
writeHtml(
path.join(h5Dir, 'privacy.html'),
renderPlainLegalPage('privacy', {
canonical: H5_PRIVACY_URL,
}),
);
writeHtml(
path.join(h5Dir, 'terms.html'),
renderLegalPage('terms', {
canonical: H5_TERMS_URL,
homeHref: '/',
privacyHref: '/privacy.html',
termsHref: '/terms.html',
primaryActionHref: 'https://www.haiyihy.com/',
primaryActionLabel: 'Official Site',
}),
);
writeHtml(
path.join(h5Dir, 'delete-account.html'),
renderPlainLegalPage('deleteAccount', {
canonical: H5_DELETE_ACCOUNT_URL,
}),
);
writeHtml(
path.join(h5Dir, 'child-safety.html'),
renderPlainLegalPage('childSafety', {
canonical: H5_CHILD_SAFETY_URL,
}),
);
writeHtml(path.join(h5Dir, 'studio', 'index.html'), renderStudioPage());
writeText(path.join(h5Dir, 'data', 'pages.json'), `${JSON.stringify(landingPages, null, 2)}\n`);
for (const page of landingPages) {
writeHtml(path.join(h5Dir, page.slug, 'index.html'), renderLandingPage(page));
}
console.log(`Built public site to ${distDir}`);
}
function renderHomePage() {
const { brand, company, hero } = siteContent;
return renderDocument({
title: `${brand.name} | Official Website`,
description: brand.description,
canonical: `${company.website}/`,
hostRoot: '',
theme: 'ember',
bodyClass: 'corp-body',
body: `
<div class="corp-home">
<header class="corp-header">
<div class="shell corp-header__inner">
<a class="corp-brand" href="/">
<span class="corp-brand__mark" aria-hidden="true"></span>
<span class="corp-brand__text">
<strong>${escapeHtml(brand.name)}</strong>
<span>${escapeHtml(company.industry)}</span>
</span>
</a>
<nav class="corp-nav" aria-label="Primary">
<a href="#about">About Us</a>
<a href="${escapeAttr(H5_PRIVACY_URL)}">Privacy</a>
<a href="${escapeAttr(H5_TERMS_URL)}">Terms</a>
<a href="${escapeAttr(company.h5Website)}">H5 Pages</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main class="corp-main">
<section class="corp-hero">
<div class="corp-hero__overlay"></div>
<div class="corp-hero__image"></div>
<div class="shell corp-hero__content">
<div class="corp-hero__eyebrow">${escapeHtml(hero.eyebrow)}</div>
<h1 class="corp-hero__title">${escapeHtml(hero.title)}</h1>
<p class="corp-hero__summary">${escapeHtml(hero.summary)}</p>
</div>
</section>
<section class="corp-strip" id="about">
<div class="shell corp-strip__grid">
<article class="corp-strip__item">
<small>About us</small>
<h2>${escapeHtml(company.legalNameEn)}</h2>
<p>${escapeHtml(company.legalNameCn)}</p>
<p>${escapeHtml(company.address)}</p>
</article>
<article class="corp-strip__item">
<small>Official pages</small>
<p><a href="${escapeAttr(H5_PRIVACY_URL)}">Privacy Policy</a></p>
<p><a href="${escapeAttr(H5_TERMS_URL)}">Terms of Service</a></p>
<p><a href="${escapeAttr(H5_DELETE_ACCOUNT_URL)}">Delete Account</a></p>
<p><a href="${escapeAttr(H5_CHILD_SAFETY_URL)}">Child Safety Standards</a></p>
<p><a href="${escapeAttr(company.h5Website)}">H5 Landing Pages</a></p>
</article>
<article class="corp-strip__item" id="contact">
<small>Contact</small>
<p><a href="mailto:${escapeAttr(company.supportEmail)}">${escapeHtml(company.supportEmail)}</a></p>
<p><a href="tel:${escapeAttr(company.phone)}">${escapeHtml(company.phone)}</a></p>
<p>${escapeHtml(company.website)}</p>
</article>
</div>
</section>
</main>
</div>
`,
});
}
function renderLegalPage(kind, options = {}) {
const { company, brand } = siteContent;
const doc = legalContent[kind];
const canonical = options.canonical || `${company.website}/${kind}/`;
const homeHref = options.homeHref || '/';
const privacyHref = options.privacyHref || H5_PRIVACY_URL;
const termsHref = options.termsHref || H5_TERMS_URL;
const primaryActionHref =
options.primaryActionHref || 'https://play.google.com/store/apps/details?id=com.org.yumi';
const primaryActionLabel = options.primaryActionLabel || 'Get Yumi';
return renderDocument({
title: `${doc.title} | ${brand.name}`,
description: doc.summary,
canonical,
hostRoot: '',
theme: kind === 'privacy' ? 'steel' : 'ink',
body: `
${renderHeader({
brandName: brand.name,
subline: doc.title,
links: [
{ href: homeHref, label: 'Home' },
{ href: privacyHref, label: 'Privacy' },
{ href: termsHref, label: 'Terms' },
],
action: { href: primaryActionHref, label: primaryActionLabel },
})}
<main class="shell legal-shell">
<div class="eyebrow">${escapeHtml(kind === 'privacy' ? 'Legal disclosure' : 'Service terms')}</div>
<h1 class="legal-title">${escapeHtml(doc.title)}</h1>
<p class="legal-summary">${escapeHtml(doc.summary)}</p>
<div class="legal-meta">
<div class="meta-chip">Effective date: ${escapeHtml(legalContent.effectiveDate)}</div>
<div class="meta-chip">${escapeHtml(company.legalNameEn)}</div>
<div class="meta-chip">${escapeHtml(company.privacyEmail)}</div>
</div>
<div class="legal-stack">
${doc.sections.map((section) => `
<section class="section-card legal-block">
<h3>${escapeHtml(section.title)}</h3>
${renderParagraphs(section.paragraphs)}
${renderBullets(section.bullets)}
</section>
`).join('')}
</div>
</main>
${renderFooter({ company, brandName: brand.name })}
`,
});
}
function renderPlainLegalPage(kind, options = {}) {
const { company, brand } = siteContent;
const doc = legalContent[kind];
const canonical = options.canonical || `${company.h5Website}/${kind}.html`;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(doc.title)} | ${escapeHtml(brand.name)}</title>
<meta name="description" content="${escapeAttr(doc.summary)}">
<link rel="canonical" href="${escapeAttr(canonical)}">
</head>
<body>
<h1>${escapeHtml(doc.title)}</h1>
<p>Effective date: ${escapeHtml(legalContent.effectiveDate)}</p>
<p>Operator: ${escapeHtml(company.legalNameEn)} (${escapeHtml(company.legalNameCn)})</p>
<p>Address: ${escapeHtml(company.address)}</p>
<p>Contact: ${escapeHtml(company.privacyEmail)} / ${escapeHtml(company.phone)}</p>
${kind === 'deleteAccount' ? `<p><a href="${escapeAttr(renderDeleteAccountMailto(company.privacyEmail))}">Start a deletion request by email</a></p>` : ''}
<p>${escapeHtml(doc.summary)}</p>
${doc.sections.map((section) => renderPlainLegalSection(section)).join('\n')}
</body>
</html>`;
}
function renderLandingIndexPage() {
const { brand, company } = siteContent;
return renderDocument({
title: `${brand.name} H5 | Campaign Pages`,
description: 'Index of JSON-driven Yumi landing pages.',
canonical: `${company.h5Website}/`,
hostRoot: '',
theme: 'sapphire',
body: `
${renderHeader({
brandName: brand.name,
subline: 'H5 landing pages',
links: [
{ href: '/', label: 'H5 Home' },
{ href: '/studio/', label: 'Studio' },
{ href: 'https://www.haiyihy.com/', label: 'Official Site' },
],
action: { href: H5_PRIVACY_URL, label: 'Privacy' },
})}
<main class="shell landing-shell">
<div class="eyebrow">Operations and campaigns</div>
<h1 class="landing-title">A structured index of all public H5 destinations.</h1>
<p class="landing-lead">Each page below is generated from a single JSON file, which keeps campaign updates lightweight and predictable.</p>
<div class="landing-grid" style="margin-top: 28px;">
${landingPages.map((page) => `
<a class="link-card" href="/${escapeAttr(page.slug)}/">
<small>${escapeHtml(page.eyebrow)}</small>
<h3>${escapeHtml(page.title)}</h3>
<p>${escapeHtml(page.summary)}</p>
<span>Open page</span>
</a>
`).join('')}
</div>
</main>
${renderFooter({ company, brandName: brand.name })}
`,
});
}
function renderLandingPage(page) {
const { brand, company } = siteContent;
return renderDocument({
title: `${page.title} | ${brand.name} H5`,
description: page.summary,
canonical: `${company.h5Website}/${page.slug}/`,
hostRoot: '../',
theme: page.theme || 'ember',
body: `
${renderHeader({
brandName: brand.name,
subline: 'H5 landing page',
links: [
{ href: '/', label: 'H5 Home' },
{ href: '/studio/', label: 'Studio' },
{ href: 'https://www.haiyihy.com/', label: 'Official Site' },
],
action: { href: H5_PRIVACY_URL, label: 'Privacy' },
})}
<main class="shell landing-shell">
<div class="eyebrow">${escapeHtml(page.eyebrow)}</div>
<h1 class="landing-title">${escapeHtml(page.title)}</h1>
<p class="landing-lead">${escapeHtml(page.summary)}</p>
<div class="landing-meta">
${page.stats.map((item) => `<div class="meta-chip"><strong>${escapeHtml(item.value)}</strong> · ${escapeHtml(item.label)}</div>`).join('')}
</div>
<div class="landing-banner">
<p>${escapeHtml(page.highlight)}</p>
</div>
<div class="actions">
<a class="button" href="${escapeAttr(page.cta.href)}">${escapeHtml(page.cta.label)}</a>
<a class="button-secondary" href="${escapeAttr(page.secondaryCta.href)}">${escapeHtml(page.secondaryCta.label)}</a>
</div>
<div class="landing-stack">
${page.sections.map((section) => `
<section class="section-card">
<h3>${escapeHtml(section.title)}</h3>
<p>${escapeHtml(section.body)}</p>
${renderBullets(section.bullets)}
</section>
`).join('')}
</div>
</main>
${renderFooter({ company, brandName: brand.name })}
`,
});
}
function renderStudioPage() {
const { brand, company } = siteContent;
return renderDocument({
title: `${brand.name} H5 Studio`,
description: 'Editorial cockpit for landing page content files.',
canonical: `${company.h5Website}/studio/`,
hostRoot: '../',
theme: 'forest',
body: `
${renderHeader({
brandName: brand.name,
subline: 'Content studio',
links: [
{ href: '/', label: 'H5 Home' },
{ href: 'https://www.haiyihy.com/', label: 'Official Site' },
{ href: H5_PRIVACY_URL, label: 'Privacy' },
],
action: { href: '/', label: 'View Pages' },
})}
<main class="shell studio-shell">
<div class="eyebrow">Editorial cockpit</div>
<h1 class="landing-title">Manage landing pages from JSON, not from page code.</h1>
<p class="landing-lead">This page is a publishing reference for operators and developers. It documents the current slugs and shows the exact JSON structure that drives each landing page.</p>
<div class="studio-note">
Current management mode is file-driven. Edit JSON under <code>public-site/content/landing-pages</code>, rebuild the site, and redeploy. This keeps operations fast while avoiding app-release coupling.
</div>
<div class="landing-stack">
${landingPages.map((page) => `
<section class="section-card">
<small>${escapeHtml(page.slug)}</small>
<h3>${escapeHtml(page.title)}</h3>
<p>${escapeHtml(page.summary)}</p>
<div class="actions">
<a class="button-secondary" href="/${escapeAttr(page.slug)}/">Preview page</a>
</div>
<pre class="code-block">${escapeHtml(JSON.stringify(page, null, 2))}</pre>
</section>
`).join('')}
</div>
</main>
${renderFooter({ company, brandName: brand.name })}
`,
});
}
function renderHeader({ brandName, subline, links, action }) {
return `
<header class="site-header">
<div class="shell site-header__inner">
<a class="brand" href="/">
<span class="brand__mark" aria-hidden="true"></span>
<span class="brand__text">
<span class="brand__name">${escapeHtml(brandName)}</span>
<span class="brand__sub">${escapeHtml(subline)}</span>
</span>
</a>
<nav class="header-links" aria-label="Primary">
${links.map((item) => `<a href="${escapeAttr(item.href)}">${escapeHtml(item.label)}</a>`).join('')}
<a class="header-pill" href="${escapeAttr(action.href)}">${escapeHtml(action.label)}</a>
</nav>
</div>
</header>
`;
}
function renderFooter({ company, brandName }) {
return `
<footer class="site-footer">
<div class="shell">
<div class="footer-grid">
<div>
<div class="eyebrow">Legal and support</div>
<p>${escapeHtml(brandName)} is operated by ${escapeHtml(company.legalNameEn)} (${escapeHtml(company.legalNameCn)}).</p>
<p>${escapeHtml(company.address)}</p>
<p>Industry: ${escapeHtml(company.industry)}</p>
<p>Support: <a href="mailto:${escapeAttr(company.supportEmail)}">${escapeHtml(company.supportEmail)}</a><br>Phone: <a href="tel:${escapeAttr(company.phone)}">${escapeHtml(company.phone)}</a></p>
</div>
<div>
<div class="footer-links">
<a href="https://www.haiyihy.com/">Official site</a>
<a href="${escapeAttr(H5_PRIVACY_URL)}">Privacy policy</a>
<a href="${escapeAttr(H5_TERMS_URL)}">Terms of service</a>
<a href="${escapeAttr(H5_DELETE_ACCOUNT_URL)}">Delete account</a>
<a href="${escapeAttr(H5_CHILD_SAFETY_URL)}">Child safety</a>
<a href="https://h5.haiyihy.com/">H5 index</a>
<a href="https://h5.haiyihy.com/studio/">H5 studio</a>
</div>
</div>
</div>
</div>
</footer>
`;
}
function renderDocument({ title, description, canonical, theme, hostRoot, body, bodyClass = '' }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<meta name="description" content="${escapeAttr(description)}">
<link rel="canonical" href="${escapeAttr(canonical)}">
<link rel="stylesheet" href="${escapeAttr(hostRoot)}assets/site.css">
</head>
<body data-theme="${escapeAttr(theme)}"${bodyClass ? ` class="${escapeAttr(bodyClass)}"` : ''}>
${body}
</body>
</html>
`;
}
function renderParagraphs(paragraphs = []) {
return paragraphs.map((paragraph) => `<p>${escapeHtml(paragraph)}</p>`).join('');
}
function renderBullets(bullets = []) {
if (!bullets?.length) {
return '';
}
return `<ul>${bullets.map((item) => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`;
}
function renderPlainLegalSection(section) {
return `
<h2>${escapeHtml(section.title)}</h2>
${renderParagraphs(section.paragraphs)}
${renderPlainBullets(section.bullets)}
`;
}
function renderPlainBullets(bullets = []) {
if (!bullets?.length) {
return '';
}
return bullets.map((item) => `<p>- ${escapeHtml(item)}</p>`).join('');
}
function renderDeleteAccountMailto(email) {
const subject = encodeURIComponent('Delete Yumi Account');
return `mailto:${email}?subject=${subject}`;
}
function renderSiteMap() {
const pages = [
'https://www.haiyihy.com/',
'https://h5.haiyihy.com/',
H5_PRIVACY_URL,
H5_TERMS_URL,
H5_DELETE_ACCOUNT_URL,
H5_CHILD_SAFETY_URL,
'https://h5.haiyihy.com/studio/',
...landingPages.map((page) => `https://h5.haiyihy.com/${page.slug}/`),
];
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${pages.map((url) => ` <url><loc>${url}</loc></url>`).join('\n')}
</urlset>
`;
}
function renderRedirectPage({ title, target }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${escapeAttr(target)}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<link rel="canonical" href="${escapeAttr(target)}">
<script>window.location.replace(${JSON.stringify(target)});</script>
</head>
<body>
<p>Redirecting to <a href="${escapeAttr(target)}">${escapeHtml(target)}</a>...</p>
</body>
</html>
`;
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function writeHtml(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, `${content}\n`, 'utf8');
}
function writeText(filePath, content) {
ensureDir(path.dirname(filePath));
fs.writeFileSync(filePath, content, 'utf8');
}
function copyFile(source, target) {
ensureDir(path.dirname(target));
fs.copyFileSync(source, target);
}
function escapeHtml(value = '') {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function escapeAttr(value = '') {
return escapeHtml(value);
}