Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,11 @@ S3_PUBLIC_URL=

# PORTAL_DESIGN_ENGINE=auto

# A website reader that renders a page in a headless browser (docker/browser).
# Optional: without it, websites are read from their HTML and CSS.
# QUACKBACK_BROWSER_URL=http://localhost:3200
# QUACKBACK_BROWSER_TOKEN=

# Cloud only; leave unset when self-hosting.
# QUACKBACK_CONTROL_PLANE_URL=https://app.quackback.io
# INTEGRATION_OAUTH_GATEWAY_URL=https://app.quackback.io
Expand Down
6 changes: 6 additions & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,9 @@ EMAIL_FROM=Quackback <noreply@example.com>
# Enables summaries, duplicate detection, feedback extraction, etc.
# OPENAI_API_KEY=
# OPENAI_BASE_URL=

# --- Website reader (optional) ------------------------------------------
# Renders a website in a headless browser so Quackback AI reads its exact
# type, colours and buttons. Start it with `--profile browser`.
# QUACKBACK_BROWSER_URL=http://browser:3200
# QUACKBACK_BROWSER_TOKEN= # openssl rand -hex 24
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@duckduckgo/autoconsent": "17.2.0",
"@floating-ui/dom": "^1.7.6",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource/barlow-condensed": "^5.3.0",
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/lib/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ const configSchema = z
// Quackback AI may write a portal design's CSS as well as its fields. Off
// until it is shown to design better with it.
portalDesignAiCss: envBoolean,
// A website reader beside Quackback that renders a page in a headless
// browser. Optional: without it, websites are read from their HTML and CSS.
browserUrl: z.string().url().optional(),
browserToken: z.string().min(16).optional(),

