diff --git a/src/blog.ts b/src/blog.ts index f882396..bf49389 100644 --- a/src/blog.ts +++ b/src/blog.ts @@ -96,13 +96,40 @@ export function nextNumber(posts: readonly Pick[]): string { return String(highest + 1).padStart(3, '0'); } +/** + * CrawlProof's cookieless pageview tracker, on every page of the blog. + * + * This is the one deliberate departure from smolweb validity: the guidelines + * forbid scripts served from another host. Nothing on the page depends on it — + * the post reads identically with JavaScript off — so the "usable without + * JavaScript" half of the rule still holds. + */ +export const TRACKER = + ''; + +/** + * The sponsored bar that runs at the foot of every page. + * + * `text_link` on purpose, not a 728x90 or 300x250: it is a 40px full-width + * strip that carries its own "Sponsored" mark inside the frame, so `ad.js` + * prepends no extra caption, and an unsold or blocked slot collapses to + * nothing instead of leaving a banner-shaped hole. + */ +export const AD_UNIT = [ + '', + '', + TRACKER, + '', +].join('\n'); + /** * Render a post file. * * Deliberately smolweb-valid, which is stricter than "valid HTML": an explicit * ``, `` and ``; `` * rather than a bare ``, because every `` needs a `content` - * attribute; and every `

` closed. + * attribute; and every `

` closed. The one exception is {@link TRACKER}, the + * external analytics tag, which smolweb's no-third-party-script rule forbids. */ export function renderPost({ title, description, date, body = '' }: NewPost): string { const day = date.slice(0, 10); @@ -142,6 +169,8 @@ ${content} +${AD_UNIT} + `; diff --git a/test/blog.test.ts b/test/blog.test.ts index 9eb366c..8307e30 100644 --- a/test/blog.test.ts +++ b/test/blog.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { + AD_UNIT, blogDir, createPost, DEFAULT_DIR, @@ -14,6 +15,7 @@ import { nextNumber, readPosts, renderPost, + TRACKER, typogrify, type Post, } from '../src/blog.ts'; @@ -105,6 +107,20 @@ describe('renderPost', () => { expect(html).toContain('How this was written:'); }); + it('carries the CrawlProof tracker and ad unit, last thing before ', () => { + expect(html).toContain(TRACKER); + expect(html).toContain('data-cp-ad'); + // Placement matters: the tags are async, but keeping them after the + // article means nothing about the post waits on a third-party host. + expect(html.indexOf(AD_UNIT)).toBeGreaterThan(html.indexOf('')); + expect(html.indexOf(AD_UNIT)).toBeLessThan(html.indexOf('')); + }); + + it('runs the thin text_link bar, not a banner', () => { + expect(html).toContain('data-format="text_link"'); + expect(html).not.toMatch(/banner_\d+x\d+/); + }); + it('escapes a description that tries to break out of its attribute', () => { const hostile = renderPost({ title: 'x',