Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b68b418
site: ask signers which apps they want to see join PDPP
tnunamak Nov 4, 2026
9965d6c
site: say what the register actually stores about affiliation and role
tnunamak Nov 4, 2026
e9c8914
site: keep unreviewed app names out of the count output
tnunamak Nov 4, 2026
0b37bc6
site: accept a confirm retry across the requestedApps deploy
tnunamak Nov 4, 2026
3861235
site: make the apps question plain free text
tnunamak Nov 4, 2026
599035d
site: let supporters name up to ten apps
tnunamak Nov 4, 2026
226a989
site: add a shareable supporter card behind a flag
tnunamak Nov 4, 2026
22492e6
site: use upstream Inter and Source Serif files for the share card
tnunamak Nov 4, 2026
db9e9f7
site: read the share card flag like the other site flags
tnunamak Nov 4, 2026
4063877
site: base the share card on the current site design
tnunamak Nov 4, 2026
a4672a5
site: give signers Sound Bloom style ways to post the card
tnunamak Nov 4, 2026
44774a4
site: lead the X post with the hashtag so the link follows 'Sign it t…
tnunamak Nov 4, 2026
1e2eb27
site: drop nested boxes and the button shape from the share card
tnunamak Nov 4, 2026
9b9aa3a
site: hide the signing form once a signature is pending or confirmed
tnunamak Nov 4, 2026
1dc780a
site: inline the caption copy control as a small text action
tnunamak Nov 4, 2026
96fbe55
site: sit the caption copy control on the text baseline
tnunamak Nov 4, 2026
629771b
site: give the share panel a site-style heading
tnunamak Nov 4, 2026
8ab3775
site: add "Is your app on PDPP?" page at /apps, behind a flag
tnunamak Nov 4, 2026
d47c288
site: read connector labels from underscored source folders, say open…
tnunamak Nov 4, 2026
b07eb7f
site: lint fixes for the connector catalogue sync
tnunamak Nov 4, 2026
5c55a9e
site: name the company only when a register names it
tnunamak Nov 4, 2026
98a92b0
site: show each connector by its data, state company status once
tnunamak Nov 4, 2026
e0343d8
site: link each connector to its source; example a random supported app
tnunamak Nov 4, 2026
1a91f14
site: type the source-folder snapshot test
tnunamak Nov 4, 2026
e1a5e68
site: keep source_path when reading the connector snapshot
tnunamak Nov 4, 2026
576b66d
feat(site): lay out /apps as a full-width card grid
tnunamak Nov 4, 2026
2046acb
feat(site): link /apps from the participate register and the footer
tnunamak Nov 4, 2026
b359c96
feat(site): add Is your app on PDPP? to a Participate menu
tnunamak Nov 4, 2026
fc4db3c
fix(site): open nav dropdowns on hover and focus (group, not md:group)
tnunamak Nov 4, 2026
fd4c775
fix(site): size nav dropdowns to their entries
tnunamak Nov 4, 2026
c1214d9
feat(site): load the connector catalogue live on /apps, with the repo…
tnunamak Nov 4, 2026
dabf822
chore(site): refresh the fallback connector catalogue to 23aee3a
tnunamak Nov 4, 2026
c47f930
docs(site): describe the live catalogue load in /apps comments
tnunamak Nov 4, 2026
4dc18ce
docs: say the register keeps up to ten requested apps
tnunamak Nov 6, 2026
9b16160
site: name the share card flag tests after the value they set
tnunamak Nov 6, 2026
1e2da76
Merge the share card into the supporter apps branch
tnunamak Nov 6, 2026
17877df
Merge the app directory into the supporter apps branch
tnunamak Nov 6, 2026
6cb45fc
site: drop the release condition from the share card flag comment
tnunamak Nov 6, 2026
8b92b1a
site: drop the unused Source Serif bold file from the share card fonts
tnunamak Nov 6, 2026
fb8c96c
site: publish /apps without a flag
tnunamak Nov 6, 2026
26d8347
site: download the share card fonts at build time
tnunamak Nov 6, 2026
249ff94
site: fix two React Doctor warnings
tnunamak Nov 6, 2026
660720d
site: accept a signature with the updates box unticked
tnunamak Nov 6, 2026
c2c3279
site: mark required consent boxes; fix a test that depended on the wo…
tnunamak Nov 6, 2026
66c2c99
site: mark required signing fields with an asterisk
tnunamak Nov 6, 2026
1340309
site: publish the share card without a flag
tnunamak Nov 6, 2026
64cd54e
site: leave the privacy page as it is on main
tnunamak Nov 7, 2026
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
157 changes: 157 additions & 0 deletions apps/site/scripts/count-requested-apps.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env node
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0