// Database
//
Expand Down Expand Up @@ -379,6 +383,8 @@ function buildConfigFromEnv(): unknown {
port: env('PORT'),
portalDesignEngine: env('PORTAL_DESIGN_ENGINE'),
portalDesignAiCss: env('PORTAL_DESIGN_AI_CSS'),
browserUrl: env('QUACKBACK_BROWSER_URL'),
browserToken: env('QUACKBACK_BROWSER_TOKEN'),

// Database
databaseUrl: env('DATABASE_URL'),
Expand Down Expand Up @@ -578,6 +584,12 @@ export const config = {
get portalDesignAiCss() {
return loadConfig().portalDesignAiCss
},
get browserUrl() {
return loadConfig().browserUrl
},
get browserToken() {
return loadConfig().browserToken
},
get controlDatabaseUrl() {
return loadConfig().controlDatabaseUrl
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,17 @@ describe('pageTree', () => {
['p', true],
])
})

it('never leaves a script behind when its tags are split by another', () => {
const nodes = pageTree(
`<div><scr<script></script>ipt>alert(1)</script><a href="/">Go</a></div>`
)
expect(nodes.map((n) => n.tag)).not.toContain('script')
expect(nodes.find((n) => n.tag === 'a')?.text).toBe('Go')
})

it('reads a script left open as running to the end of the page', () => {
const nodes = pageTree(`<div><a href="/">Real</a><script>var x = '<a href="/">Fake</a>'</div>`)
expect(nodes.filter((n) => n.tag === 'a').map((n) => n.text)).toEqual(['Real'])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { PageMeasure } from '../../render/page-scripts'
import type { SiteSignals } from '../site-signals'

const settings = vi.hoisted(() => ({
browserUrl: undefined as string | undefined,
browserToken: undefined as string | undefined,
}))
vi.mock('@/lib/server/config', () => ({ config: settings }))

const { fromPage, lookPalette, renderSite, renderedSignals, withRendered } =
await import('../site-render')

const page: PageMeasure = {
title: 'Brand',
page: 'rgb(255, 255, 255)',
headline: {
family: '"Display Sans", Inter, sans-serif',
size: 103.5,
weight: 800,
tracking: 0,
leading: 0.85,
transform: 'none',
colour: 'rgb(22, 51, 0)',
},
heading: null,
body: {
family: 'Inter, sans-serif',
size: 16,
weight: 400,
tracking: 0,
leading: 1.5,
transform: 'none',
colour: 'rgb(14, 15, 12)',
},
button: {
fill: 'rgb(159, 232, 112)',
ink: 'rgb(22, 51, 0)',
radius: 999,
height: 48,
borderWidth: 0,
shadow: false,
weight: 600,
transform: 'none',
text: 'Send money',
},
action: true,
card: null,
field: null,
palette: [
{ colour: 'rgb(255, 255, 255)', share: 0.6 },
{ colour: 'rgb(159, 232, 112)', share: 0.3 },
{ colour: 'rgba(0, 0, 0, 0.4)', share: 0.1 },
],
hero: 'rgb(159, 232, 112)',
}

const staticSite: SiteSignals = {
domain: 'brand.example.com',
title: null,
description: 'A brand',
colour: '#37517e',
palette: ['#37517e', '#163300'],
background: '#f7f7f7',
fonts: { heading: 'Inter', body: 'Inter' },
dark: false,
button: '#37517e',
bold: false,
monochrome: false,
hero: null,
logo: null,
shareImage: null,
measured: { headline: { size: 64 }, card: { radius: 16 } },
}

describe('fromPage', () => {
it('reads the main button, hero and type as the page renders them', () => {
const rendered = fromPage(page)
expect(rendered.measured.button).toMatchObject({
fill: '#9fe870',
ink: '#163300',
radius: 999,
action: true,
})
expect(rendered.measured.headline).toMatchObject({
size: 103.5,
weight: 800,
colour: '#163300',
})
expect(rendered.brand).toMatchObject({
colour: '#9fe870',
hero: '#9fe870',
bold: true,
background: '#ffffff',
dark: false,
})
expect(rendered.brand.fonts.heading).toBe('"Display Sans", Inter, sans-serif')
// A dimming layer is not a colour the page is painted in.
expect(rendered.brand.palette).toEqual(['#9fe870', '#ffffff'])
})
})

describe('fromPage, where the page names no brand colour', () => {
// A page with no call to action and a promotion's panel covering much of it.
const promo: PageMeasure = {
...page,
button: null,
action: false,
hero: null,
palette: [
{ colour: 'rgb(255, 255, 255)', share: 0.5 },
{ colour: 'rgb(255, 219, 0)', share: 0.4 },
],
}

it('leaves the brand colour to the site, keeping a painted panel in the palette', () => {
const rendered = fromPage(promo)
expect(rendered.brand.colour).toBeUndefined()
expect(rendered.brand.palette).toContain('#ffdb00')
const merged = withRendered(
{ ...staticSite, colour: '#0058a3', palette: ['#0058a3', '#ffdb00'] },
{ ...rendered, screenshot: null }
)
expect(merged.colour).toBe('#0058a3')
expect(merged.palette.slice(0, 2)).toEqual(['#0058a3', '#ffdb00'])
})

it('gives the screenshot palette, imagery and all, when the screenshot was read', () => {
const rendered = fromPage(page, ['#ffffff', '#ff7a59', '#b07cff'])
// The main call to action leads; the screenshot's colours follow in its order.
expect(rendered.brand.palette).toEqual(['#9fe870', '#ffffff', '#ff7a59', '#b07cff'])
})
})

describe('lookPalette', () => {
it('leads with the site brand colour before the screen, whose first colour can be a photo', () => {
const rendered = {
...fromPage({ ...page, button: null, action: false, hero: null }, ['#7c322c', '#ffdb00']),
screenshot: null,
}
expect(
lookPalette({ ...staticSite, colour: '#0058a3', palette: ['#0058a3', '#ffdb00'] }, rendered)
).toEqual(['#0058a3', '#7c322c', '#ffdb00'])
expect(lookPalette(null, rendered)).toEqual(['#7c322c', '#ffdb00'])
})

it("keeps the site's second colour when the first screen hardly shows it", () => {
const rendered = {
...fromPage({ ...page, button: null, action: false, hero: null }, [
'#ffffff',
'#112231',
'#d8d4cf',
]),
screenshot: null,
}
const site = { ...staticSite, colour: '#112231', palette: ['#112231', '#ff4f40', '#218fb7'] }
expect(lookPalette(site, rendered)).toContain('#ff4f40')
expect(lookPalette(site, rendered)[0]).toBe('#112231')
})
})

describe('withRendered', () => {
it('lets the render lead the CSS read, keeping what only the CSS read found', () => {
const merged = withRendered(staticSite, { ...fromPage(page), screenshot: null })
expect(merged.colour).toBe('#9fe870')
expect(merged.palette[0]).toBe('#9fe870')
expect(merged.palette).toContain('#163300')
expect(merged.description).toBe('A brand')
expect(merged.measured?.headline?.size).toBe(103.5)
// The render found no card, so the CSS read's stands.
expect(merged.measured?.card).toEqual({ radius: 16 })
})

it('stands alone when the page HTML could not be read', () => {
const alone = renderedSignals('brand.example.com', { ...fromPage(page), screenshot: null })
expect(alone).toMatchObject({ domain: 'brand.example.com', colour: '#9fe870', title: 'Brand' })
})
})

describe('renderSite', () => {
afterEach(() => {
vi.unstubAllGlobals()
settings.browserUrl = undefined
settings.browserToken = undefined
})

it('does nothing without a reader set up', async () => {
const fetch = vi.fn()
vi.stubGlobal('fetch', fetch)
expect(await renderSite('brand.example.com')).toBeNull()
expect(fetch).not.toHaveBeenCalled()
})

it('asks the reader with its token, once more if it is waking', async () => {
settings.browserUrl = 'http://reader.internal:3200'
settings.browserToken = 'a-long-enough-token'
const fetch = vi
.fn()
.mockResolvedValueOnce(new Response('waking', { status: 502 }))
.mockResolvedValueOnce(
Response.json({
ok: true,
url: 'https://brand.example.com/',
measure: page,
screenshot: 'data:image/jpeg;base64,AA==',
refused: [],
})
)
vi.stubGlobal('fetch', fetch)
const rendered = await renderSite('brand.example.com')
expect(rendered?.brand.colour).toBe('#9fe870')
expect(rendered?.screenshot).toBe('data:image/jpeg;base64,AA==')
expect(fetch).toHaveBeenCalledTimes(2)
const [url, init] = fetch.mock.calls[0]!
expect(String(url)).toBe('http://reader.internal:3200/read')
expect(init.headers.authorization).toBe('Bearer a-long-enough-token')
})

it('gives nothing back when the reader cannot read the site', async () => {
settings.browserUrl = 'http://reader.internal:3200'
settings.browserToken = 'a-long-enough-token'
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(Response.json({ ok: false, reason: 'robots' }))
)
expect(await renderSite('brand.example.com')).toBeNull()
})
})
23 changes: 22 additions & 1 deletion apps/web/src/lib/server/domains/design/ai/page-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,29 @@ const VOID = new Set([
const WORDED = new Set(['a', 'button'])
const LIMIT = 8000

const BLOCK = /<(script|style|svg|noscript|template)\b[\s\S]*?<\/\1\s*>/gi
const BLOCK_OPEN = /<(script|style|svg|noscript|template)\b/i
const BLOCK_CLOSE = /<\/(script|style|svg|noscript|template)\b/gi

/**
* The page without its scripts, styles and drawings. Removal repeats until
* nothing changes, since taking one block out can join the pieces of
* another. A block left open runs to the end of the page, as a browser
* reads it, and a stray closing tag is blanked.
*/
function withoutBlocks(html: string): string {
let body = html
for (let pass = 0; pass < 8; pass++) {
const next = body.replace(BLOCK, ' ')
if (next === body) break
body = next
}
const open = BLOCK_OPEN.exec(body)
return (open ? body.slice(0, open.index) : body).replace(BLOCK_CLOSE, ' ')
}

export function pageTree(html: string): PageNode[] {
const body = html.replace(/<(script|style|svg|noscript|template)\b[\s\S]*?<\/\1>/gi, ' ')
const body = withoutBlocks(html)
const nodes: PageNode[] = []
const open: number[] = []
const starts = new Map<number, number>()
Expand Down
Loading
Loading