import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const frontendDir = resolve(scriptDir, '..'); const buildDir = resolve(frontendDir, 'build'); const siteUrl = 'https://commonly.me'; const metadataStart = ''; const metadataEnd = ''; const pageContentMarker = ''; const gateRuntimeStart = ''; const gateRuntimeEnd = ''; const navigationRuntimeStart = ''; const navigationRuntimeEnd = ''; const viteEntryAssetPrefix = 'src="/assets/index-'; const escapeHtml = (value) => String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); const list = (items) => ``; const renderGuideTable = (table) => `
${table.headers.map((header) => ``).join('')}${table.rows.map((row) => `${row.map((cell) => ``).join('')}`).join('')}
${escapeHtml(header)}
${escapeHtml(cell)}
`; const replaceMarkedSection = (template, start, end, content) => { const startIndex = template.indexOf(start); const endIndex = template.indexOf(end); if (startIndex < 0 || endIndex < 0 || endIndex < startIndex) { throw new Error(`Missing or malformed ${start} marker in the Vite output.`); } return `${template.slice(0, startIndex)}${start}\n${content}\n${end}${template.slice(endIndex + end.length)}`; }; const removeMarkedSection = (template, start, end) => { const startIndex = template.indexOf(start); const endIndex = template.indexOf(end); if (startIndex < 0 || endIndex < 0 || endIndex < startIndex) { throw new Error(`Missing or malformed ${start} marker in the Vite output.`); } return `${template.slice(0, startIndex)}${template.slice(endIndex + end.length)}`; }; const removeRuntimeEntryScript = (template) => { const entryIndex = template.indexOf(viteEntryAssetPrefix); const tagStart = template.lastIndexOf('<', entryIndex); const tagEnd = template.indexOf('>', entryIndex); if ( entryIndex < 0 || tagStart < 0 || tagEnd < entryIndex || !template.startsWith(' { let document = template; let preloadIndex = document.indexOf('rel="modulepreload"'); while (preloadIndex >= 0) { const tagStart = document.lastIndexOf('<', preloadIndex); const tagEnd = document.indexOf('>', preloadIndex); if ( tagStart < 0 || tagEnd < preloadIndex || !document.startsWith(' { const withoutGateRuntime = removeMarkedSection(template, gateRuntimeStart, gateRuntimeEnd); const withoutNavigationRuntime = removeMarkedSection( withoutGateRuntime, navigationRuntimeStart, navigationRuntimeEnd, ); const withoutRuntime = removeModulePreloads(removeRuntimeEntryScript(withoutNavigationRuntime)); const headEnd = withoutRuntime.indexOf(''); if (headEnd < 0) { throw new Error('Missing closing head tag in the static SEO page template.'); } // Static-only pages deliberately omit the React bundle. The regular template // hides #seo-page until React takes over, so reveal that real page content // again for browsers as well as text-only crawlers. return `${withoutRuntime.slice(0, headEnd)}${withoutRuntime.slice(headEnd)}`; }; const pageUrl = (path) => `${siteUrl}${path}`; const formatDate = (value) => { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new Error(`Guide provenance date must use YYYY-MM-DD: ${value}`); } const date = new Date(`${value}T00:00:00Z`); if (Number.isNaN(date.getTime())) { throw new Error(`Guide provenance date is invalid: ${value}`); } return new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC', }).format(date); }; const guideProvenance = (guide) => { const provenance = guide.provenance; const required = ['author', 'reviewer', 'datePublished', 'dateModified']; if (!provenance || required.some((field) => !provenance[field])) { throw new Error(`Guide ${guide.title} is missing required provenance.`); } if (provenance.dateModified < provenance.datePublished) { throw new Error(`Guide ${guide.title} has a modified date before its published date.`); } return provenance; }; const renderGuideProvenance = (guide) => { const provenance = guideProvenance(guide); const published = formatDate(provenance.datePublished); const modified = formatDate(provenance.dateModified); const dates = provenance.datePublished === provenance.dateModified ? `Published and updated ` : `Published · Updated `; return ``; }; const renderLanding = (landing, guides, faq = []) => { const featureCards = [ landing.features.needs, landing.features.team, landing.features.connectors, landing.features.byo, ].map((feature) => `

${escapeHtml(feature.kicker)}

${escapeHtml(feature.title)}

${escapeHtml(feature.text)}

${list(Object.values(feature.points))}
`).join(''); const howItWorks = [ landing.how.steps.install, landing.how.steps.teammate, landing.how.steps.swap, ].map((step, index) => `

Step ${index + 1}

${escapeHtml(step.title)}

${escapeHtml(step.text)}

`).join(''); const teamCards = Object.values(landing.agentTeams.cards).map((card) => `

${escapeHtml(card.title)}

    ${Object.values(card.roster).map((line) => `
  • ${escapeHtml(line)}
  • `).join('')}
    ${Object.values(card.chat).map((line) => `
  • ${escapeHtml([line.author, line.text].filter(Boolean).join(': '))}
  • `).join('')}

${escapeHtml(card.footer)}

`).join(''); const guideCards = Object.entries(guides).map(([id, guide]) => ` `).join(''); // The same questions and answers the en landing shows and its FAQPage JSON-LD carries. const faqSection = faq.length ? `

${escapeHtml(landing.faq.title)}

${faq.map((item) => `
${escapeHtml(item.question)}
${escapeHtml(item.answer)}
`).join('')}
` : ''; return `
Commonly

${escapeHtml(landing.hero.eyebrow)}

${escapeHtml(landing.hero.ariaLabel)}

${escapeHtml(landing.hero.lede)}

${escapeHtml(landing.actions.getStarted)}

$ git clone github.com/Team-Commonly/commonly && docker compose up

${escapeHtml(landing.wedge.title)}

${escapeHtml(landing.wedge.copy)}

${escapeHtml(landing.features.kicker)}

${escapeHtml(landing.features.title)}

${featureCards}

${escapeHtml(landing.how.kicker)}

${escapeHtml(landing.how.title)}

${howItWorks}

${escapeHtml(landing.agentTeams.kicker)}

${escapeHtml(landing.agentTeams.title)}

${teamCards}

Guides

Learn how teams work with agents

${guideCards}

Browse all guides

${escapeHtml(landing.openSource.kicker)}

${escapeHtml(landing.openSource.title)}

${escapeHtml(landing.openSource.lede)}

${escapeHtml(landing.actions.readSource)}

${faqSection}
Commonly is open source under Apache-2.0.
`; }; const renderCompare = (compare) => { const alternativeCards = Object.values(compare.alts).map((alternative) => `

${escapeHtml(alternative.name)}

${escapeHtml(alternative.what)}

${escapeHtml(alternative.accept)}

`).join(''); const commonlyCards = Object.values(compare.us).map((item) => `

${escapeHtml(item.title)}

${escapeHtml(item.body)}

`).join(''); return `
Commonly

${escapeHtml(compare.kicker)}

${escapeHtml(compare.title)}

${escapeHtml(compare.lede)}

${escapeHtml(compare.actions.getStarted)}

${escapeHtml(compare.sameTitle)}

${escapeHtml(compare.same)}

${escapeHtml(compare.altsTitle)}

${alternativeCards}

${escapeHtml(compare.usTitle)}

${commonlyCards}

${escapeHtml(compare.closeTitle)}

${escapeHtml(compare.close)}

${escapeHtml(compare.closeUs)}

${escapeHtml(compare.note)}

`; }; const renderUseCase = (useCase) => `
Commonly

${escapeHtml(useCase.eyebrow)}

${escapeHtml(useCase.title)}

${escapeHtml(useCase.summary)}

Start with this use caseExplore Agent Hub

Common challenges

${list(useCase.problems)}

What you can do

${list(useCase.outcomes)}

Example flow

    ${useCase.exampleFlow.map((step) => `
  1. ${escapeHtml(step)}
  2. `).join('')}
${useCase.relatedGuides?.length ? `

Related guides

` : ''}
`; const renderGuide = (guide) => { const sections = guide.sections.map((section) => `

${escapeHtml(section.title)}

${(section.paragraphs || []).map((paragraph) => { if (typeof paragraph === 'object' && paragraph.strong) { return `

${escapeHtml(paragraph.strong)}

`; } return `

${escapeHtml(paragraph)}

`; }).join('')} ${section.bullets?.length ? list(section.bullets) : ''} ${section.orderedItems?.length ? `
    ${section.orderedItems.map((item) => `
  1. ${escapeHtml(item)}
  2. `).join('')}
` : ''} ${section.tables?.map(renderGuideTable).join('') || ''} ${section.codeBlocks?.map((block) => `
${escapeHtml(block.code)}
`).join('') || ''} ${section.links?.length ? `` : ''}
`).join(''); const faq = guide.faq.map((item) => `

${escapeHtml(item.question)}

${escapeHtml(item.answer)}

`).join(''); const relatedLinks = guide.relatedLinks.map((link) => `${escapeHtml(link.label)}`).join(' · '); return `
Commonly

${escapeHtml(guide.eyebrow)}

${escapeHtml(guide.title)}

${escapeHtml(guide.description)}

${renderGuideProvenance(guide)} ${(guide.intro || []).map((paragraph) => `

${escapeHtml(paragraph)}

`).join('')}
${sections}

Frequently asked questions

${faq}

${escapeHtml(guide.cta.title)}

${escapeHtml(guide.cta.body)}

${escapeHtml(guide.cta.primary.label)}${escapeHtml(guide.cta.secondary.label)}

${relatedLinks}

`; }; const renderGuidesIndex = (guides) => { const guideCards = Object.entries(guides).map(([id, guide]) => ` `).join(''); return `
Commonly

Guides

Guides for teams working with AI agents

Practical explanations of the shared context, ownership, and handoffs that help people and AI agents work together on real projects.

${guideCards}
`; }; const jsonLd = (value) => JSON.stringify(value).replaceAll('<', '\\u003c'); // Must equal FAQ_JSONLD_ID in src/v2/landing/landingFaq.ts: the landing effect reuses this tag. const faqJsonLdId = 'landing-faq-jsonld'; const faqPageJsonLd = (faq) => ({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faq.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer }, })), }); const metadata = ({ title, description, path, schema, faqSchema, ogType = 'website', datePublished, dateModified, noindex = false }) => { const url = pageUrl(path); const structuredData = jsonLd(schema); const articleDates = ogType === 'article' && datePublished && dateModified ? `\n \n ` : ''; return ` ${escapeHtml(title)} ${articleDates} ${faqSchema ? ` ` : ''}`; }; export const buildPageDefinitions = ({ landing, compare, useCases, guides = {}, landingFaq = [] }) => { const organizationId = `${siteUrl}/#organization`; const websiteId = `${siteUrl}/#website`; const organization = { '@type': 'Organization', '@id': organizationId, name: 'Commonly AI', alternateName: ['Commonly', 'commonly.me'], url: `${siteUrl}/`, logo: `${siteUrl}/favicon.svg`, description: 'Commonly (commonly.me) is a shared workspace platform where humans and AI agents from any origin work together in pods with shared memory, tasks, and messaging.', sameAs: [ 'https://github.com/Team-Commonly/commonly', 'https://www.npmjs.com/package/@commonlyai/mcp', 'https://discord.gg/NsS3fzsJDw', ], }; const website = { '@type': 'WebSite', '@id': websiteId, name: 'Commonly', alternateName: 'Commonly.me', url: `${siteUrl}/`, publisher: { '@id': organizationId }, }; const softwareApplication = { '@type': 'SoftwareApplication', name: 'Commonly AI', alternateName: ['Commonly', 'commonly.me'], url: `${siteUrl}/`, applicationCategory: 'BusinessApplication', operatingSystem: 'Web', description: landing.hero.lede, license: 'https://www.apache.org/licenses/LICENSE-2.0', publisher: { '@id': organizationId }, }; const webPageNode = (title, description, path) => ({ '@type': 'WebPage', '@id': pageUrl(path), name: title, description, url: pageUrl(path), isPartOf: { '@id': websiteId }, }); const webPage = (title, description, path) => ({ '@context': 'https://schema.org', '@graph': [organization, website, webPageNode(title, description, path)], }); const landingPage = { path: '/', outputPath: 'index.html', title: 'Commonly: bring your Claude Code, your teammate\'s Codex and your team into one pod', description: 'Everyone brings their own agent, and the team shares the conversations and the task board. Open source under Apache-2.0, and you can self-host.', content: renderLanding(landing, guides, landingFaq), }; if (landingFaq.length) landingPage.faqSchema = faqPageJsonLd(landingFaq); landingPage.schema = { '@context': 'https://schema.org', '@graph': [organization, website, softwareApplication, webPageNode(landingPage.title, landingPage.description, landingPage.path)], }; const pages = [landingPage, { path: '/compare/', outputPath: 'compare/index.html', title: 'Commonly vs the alternatives | Commonly AI', description: compare.lede, content: renderCompare(compare), schema: webPage(compare.title, compare.lede, '/compare/'), // Unlisted: noindex, kept out of the sitemap, linked from nothing. noindex: true, }]; const guidesIndexPath = '/guides/'; const guidesIndexPage = { path: guidesIndexPath, outputPath: 'guides/index.html', title: 'Guides for teams working with AI agents | Commonly AI', description: 'Practical guides for teams that work with AI agents: shared context, task ownership, human review, and durable handoffs.', content: renderGuidesIndex(guides), staticOnly: true, schema: webPage('Guides for teams working with AI agents', 'Practical guides for teams that work with AI agents: shared context, task ownership, human review, and durable handoffs.', guidesIndexPath), }; const useCasePages = Object.entries(useCases).map(([id, useCase]) => { const path = `/use-cases/${id}/`; return { path, outputPath: `use-cases/${id}/index.html`, title: `${useCase.title} | Commonly AI`, description: useCase.summary, content: renderUseCase(useCase), schema: webPage(useCase.title, useCase.summary, path), }; }); const guidePages = Object.entries(guides).map(([id, guide]) => { const path = `/guides/${id}/`; const provenance = guideProvenance(guide); const author = { '@type': 'Organization', '@id': organizationId, name: provenance.author, url: `${siteUrl}/` }; const reviewer = { '@type': 'Organization', name: provenance.reviewer, url: `${siteUrl}/` }; return { path, outputPath: `guides/${id}/index.html`, title: guide.titleTag, description: guide.description, content: renderGuide(guide), staticOnly: true, ogType: 'article', datePublished: provenance.datePublished, dateModified: provenance.dateModified, schema: { '@context': 'https://schema.org', '@graph': [{ '@type': 'Article', '@id': `${pageUrl(path)}#article`, headline: guide.title, description: guide.description, mainEntityOfPage: { '@id': pageUrl(path) }, author, datePublished: provenance.datePublished, dateModified: provenance.dateModified, publisher: { '@id': organizationId }, }, { '@type': 'WebPage', '@id': pageUrl(path), name: guide.title, description: guide.description, url: pageUrl(path), reviewedBy: reviewer, isPartOf: { '@id': websiteId }, }, organization, website], }, }; }); return pages.concat(useCasePages, [guidesIndexPage], guidePages); }; export const renderStaticPage = (template, page) => { const staticTemplate = page.staticOnly ? staticDocument(template) : template; const withMetadata = replaceMarkedSection(staticTemplate, metadataStart, metadataEnd, metadata(page)); if (!withMetadata.includes(pageContentMarker)) { throw new Error('Missing SEO page-content marker in the Vite output.'); } return withMetadata.replace(pageContentMarker, page.content); }; export const generateSeoPages = async ({ outputDir = buildDir } = {}) => { const [template, translationText, useCaseText, guideText, faqText] = await Promise.all([ readFile(resolve(outputDir, 'index.html'), 'utf8'), readFile(resolve(frontendDir, 'src/i18n/locales/en.json'), 'utf8'), readFile(resolve(frontendDir, 'src/content/use-cases.json'), 'utf8'), readFile(resolve(frontendDir, 'src/content/guides.json'), 'utf8'), readFile(resolve(frontendDir, 'src/content/landing-faq.json'), 'utf8'), ]); const translations = JSON.parse(translationText); const useCases = JSON.parse(useCaseText); const guides = JSON.parse(guideText); const pages = buildPageDefinitions({ landing: translations.landing, compare: translations.compare, useCases, guides, landingFaq: JSON.parse(faqText), }); await Promise.all(pages.map(async (page) => { const outputPath = resolve(outputDir, page.outputPath); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, renderStaticPage(template, page)); })); const sitemap = `\n\n${pages.filter((page) => !page.noindex).map((page) => ` ${pageUrl(page.path)}`).join('\n')}\n\n`; await writeFile(resolve(outputDir, 'sitemap.xml'), sitemap); return pages; }; if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { generateSeoPages().catch((error) => { console.error(error); process.exitCode = 1; }); }