// Counts how many signatories named each app they would like to see join PDPP.
// Reads a local checkout of the private register and prints, to stdout:
// {"apps": [{app, count}], "unreviewedAnswers": N}
// `apps` holds only canonical names: the reviewed names below plus the targets
// of the alias map. Anything else a signer typed is free
// text and can be personal data (an email, a name, a phone number), so it is
// counted but never printed. The stdout output is safe to share with a company.
//
// `--review` additionally prints the unreviewed raw values and their counts to
// STDERR, so a maintainer can extend the alias map. That output is private.

import { readSignatories } from "./publish-supporters.mjs";

const WHITESPACE = /\s+/g;

// App names a maintainer has reviewed and may print. The form is free text;
// extend this list (and ALIASES) from what `--review` shows.
const REVIEWED_APP_NAMES = [
"Amazon",
"Apple Health",
"ChatGPT",
"Clue",
"Fitbit",
"Flo",
"Garmin",
"Google",
"Instagram",
"LinkedIn",
"Netflix",
"Oura",
"Reddit",
"Spotify",
"Strava",
"TikTok",
"Uber",
"WhatsApp",
"X",
"YouTube",
];

// Lower-cased, space-collapsed spelling -> the name to count it under.
const ALIASES = new Map([
["insta", "Instagram"],
["ig", "Instagram"],
["chat gpt", "ChatGPT"],
["chatgpt", "ChatGPT"],
["openai", "ChatGPT"],
["open ai", "ChatGPT"],
["apple health kit", "Apple Health"],
["healthkit", "Apple Health"],
["twitter", "X"],
["yt", "YouTube"],
["you tube", "YouTube"],
["whats app", "WhatsApp"],
["tik tok", "TikTok"],
]);

// Folded spelling -> canonical name, for every name that may be printed.
const CANONICAL = new Map([...REVIEWED_APP_NAMES, ...ALIASES.values()].map((name) => [foldName(name), name]));

function foldName(name) {
return String(name).trim().replace(WHITESPACE, " ").toLowerCase();
}

/** The distinct folded spellings one record asks for, with their trimmed raw form. */
function namesInRecord(record) {
const names = new Map();
for (const raw of Array.isArray(record?.requestedApps) ? record.requestedApps : []) {
const key = typeof raw === "string" ? foldName(raw) : "";
if (key && !names.has(key)) {
names.set(key, raw.trim().replace(WHITESPACE, " "));
}
}
return names;
}

function mostCommonSpelling(spellings) {
// A tie goes to the first by code point, which puts a capitalised spelling
// ahead of a lower-case one.
return [...spellings.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1))[0][0];
}

/**
* Counts requested apps across records. One signatory counts once per app,
* however they spelled it.
*
* Returns:
* - apps: [{app, count}] for canonical names only, count descending. Safe to share.
* - unreviewedAnswers: how many answers matched no canonical name. A count only. Safe to share.
* - unreviewed: [{value, count}] the raw unmatched values. PRIVATE: may be personal data.
*/
export function countRequestedApps(records) {
const canonicalCounts = new Map();
const unreviewedGroups = new Map();
for (const record of records) {
const seenCanonical = new Set();
for (const [key, spelling] of namesInRecord(record)) {
const canonical = ALIASES.get(key) ?? CANONICAL.get(key) ?? null;
if (canonical) {
seenCanonical.add(canonical);
continue;
}
const group = unreviewedGroups.get(key) ?? { count: 0, spellings: new Map() };
group.count += 1;
group.spellings.set(spelling, (group.spellings.get(spelling) ?? 0) + 1);
unreviewedGroups.set(key, group);
}
for (const canonical of seenCanonical) {
canonicalCounts.set(canonical, (canonicalCounts.get(canonical) ?? 0) + 1);
}
}
const apps = [...canonicalCounts.entries()]
.map(([app, count]) => ({ app, count }))
.sort((a, b) => b.count - a.count || a.app.localeCompare(b.app));
const unreviewed = [...unreviewedGroups.values()]
.map((group) => ({ value: mostCommonSpelling(group.spellings), count: group.count }))
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
return {
apps,
unreviewed,
unreviewedAnswers: unreviewed.reduce((total, entry) => total + entry.count, 0),
};
}

async function main() {
const args = process.argv.slice(2);
const review = args.includes("--review");
const [sourceDirectory] = args.filter((arg) => arg !== "--review");
if (!sourceDirectory) {
console.error("Usage: count-requested-apps.mjs [--review] <private-register-directory>");
process.exitCode = 1;
return;
}
const { apps, unreviewed, unreviewedAnswers } = countRequestedApps(await readSignatories(sourceDirectory));
console.log(JSON.stringify({ apps, unreviewedAnswers }, null, 2));
if (review) {
console.error(
"PRIVATE: unreviewed values typed by signatories. They may be personal data. Do not share, paste or commit."
);
console.error(JSON.stringify(unreviewed, null, 2));
}
}

