Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 157 additions & 1 deletion web/scripts/__tests__/static-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
rmSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { generateStaticPages } from '../static-pages';
import { generateStaticPages, generateAtomFeed } from '../static-pages';
import type { ActivityData } from '../../shared/types';

const TEST_OUT = resolve(__dirname, '__test-output-static-pages__');
Expand Down Expand Up @@ -1375,3 +1375,159 @@ describe('generateStaticPages', () => {
}
});
});

describe('generateAtomFeed', () => {
it('generates a valid Atom 1.0 envelope', () => {
const feed = generateAtomFeed([], '2026-03-04T00:00:00Z');
expect(feed).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(feed).toContain('<feed xmlns="http://www.w3.org/2005/Atom">');
expect(feed).toContain('<title>Colony Governance Feed</title>');
expect(feed).toContain('<updated>2026-03-04T00:00:00Z</updated>');
expect(feed).toContain('</feed>');
});

it('includes an entry for each proposal with required Atom fields', () => {
const proposals = [
{
number: 42,
title: 'Add dark mode',
phase: 'implemented' as const,
author: 'hivemoot-builder',
createdAt: '2026-02-01T12:00:00Z',
commentCount: 3,
body: 'Dark mode improves readability.',
},
];
const feed = generateAtomFeed(proposals, '2026-03-04T00:00:00Z');
expect(feed).toContain('<entry>');
expect(feed).toContain('<title>Add dark mode</title>');
expect(feed).toContain('/proposal/42/');
expect(feed).toContain('<published>2026-02-01T12:00:00Z</published>');
expect(feed).toContain('<name>hivemoot-builder</name>');
expect(feed).toContain('<category term="implemented"/>');
expect(feed).toContain('Dark mode improves readability');
});

it('orders entries newest first', () => {
const proposals = [
{
number: 1,
title: 'Old',
phase: 'implemented' as const,
author: 'a',
createdAt: '2026-01-01T00:00:00Z',
commentCount: 0,
},
{
number: 2,
title: 'New',
phase: 'discussion' as const,
author: 'b',
createdAt: '2026-03-01T00:00:00Z',
commentCount: 0,
},
];
const feed = generateAtomFeed(proposals, '2026-03-04T00:00:00Z');
const pos1 = feed.indexOf('/proposal/1/');
const pos2 = feed.indexOf('/proposal/2/');
// Newer proposal (#2) appears before older (#1)
expect(pos2).toBeLessThan(pos1);
});

it('XML-escapes < > & in proposal titles and authors', () => {
const proposals = [
{
number: 7,
title: 'Fix <script> injection & other issues',
phase: 'discussion' as const,
author: 'user&admin',
createdAt: '2026-02-01T00:00:00Z',
commentCount: 0,
},
];
const feed = generateAtomFeed(proposals, '2026-03-04T00:00:00Z');
expect(feed).toContain('Fix &lt;script&gt; injection &amp; other issues');
expect(feed).toContain('user&amp;admin');
expect(feed).not.toContain('<script>');
expect(feed).not.toContain('user&admin');
});

it('uses deployment-aware BASE_URL for feed and entry URLs', () => {
const feed = generateAtomFeed([], '2026-03-04T00:00:00Z');
// Default BASE_URL contains hivemoot.github.io/colony
expect(feed).toContain('hivemoot.github.io/colony/feed.xml');
expect(feed).toContain('hivemoot.github.io/colony/proposals/');
});

it('limits feed to 50 most recent proposals', () => {
const proposals = Array.from({ length: 60 }, (_, i) => ({
number: i + 1,
title: `Proposal ${i + 1}`,
phase: 'discussion' as const,
author: 'a',
createdAt: new Date(2026, 0, i + 1).toISOString(),
commentCount: 0,
}));
const feed = generateAtomFeed(proposals, '2026-03-04T00:00:00Z');
const entryCount = (feed.match(/<entry>/g) ?? []).length;
expect(entryCount).toBe(50);
// Most recent 50 (proposals 11-60) should be present
expect(feed).toContain('/proposal/60/');
expect(feed).toContain('/proposal/11/');
// Oldest 10 (proposals 1-10) should be excluded
expect(feed).not.toContain('/proposal/10/');
expect(feed).not.toContain('/proposal/1/');
});

it('generates feed.xml in output directory when generateStaticPages runs', () => {
const data = minimalActivityData({
proposals: [
{
number: 1,
title: 'Test proposal',
phase: 'discussion' as const,
author: 'tester',
createdAt: '2026-02-01T00:00:00Z',
commentCount: 0,
},
],
});
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);
generateStaticPages(TEST_OUT);
expect(existsSync(join(TEST_OUT, 'feed.xml'))).toBe(true);
const feed = readFileSync(join(TEST_OUT, 'feed.xml'), 'utf-8');
expect(feed).toContain('<feed xmlns="http://www.w3.org/2005/Atom">');
expect(feed).toContain('Test proposal');
});

it('includes feed.xml in sitemap', () => {
const data = minimalActivityData();
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);
generateStaticPages(TEST_OUT);
const sitemap = readFileSync(join(TEST_OUT, 'sitemap.xml'), 'utf-8');
expect(sitemap).toContain(
'<loc>https://hivemoot.github.io/colony/feed.xml</loc>'
);
});

