Munchable scans a food barcode and tells you whether the product suits a digestive condition you have. It is one of five apps published by the same company, and the other four have nothing to do with food: practice for employer assessments, a creator outreach tool, a rental listing watcher, and a quiz night for pubs.
Five unrelated products on five separate domains, each linking to the other four, is also a decent description of a link scheme. The honest version of that situation and the dishonest version look identical from the outside, which means the work is in saying the true thing precisely, in two places at once: in prose a person reads, and in structured data a crawler reads. Those two must agree, because markup that claims something the page does not say is a manual action waiting to happen.
The whole of that lives in one file, lib/publisher.ts, and that file is checked into all five repositories.
The one line that differs
The file opens by saying what it is:
* The data in this file is the same in every one of the five repositories apart
* from SELF, which names the app the repository builds. Keeping the copies in
* agreement is the point: every site emits the same company identifier and the
* same list of apps, and that agreement is what tells a crawler the domains have
* one publisher rather than being five unrelated sites that happen to link to
* each other. Diff this file against the other repositories before changing it,
* and change all five in the same sitting.
Everything else is identical by design: the company, and an array of the five apps with a one-line blurb and a longer paragraph each.
export const PUBLISHER = {
name: 'CogniPrep Ltd',
registeredIn: 'England and Wales',
companyNumber: '17407266',
id: 'https://cogniprep.app/about#cogniprep-ltd',
} as const;
id is worth a note. It is an identifier, not a link. Nothing renders it as an anchor, and the comment in the file says why: "The ids are identifiers, not links, so nothing here points at a page that would then have to be kept in step with it." In JSON-LD an @id is a name for a node, and five sites that all name the company with the same string are all talking about one company. The string happening to look like a URL is a convention, not a promise to serve something there.
The annotation that would have let Munchable be its own sibling
Here is the bit I would have got wrong without thinking about it:
/**
* `as const satisfies` rather than a `: readonly FamilyApp[]` annotation: the
* annotation widens every `key` to `string`, which would let a typo in SELF
* compile cleanly and list this app as its own sibling.
*/
export const APPS = [
{ key: 'cogniprep', name: 'CogniPrep', /* ... */ },
{ key: 'munchable', name: 'Munchable', /* ... */ },
// three more
] as const satisfies readonly FamilyApp[];
type AppKey = (typeof APPS)[number]['key'];
/** The app this repository builds. The one line that differs between copies. */
export const SELF: AppKey = 'munchable';
The obvious way to write that array is export const APPS: readonly FamilyApp[] = [...]. It type checks. It also throws away every literal type on the way in: key becomes string, so AppKey becomes string, so SELF accepts any string at all.
Now imagine the realistic mistake. This file is copied into a new repository and SELF is edited by hand, and the edit has a typo in it, or it simply gets forgotten and still says the previous app's key. With the annotation, that compiles. The derived values then quietly do the wrong thing:
/** This app's own entry. Non-null by construction: SELF is one of the keys. */
export const SELF_APP: FamilyApp = APPS.find((app) => app.key === SELF)!;
/** The other four, for the family section on /about. */
export const SIBLING_APPS: readonly FamilyApp[] = APPS.filter((app) => app.key !== SELF);
A typo makes find return undefined, and that ! is a lie, so SELF_APP is undefined with a type saying otherwise. A forgotten edit is worse, because nothing crashes: the about page lists five apps under "Also from the same maker", one of which is the app you are already reading, and the site cheerfully recommends itself as something else to try.
as const satisfies is the fix, and it is the whole reason to prefer it over an annotation. satisfies checks the array against FamilyApp[] without widening it, so key stays a union of five exact strings, and a typo in SELF is a compile error in the one file where the mistake is actually made.
Everything else is derived
Nothing that can be computed is typed out twice. The count of apps is spelled in words, from the array's own length:
const COUNT_IN_WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', ...];
/** Spelled out, because a numeral that small reads as a typo in a sentence. */
const APP_COUNT = COUNT_IN_WORDS[APPS.length] ?? String(APPS.length);
And the two sentences on every about page are built from it, so adding a sixth app rewrites both of them for free rather than leaving two pages saying "Four apps come from the same maker" under a list of five.
The identifier a site puts on itself is derived too, from the URL that is already in the array:
/** The `@id` a family site puts on the Organization it emits for itself. */
export function organizationIdFor(app: { url: string }): string {
return `${app.url}/#organization`;
}
That matters more than it looks. Each site emits a fuller Organization node for itself, and all five sites emit a thin node for each of the others. They are meant to merge, and they merge on @id and nothing else. One hand-typed @id with a trailing slash in the wrong place, and a crawler has two organizations where it should have one.
The paragraph that says the unflattering thing
The family section opens with a sentence I like more than most copy I have written, mostly because of what it refuses to do:
Five apps come from the same maker, and they look unrelated because they are: a quiz night for a pub and a food scanner for people with IBS share no audience at all. What they have in common is how they are built.
The comment above it in the file is the real argument:
* It says the unflattering thing on purpose. The apps share no audience, and a
* page that implied otherwise would be selling; what they share is a standard,
* and that is the only claim worth making to somebody who arrived from one of
* the others.
The alternative, the "complete suite" framing, would be the lie that turns five honest cross-links into the link scheme they already resemble.
The sentence and the markup are the same claim
Under the list sits one sentence, rendered from the same constants:
export const OPERATORS_NOTE = `All ${APP_COUNT.toLowerCase()} are published by ${PUBLISHER.name}, a company registered in ${PUBLISHER.registeredIn} (company number ${PUBLISHER.companyNumber}).`;
Its comment names the job it is doing: "It is the visible half of familyLd(): the parent claim in the structured data is only safe because this sentence says the same thing to a person."
The machine half is deliberately modest about what relation it asserts:
/**
* `parentOrganization` rather than `sameAs`: the apps are separate products of
* one company, and sameAs would assert they are the same entity.
*/
export function appRelationsLd() {
return {
parentOrganization: { '@type': 'Organization', '@id': PUBLISHER.id, name: PUBLISHER.name },
};
}
sameAs is the tempting one, because it is the property people reach for when two URLs are "related". It would be false here. Munchable is not CogniPrep under another name, and claiming so in markup invites a crawler to consolidate five products into one, which is both wrong and against my interests. parentOrganization says the true and weaker thing: separate products, one company. Weaker claims are the only kind worth making in structured data, because they are the kind that survive being checked.
Five readers, one rename, one legal event
The company constant is read in five places: the about page, the terms, the privacy notice, the site footer, and the footer of every transactional email. That is a find and replace, except for one thing. Who you are contracting with is a material term, and who the data controller is, is a material statement in a privacy notice. So the version constants move with the name:
export const TERMS_VERSION = '1.3';
export const PRIVACY_VERSION = '1.3';
Those two numbers are compared against what each signed-in account recorded at signup, and being ahead of a recorded version is what shows that account the "we have updated our terms" notice on their next visit. The rename commit touched eight files, and two of them were those version numbers going from 1.2 to 1.3. The practical effect was that renaming the publisher re-consented every existing user, which is the correct outcome and not one you would get from a careless find and replace.
I wrote about the notice itself separately: it has no Agree button, because carrying on is the acceptance.
What is still wrong with this
- Five copies, kept in agreement by a comment. The file says to diff it against the other four repositories before changing it. Nothing enforces that, because no test in this repository can see the other four. The honest description is that consistency here is a habit with a reminder attached, not a guarantee.
-
The company
@idpoints into a page this repo cannot check. It names a fragment on another domain. If that page's heading anchor is renamed, nothing here fails. The identifier still works as an identifier, which is most of what it is for, but the half of its value that comes from resolving to something is on trust. - The company number is prose. It is rendered into the terms, the privacy notice and the structured data, and nothing checks it against the register. It is right because it was typed carefully once.
- The count is derived, "five scans a month" is not. The app count is spelled from the array length, and the free tier's five scans appears as the literal word "five" in nine places across eight files. Same word, same page in some cases, completely different provenance, and only one of the two can be trusted to update itself.
Look at it on the live site
The claim is public and checkable, which is the point of writing it down carefully. Open Munchable's about page and read the family section at the bottom, then open CogniPrep's and compare: same heading, same intro paragraph, same publisher sentence, five entries with one of them missing from each list, because each site omits itself. The company and its registration are named in the terms and in the privacy notice, both of which carry the version number that moved when the company was renamed.
If you want the machine half, view the source of the about page and look for the AboutPage node's mentions array. Every app in the family is in it, and every one of them names the same parent.
Related: our structured data is two script tags, and the rule is that no node may claim more than the page shows, which is the general version of the rule this file follows.
Top comments (0)