if (import.meta.main) {
main().catch((error) => {
// A JSON SyntaxError message quotes a snippet of the file, which is
// personal data, so it is replaced rather than passed through.
console.error(
error instanceof SyntaxError ? "A signatory file is not valid JSON." : (error?.message ?? "count failed")
);
process.exitCode = 1;
});
}
129 changes: 129 additions & 0 deletions apps/site/scripts/count-requested-apps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0

import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, test } from "node:test";
import { promisify } from "node:util";
import { countRequestedApps } from "./count-requested-apps.mjs";

const USAGE = /Usage:/;
const PRIVATE_LABEL = /PRIVATE.*Do not share/;
const run = promisify(execFile);
const SCRIPT = path.join(import.meta.dirname, "count-requested-apps.mjs");
const temporaryDirectories: string[] = [];

afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map(async (directory) => await rm(directory, { force: true, recursive: true }))
);
});

const EMAIL_VALUE = "alice@example.test";
const NAME_VALUE = "Alice Fakename";
const PHONE_VALUE = "+1 555 010 0199";
const PERSONAL_VALUES = [EMAIL_VALUE, NAME_VALUE, PHONE_VALUE];

test("count folds case, spacing and aliases, and sorts by count", () => {
const { apps } = countRequestedApps([
{ requestedApps: ["Spotify", "insta"] },
{ requestedApps: [" spotify ", "Chat GPT"] },
{ requestedApps: ["OpenAI", "Instagram", "Strava"] },
{ requestedApps: [] },
{ id: "legacy-record-without-the-field" },
]);
assert.deepEqual(apps, [
{ app: "ChatGPT", count: 2 },
{ app: "Instagram", count: 2 },
{ app: "Spotify", count: 2 },
{ app: "Strava", count: 1 },
]);
});

test("one signatory counts once per app even when they spell it two ways", () => {
assert.deepEqual(countRequestedApps([{ requestedApps: ["Insta", "Instagram", "instagram"] }]).apps, [
{ app: "Instagram", count: 1 },
]);
});

test("an app outside the reviewed names and alias map is unreviewed, never in apps", () => {
const result = countRequestedApps([
{ requestedApps: ["oura", "Obscure App"] },
{ requestedApps: ["Oura", "obscure app"] },
{ requestedApps: ["Obscure App"] },
]);
assert.deepEqual(result.apps, [{ app: "Oura", count: 2 }]);
assert.equal(result.unreviewedAnswers, 3);
assert.deepEqual(result.unreviewed, [{ value: "Obscure App", count: 3 }]);
});

async function writeRegister(records: Array<{ id: string; requestedApps: string[] }>): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "pdpp-app-counts-"));
temporaryDirectories.push(directory);
const year = path.join(directory, "signatories", "2026");
await mkdir(year, { recursive: true });
await Promise.all(
records.map(
async (record) =>
await writeFile(
path.join(year, `${record.id}.json`),
JSON.stringify({
displayName: `Fake Person ${record.id}`,
email: `${record.id}@example.test`,
...record,
})
)
)
);
return directory;
}

test("default output holds canonical names and an unreviewed total, never free text", async () => {
const directory = await writeRegister([
{ id: "first-fake-id", requestedApps: ["Spotify", "Strava", EMAIL_VALUE] },
{ id: "second-fake-id", requestedApps: ["spotify", NAME_VALUE] },
{ id: "third-fake-id", requestedApps: [PHONE_VALUE] },
]);

const { stdout, stderr } = await run(process.execPath, [SCRIPT, directory]);
assert.deepEqual(JSON.parse(stdout), {
apps: [
{ app: "Spotify", count: 2 },
{ app: "Strava", count: 1 },
],
unreviewedAnswers: 3,
});
assert.equal(stderr, "");
for (const private_ of [...PERSONAL_VALUES, "Fake Person", "example.test", "fake-id", "555"]) {
assert.equal(stdout.includes(private_), false, `${private_} must not appear in the output`);
}
});

test("--review shows unreviewed values on stderr only, labelled private", async () => {
const directory = await writeRegister([
{ id: "first-fake-id", requestedApps: ["Spotify", ...PERSONAL_VALUES] },
{ id: "second-fake-id", requestedApps: [EMAIL_VALUE] },
]);

const { stdout, stderr } = await run(process.execPath, [SCRIPT, "--review", directory]);
assert.deepEqual(JSON.parse(stdout), { apps: [{ app: "Spotify", count: 1 }], unreviewedAnswers: 4 });
for (const private_ of PERSONAL_VALUES) {
assert.equal(stdout.includes(private_), false, `${private_} must not appear on stdout`);
assert.equal(stderr.includes(private_), true, `${private_} should be listed for review on stderr`);
}
assert.match(stderr, PRIVATE_LABEL);
for (const leaked of ["Fake Person", "fake-id"]) {
assert.equal(stderr.includes(leaked), false, `${leaked} is not a requested app and must not appear`);
}
});