it('includes Atom feed auto-discovery link in proposals index', () => {
const data = minimalActivityData();
writeFileSync(
join(TEST_OUT, 'data', 'activity.json'),
JSON.stringify(data)
);
generateStaticPages(TEST_OUT);
const html = readFileSync(
join(TEST_OUT, 'proposals', 'index.html'),
'utf-8'
);
expect(html).toContain('application/atom+xml');
expect(html).toContain('feed.xml');
});
});
79 changes: 77 additions & 2 deletions web/scripts/static-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ interface PageMeta {
description: string;
canonicalPath: string;
jsonLd?: object;
/** Optional extra <link> or <meta> tags injected into <head>. */
extraHeadTags?: string;
}

/**
Expand Down Expand Up @@ -207,7 +209,7 @@ function htmlShell(meta: PageMeta, content: string): string {
<meta name="description" content="${escapeHtml(meta.description)}" />
<link rel="canonical" href="${escapeHtml(fullUrl)}" />
<link rel="icon" href="${basePath()}favicon.ico" sizes="any" />
<link rel="apple-touch-icon" sizes="180x180" href="${basePath()}apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="${basePath()}apple-touch-icon.png" />${meta.extraHeadTags ? `\n ${meta.extraHeadTags}` : ''}
<meta property="og:type" content="website" />
<meta property="og:url" content="${escapeHtml(fullUrl)}" />
<meta property="og:title" content="${escapeHtml(meta.title)}" />
Expand Down Expand Up @@ -535,6 +537,7 @@ function proposalsIndexPage(proposals: Proposal[]): string {
title: 'Colony Governance Proposals | Colony',
description: `All ${proposals.length} governance proposals from Colony — an autonomous agent-governed open-source project.`,
canonicalPath: '/proposals/',
extraHeadTags: `<link rel="alternate" type="application/atom+xml" title="Colony Governance Feed" href="${escapeHtml(BASE_URL)}/feed.xml" />`,
};

// Sort by proposal number descending (most recent first)
Expand Down Expand Up @@ -614,6 +617,12 @@ function generateSitemap(
<lastmod>${lastmod}</lastmod>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>${BASE_URL}/feed.xml</loc>
<lastmod>${lastmod}</lastmod>
<changefreq>daily</changefreq>
<priority>0.5</priority>
</url>`;

for (const p of proposals) {
Expand Down Expand Up @@ -643,6 +652,68 @@ ${urls}
`;
}

/**
* Escape a string for safe embedding in XML content or attribute values.
* Mirrors the subset of escapeHtml relevant for XML: &, <, >, and ".
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

/**
* Generate an Atom 1.0 feed for the 50 most recent governance proposals.
* Entries are ordered newest first. All user-supplied strings are XML-escaped.
* Feed and entry URLs use BASE_URL so template deployments produce correct links.
*/
export function generateAtomFeed(
proposals: Proposal[],
generatedAt: string
): string {
const feedUrl = `${BASE_URL}/feed.xml`;
const hubUrl = `${BASE_URL}/proposals/`;

const recent = [...proposals]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
.slice(0, 50);

const entries = recent
.map((p) => {
const entryUrl = `${BASE_URL}/proposal/${p.number}/`;
const summary = escapeXml(bodyExcerpt(p.body));
return ` <entry>
<id>${escapeXml(entryUrl)}</id>
<title>${escapeXml(p.title)}</title>
<link href="${escapeXml(entryUrl)}"/>
<published>${p.createdAt}</published>
<updated>${p.createdAt}</updated>
<author><name>${escapeXml(p.author)}</name></author>
<category term="${escapeXml(p.phase)}"/>
${summary ? `<summary>${summary}</summary>` : ''}
</entry>`;
})
.join('\n');

return `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>${escapeXml(feedUrl)}</id>
<title>Colony Governance Feed</title>
<link rel="self" href="${escapeXml(feedUrl)}"/>
<link rel="alternate" href="${escapeXml(hubUrl)}"/>
<updated>${generatedAt}</updated>
<author><name>Hivemoot Colony</name></author>
<rights>Apache 2.0</rights>
${entries}
</feed>
`;
}

/**
* Generate static HTML pages for proposals and agents.
* Called from the Vite staticPageGenerator plugin.
Expand Down Expand Up @@ -706,7 +777,11 @@ export function generateStaticPages(outDir: string): void {
const robotsTxt = `User-agent: *\nAllow: /\n\nSitemap: ${BASE_URL}/sitemap.xml\n`;
writeFileSync(join(outDir, 'robots.txt'), robotsTxt);

// Generate Atom feed for the 50 most recent governance proposals.
const atomFeed = generateAtomFeed(data.proposals, data.generatedAt);
writeFileSync(join(outDir, 'feed.xml'), atomFeed);

console.log(
`[static-pages] Generated ${proposalCount} proposal pages, ${agentCount} agent pages, proposals index, agents index, sitemap.xml, and robots.txt`
`[static-pages] Generated ${proposalCount} proposal pages, ${agentCount} agent pages, proposals index, agents index, sitemap.xml, robots.txt, and feed.xml`
);
}
Loading