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
41 changes: 41 additions & 0 deletions apps/web/public/static/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,47 @@ a:hover {
margin-top: 2.2rem;
}

/* ------------------------------------------------------------------- ads */

/* One sponsored line, sold through CrawlProof. Sized here rather than by an ad
script, so it occupies the same space before and after it loads and moves
nothing underneath it.

An unsold slot answers with a blank document, and nothing on this side can
see that across an origin — so the unit is styled as a strip of whitespace
with no border, no ground and no label of its own. Empty, it reads as the gap
between two sections, which is where it was put. */
.ad-unit {
display: block;
margin: 2.6rem 0;
}
.ad-unit iframe {
display: block;
border: 0;
max-width: 100%;
}
/* The fluid format: the creative lays itself out at whatever width it is given
and drops its second line on a narrow one. */
.ad-unit[data-ad-format="text_link"] iframe {
width: 100%;
height: 40px;
}
/* The banner creatives are laid out at a fixed pixel width inside the frame, so
a narrowed frame would crop one rather than reflow it. They keep their own
width and centre instead. */
.ad-unit:not([data-ad-format="text_link"]) iframe {
margin-inline: auto;
}
.ad-label {
display: block;
margin-bottom: 0.3rem;
font-family: var(--mono);
font-size: 0.6rem;
letter-spacing: 0.17em;
text-transform: uppercase;
color: var(--text-faint);
}

/* --------------------------------------------------------------- test page */

