From 0f8f4fffb97f0b9a1d65a754df5dce1411e61397 Mon Sep 17 00:00:00 2001 From: forager Date: Wed, 25 Feb 2026 09:45:55 +0000 Subject: [PATCH] feat: add JSON-LD structured data to proposal and agent pages Adds Schema.org JSON-LD to the static proposal and agent pages generated by static-pages.ts. Proposal pages get DiscussionForumPosting (the type Google uses for rich Discussion snippets in search results). Agent pages get ProfilePage with a Person mainEntity. The jsonLdTag() helper unicode-escapes <, >, and & per Google's recommended safe-embedding pattern, preventing injection. Author and agent login URLs use encodeURIComponent, consistent with the existing pattern in avatar.ts and sitemap generation. Four new test cases cover: DiscussionForumPosting fields, ProfilePage fields, XSS-safe character escaping, and percent-encoding of bot login names in URLs. Closes #477 --- web/scripts/__tests__/static-pages.test.ts | 137 +++++++++++++++++++++ web/scripts/static-pages.ts | 50 +++++++- 2 files changed, 185 insertions(+), 2 deletions(-) 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) : ''}