test("the command exits non-zero with a usage line when given no directory", async () => {
await assert.rejects(run(process.execPath, [SCRIPT]), (error: { code?: number; stderr?: string }) => {
assert.equal(error.code, 1);
assert.match(error.stderr ?? "", USAGE);
return true;
});
});
3 changes: 3 additions & 0 deletions apps/site/scripts/publish-supporters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ test("publish supporters writes only consented public fields", async () => {
privateMetadata: { identityProviderToken: "nested-private-token" },
principlesVersion: "v1.0",
publicName: "Public P.",
requestedApps: ["Fake Requested App"],
signatoryName: "Private Signatory",
signatoryRole: "Private Role",
type: "Individual",
Expand Down Expand Up @@ -76,6 +77,8 @@ test("publish supporters writes only consented public fields", async () => {
"Private Signatory",
"Private Role",
"unlisted@example.test",
"Fake Requested App",
"requestedApps",
]) {
assert.equal(output.includes(privateValue), false, `${privateValue} must not reach the public register`);
}
Expand Down
1 change: 1 addition & 0 deletions apps/site/scripts/seo-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ test("sitemap includes the front door, nav destinations, and every real doc page
assert.ok(urls.includes(SITE_ORIGIN), "front door must be in the sitemap");
assert.ok(urls.includes(`${SITE_ORIGIN}/self-host`));
assert.ok(urls.includes(`${SITE_ORIGIN}/participate`));
assert.ok(urls.includes(`${SITE_ORIGIN}/apps`));
assert.ok(urls.includes(`${SITE_ORIGIN}/specification`), "the docs index itself must be listed exactly once");
assert.ok(urls.includes(`${SITE_ORIGIN}/specification/spec-core`));
// A normative companion specification: unlisting the maintainer documents
Expand Down
59 changes: 59 additions & 0 deletions apps/site/scripts/sync-connector-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/usr/bin/env node
// Copyright The PDP-Connect Contributors
// SPDX-License-Identifier: Apache-2.0

// Refreshes src/data/connector-catalog.json, the fallback copy of the catalogue for /apps.
//
// The page loads the catalogue live and refreshes it hourly. This file is
// the copy it falls back to when that load fails, so refresh it now and then.
// The loading code, and what it does and does not verify, is in
// src/lib/connector-catalog.mjs.
//
// Run: node scripts/sync-connector-catalog.mjs [--out <path>]

import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadConnectorSnapshot } from "../src/lib/connector-catalog.mjs";

// Measured against the repo formatter: it keeps a JSON array on one line up to this width.
const LINE_WIDTH = 80;
const TRAILING_COMMA = /,$/;
const STRING_ARRAY = /^( *)("[^"]+": )\[\n((?: +"(?:[^"\\]|\\.)*",?\n)+) *\]/gm;

/**
* JSON.stringify output, with each short array of strings on one line. The
* committed file is checked by the repo formatter, which lays arrays out the
* same way, so a fresh sync leaves no formatting diff.
*/
export function formatSnapshot(snapshot) {
const text = JSON.stringify(snapshot, null, 2).replace(STRING_ARRAY, (whole, indent, key, body) => {
const items = body
.trim()
.split("\n")
.map((item) => item.trim().replace(TRAILING_COMMA, ""));
const line = `${indent}${key}[${items.join(", ")}]`;
return line.length <= LINE_WIDTH ? line : whole;
});
return `${text}\n`;
}

const DEFAULT_OUTPUT = fileURLToPath(new URL("../src/data/connector-catalog.json", import.meta.url));

export async function syncConnectorCatalog(outputPath = DEFAULT_OUTPUT) {
const snapshot = await loadConnectorSnapshot();
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, formatSnapshot(snapshot));
return snapshot;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const outIndex = process.argv.indexOf("--out");
const outputPath = outIndex === -1 ? DEFAULT_OUTPUT : path.resolve(process.argv[outIndex + 1]);
const snapshot = await syncConnectorCatalog(outputPath);
const withLabels = snapshot.connectors.filter((connector) => connector.labels.length > 0).length;
console.log(
`Wrote ${snapshot.connectors.length} connectors (${withLabels} with labels) to ${outputPath}\n` +
`manifest ${snapshot.manifest_digest}\nlayer ${snapshot.layer_digest}\ncommit ${snapshot.source_commit}`
);
}
Loading
Loading