diff --git a/web/scripts/__tests__/static-pages.test.ts b/web/scripts/__tests__/static-pages.test.ts
index a3a1deb7..5b62f9be 100644
--- a/web/scripts/__tests__/static-pages.test.ts
+++ b/web/scripts/__tests__/static-pages.test.ts
@@ -987,6 +987,143 @@ describe('generateStaticPages', () => {
expect(bodySection).not.toMatch(/
]*>[^<]*
{
+ const data = minimalActivityData({
+ proposals: [
+ {
+ number: 80,
+ title: 'Test JSON-LD proposal',
+ phase: 'voting',
+ author: 'hivemoot-forager',
+ createdAt: '2026-02-21T10:00:00Z',
+ commentCount: 7,
+ },
+ ],
+ });
+ writeFileSync(
+ join(TEST_OUT, 'data', 'activity.json'),
+ JSON.stringify(data)
+ );
+
+ generateStaticPages(TEST_OUT);
+
+ const html = readFileSync(
+ join(TEST_OUT, 'proposal', '80', 'index.html'),
+ 'utf-8'
+ );
+ expect(html).toContain('application/ld+json');
+ expect(html).toContain('DiscussionForumPosting');
+ expect(html).toContain('https://schema.org');
+ expect(html).toContain('hivemoot-forager');
+ expect(html).toContain('"commentCount":7');
+ expect(html).toContain('"datePublished":"2026-02-21T10:00:00Z"');
+ });
+
+ it('includes ProfilePage JSON-LD in agent pages', () => {
+ const data = minimalActivityData({
+ agentStats: [
+ {
+ login: 'hivemoot-builder',
+ commits: 10,
+ pullRequestsMerged: 5,
+ issuesOpened: 3,
+ reviews: 8,
+ comments: 12,
+ lastActiveAt: '2026-02-14T00:00:00Z',
+ },
+ ],
+ });
+ writeFileSync(
+ join(TEST_OUT, 'data', 'activity.json'),
+ JSON.stringify(data)
+ );
+
+ generateStaticPages(TEST_OUT);
+
+ const html = readFileSync(
+ join(TEST_OUT, 'agent', 'hivemoot-builder', 'index.html'),
+ 'utf-8'
+ );
+ expect(html).toContain('application/ld+json');
+ expect(html).toContain('ProfilePage');
+ expect(html).toContain('https://schema.org');
+ expect(html).toContain('hivemoot-builder');
+ });
+
+ it('unicode-escapes < > & in JSON-LD to prevent script injection', () => {
+ const data = minimalActivityData({
+ proposals: [
+ {
+ number: 81,
+ title: 'A & B "proposal"',
+ phase: 'discussion',
+ author: 'agent',
+ createdAt: '2026-02-21T00:00:00Z',
+ commentCount: 0,
+ },
+ ],
+ });
+ writeFileSync(
+ join(TEST_OUT, 'data', 'activity.json'),
+ JSON.stringify(data)
+ );
+
+ generateStaticPages(TEST_OUT);
+
+ const html = readFileSync(
+ join(TEST_OUT, 'proposal', '81', 'index.html'),
+ 'utf-8'
+ );
+ // The JSON-LD script block must not contain raw < > & inside string values
+ // Extract the JSON-LD script content
+ const ldMatch = html.match(
+ /');
+ // Angle brackets and ampersands are unicode-escaped
+ expect(ldContent).toContain('\\u003c');
+ expect(ldContent).toContain('\\u003e');
+ expect(ldContent).toContain('\\u0026');
+ });
+
+ it('percent-encodes bot login names in JSON-LD author URLs', () => {
+ const data = minimalActivityData({
+ proposals: [
+ {
+ number: 82,
+ title: 'Bot authored proposal',
+ phase: 'discussion',
+ author: 'hivemoot[bot]',
+ createdAt: '2026-02-21T00:00:00Z',
+ commentCount: 0,
+ },
+ ],
+ });
+ writeFileSync(
+ join(TEST_OUT, 'data', 'activity.json'),
+ JSON.stringify(data)
+ );
+
+ generateStaticPages(TEST_OUT);
+
+ const html = readFileSync(
+ join(TEST_OUT, 'proposal', '82', 'index.html'),
+ 'utf-8'
+ );
+ // Extract the JSON-LD script block and verify the author URL is encoded
+ const ldMatch = html.match(
+ /` sequence or an entity that
+ * could confuse surrounding markup. This is the pattern recommended by
+ * Google's Search Central documentation.
+ */
+function jsonLdTag(data: object): string {
+ const json = JSON.stringify(data).replace(/[<>&]/g, (c) => {
+ if (c === '<') return '\\u003c';
+ if (c === '>') return '\\u003e';
+ return '\\u0026';
+ });
+ return ``;
}
// -- Phase display helpers --
@@ -199,6 +218,7 @@ function htmlShell(meta: PageMeta, content: string): string {
+ ${meta.jsonLd ? jsonLdTag(meta.jsonLd) : ''}