/* Test pages run as a rack: the instrument list stays put, the bench changes. */
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ app.use('*', async (c, next) => {

/**
* The analytics script, when one is configured, is the only third-party origin
* the policy below has to make room for.
* the policy below has to make room for in `script-src`. The ad frame is the
* other third party, and it needs no script source at all.
*/
const analyticsOrigin = (() => {
if (!config.analytics.src) return null;
Expand Down Expand Up @@ -71,6 +72,20 @@ const CSP = [
`connect-src 'self'${analyticsOrigin ? ` ${analyticsOrigin}` : ''}`,
"worker-src 'self'",
"manifest-src 'self'",
/**
* Advertising costs exactly one directive, and deliberately so. The ad is a
* plain cross-origin document in an iframe, which carries its own policy, so
* nothing else here has to move. The vendor's own snippet would have needed
* `script-src` for its tag, `connect-src` for the fetch behind it, and then —
* because it injects the creative as `srcdoc`, and a srcdoc document inherits
* the embedder's policy — `'unsafe-inline'` in `style-src` plus a wide-open
* `img-src` for every page on the site. See AdUnit.jsx.
*
* Omitted rather than set to 'none' when there is no slot: `default-src`
* already keeps frames to this origin, and a bare 'none' would be a rule
* about something nothing on the site does.
*/
...(config.ads.slot ? [`frame-src ${config.ads.origin}`] : []),
].join('; ');

/**
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/components/AdUnit.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { config } from '@d3vices/config';

/**
* One CrawlProof ad unit, embedded as a script-free cross-origin iframe.
*
* The generated install snippet loads `crawlproof.com/ad.js`, which fills each
* placeholder with a `srcdoc` iframe. A srcdoc document inherits the embedding
* page's Content-Security-Policy, so the creative's own `<style>` block and its
* remote images would render only if this site added `'unsafe-inline'` to
* `style-src` and opened `img-src` to the web — on every page, for an ad. The
* policy in app.js is a large part of what this site is; it is not worth that.
*
* `/api/ads/frame` returns the same creative as a real cross-origin document,
* which carries its own policy. This end needs one `frame-src` entry, runs no
* third-party JavaScript, sets no cookie, and — unlike the script tag, which
* mints a `crawlproof.visitor` id — writes nothing into this origin's local
* storage. The privacy page can go on saying what it says.
*
* The cost of having no script is that nothing here can measure the viewport,
* and every banner creative is laid out at a fixed pixel width inside the frame
* (a narrowed iframe clips it rather than reflowing it). `text_link` is the one
* format built to fill its container, so it is the default and the only one
* that belongs in a column whose width is not known in advance.
*/
const SIZES = {
banner_300x250: [300, 250],
banner_728x90: [728, 90],
banner_320x50: [320, 50],
// Fluid: the creative fills whatever width the frame is given.
text_link: [null, 40],
};

export function AdUnit({ format = 'text_link' }) {
const { origin, slot } = config.ads;
// No slot configured — the desktop export, and any checkout that has not set
// one — renders nothing at all rather than an empty box.
if (!slot) return null;
const size = SIZES[format];
if (!size) return null;
const [width, height] = size;
const src = `${origin}/api/ads/frame?slot=${encodeURIComponent(slot)}&format=${encodeURIComponent(format)}`;

return (
<aside class="ad-unit" data-ad-format={format}>
{/* The text link carries its own "Sponsored" mark inside the frame. The
banner creatives do not, so those get a label out here. */}
{format === 'text_link' ? null : <span class="ad-label">Advertisement</span>}
{/* No allow-scripts and no allow-same-origin: the creative is static
HTML. The popup permissions are what let its click-through open a new
tab, which is the only thing it is allowed to do. */}
<iframe
src={src}
title="Advertisement"
width={width ?? undefined}
height={height}
loading="lazy"
scrolling="no"
sandbox="allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation"
/>
</aside>
);
}
17 changes: 15 additions & 2 deletions apps/web/src/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,21 @@
*/
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { TESTS } from '@d3vices/tests/registry';
import app from './app.js';

// The desktop app ships this output and makes no network request unless you run
// the network test — a promise the privacy page makes on its behalf. An ad frame
// is a network request, so advertising is switched off here rather than left to
// whoever runs the build.
//
// Everything that could reach the config is imported after the assignment, and
// dynamically: a static import is evaluated before the first statement in this
// file, so the config would have read the variable as it was and the desktop
// build would have quietly shipped ads.
process.env.ADS_SLOT = '';
const [{ TESTS }, { default: app }] = await Promise.all([
import('@d3vices/tests/registry'),
import('./app.js'),
]);

const outDir = process.argv[2] || join(import.meta.dir, '../../../dist/site');

Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/pages/Home.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { buildDate, config } from '@d3vices/config';
import { GROUPS, TESTS, testsInGroup } from '@d3vices/tests/registry';
import { AdUnit } from '../components/AdUnit.jsx';
import { Layout } from '../components/Layout.jsx';

/** Questions about the site rather than about one instrument. */
Expand Down Expand Up @@ -179,6 +180,12 @@ export function Home() {
))}
</section>

{/* After the rack, not before it. Whoever came here to test something has
already been handed the whole list; this sits in the seam between the
instruments and the copy about them, where a section break was going
to be anyway. */}
<AdUnit />

<section class="section section-split">
<div>
<h2>Why another device tester?</h2>
Expand Down
26 changes: 23 additions & 3 deletions apps/web/src/pages/Privacy.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,30 @@ export function Privacy() {

<h2>Third parties</h2>
<p>
{config.analytics.src
? 'A privacy-preserving, cookie-free analytics endpoint records page views in aggregate. It sets no cookie and does not fingerprint you.'
: 'None. No analytics, no advertising, no fonts or scripts loaded from anyone else’s server.'}
The two typefaces are loaded from Google Fonts, so fetching them tells Google that a browser at your
address asked for a font file. Nothing else about you goes with it.
</p>
{config.analytics.src ? (
<p>
A privacy-preserving, cookie-free analytics endpoint records page views in aggregate. It sets no
cookie and does not fingerprint you.
</p>
) : null}
{config.ads.slot ? (
<p>
There is one advertisement on some pages, sold through CrawlProof, and it is what pays for the
site. It is embedded the quiet way: a plain frame containing a page from their server, rather than
the usual advertising script. No third-party code runs on this page, no cookie is set, and nothing
is written to the local storage described above — an ad script would have put a permanent visitor
id there, and that is precisely why there is no ad script. Loading the frame tells CrawlProof what
any request tells a server: your IP address, your browser’s user agent, and which page the ad
appeared on. Which ad you get is chosen from that and nothing else. No reading any test takes is
available to it, or to anyone.
</p>
) : null}
{!config.analytics.src && !config.ads.slot ? (
<p>Nothing further. No analytics and no advertising.</p>
) : null}

<h2>The desktop app</h2>
<p>
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/pages/TestPage.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { config } from '@d3vices/config';
import { API_DOCS } from '@d3vices/tests/api-docs';
import { TESTS, testsInGroup } from '@d3vices/tests/registry';
import { AdUnit } from '../components/AdUnit.jsx';
import { Layout } from '../components/Layout.jsx';
import { RackNav, rackCode } from '../components/RackNav.jsx';

Expand Down Expand Up @@ -123,6 +124,13 @@ export function TestPage({ test }) {
</section>
) : null}

{/* Below the instrument, its permissions and its FAQ: the reading is
done and the next thing on the page is a list of links away, so a
sponsored line here interrupts nothing. Never above the test — a
reader who came to find out whether their microphone works should
reach it without passing an ad. */}
<AdUnit />

<section class="related">
<h2>Nearby instruments</h2>
<div class="card-grid">
Expand Down
13 changes: 13 additions & 0 deletions packages/config/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ export const config = {
src: env('ANALYTICS_SRC', ''),
siteId: env('ANALYTICS_SITE_ID', ''),
},
/**
* The CrawlProof slot this site sells its ad inventory through. It is a
* public identifier, not a secret — it ships in the markup — so it is written
* down here rather than left to a variable somebody has to remember to set,
* which would make merging this a silent no-op.
*
* Set `ADS_SLOT=` (empty) to turn advertising off. The static export does
* exactly that, because the desktop app must make no network request.
*/
ads: {
origin: 'https://crawlproof.com',
slot: env('ADS_SLOT', 'f54a21ad-3db6-4a62-a9f4-93d505c221c9'),
},
};

export const siteName = 'd3vices';
Expand Down
Loading