CogniPrep is one of five products built by the same company. The others are a food scanner, a rental listing watcher, a pub quiz host and a creator outreach tool. Five domains, five repositories, five deploy pipelines, one company behind all of them.
That last fact is the awkward one. A crawler looking at five sites that link to each other has no reason to believe they share a publisher rather than being five strangers swapping links. So each site emits the same company as structured data, and the fact that the node is byte-identical across all five is the entire claim.
Which leads to the part I want to write about: if agreement between five independently deployed sites is the signal, then reading the file in one repository tells you almost nothing. You have to check from the outside.
The file
lib/publisher.ts is copied into all five repositories. It holds the company, the five apps, and exactly one line that differs between copies.
/** The company that publishes all five apps. */
export const PUBLISHER = {
name: 'CogniPrep Ltd',
registeredIn: 'England and Wales',
companyNumber: '17407266',
id: 'https://cogniprep.app/about#cogniprep-ltd',
} as const;
id is the @id every site uses for the company. It is a fragment on one page of one of the five sites, so there is a single place a crawler can resolve it to, no matter which domain it arrived from.
Each app then gets its own Organization node, and points at the company:
export function appRelationsLd() {
return {
parentOrganization: { '@type': 'Organization', '@id': PUBLISHER.id, name: PUBLISHER.name },
};
}
parentOrganization and not sameAs. The apps are separate products of one company, and sameAs would be asserting that cogniprep.app and munchable.app are the same entity, which is false and is the kind of false that gets a site a manual action rather than a ranking boost.
There is a second rule, and it is a copy rule rather than a code rule: nothing in the structured data may say something the page does not say to a human. So the same module exports the sentence that renders under the app list:
All five are published by CogniPrep Ltd, a company registered in England and
Wales (company number 17407266).
The parentOrganization claim is only defensible because that sentence is on the page. Structured data that tells a crawler about a relationship a reader cannot see is a different thing from marking up what you already published.
The one line that differs, and the type that protects it
Each copy names the app its own repository builds:
export const SELF: AppKey = 'cogniprep';
export const SELF_APP: FamilyApp = APPS.find((app) => app.key === SELF)!;
export const SIBLING_APPS: readonly FamilyApp[] = APPS.filter((app) => app.key !== SELF);
Everything else is derived from that. The about page's family section is SIBLING_APPS, so no copy has a hand-maintained list of the other four.
The array of apps is declared like this, and the reason is specifically about the line above:
export const APPS = [ /* ... */ ] as const satisfies readonly FamilyApp[];
A plain : readonly FamilyApp[] annotation would be more idiomatic and would be a bug. The annotation widens every key to string, so AppKey becomes string, so SELF: AppKey = 'cognirep' compiles. A typo in the one line that differs between five copies is the single most likely mistake in this whole arrangement, and the failure mode is quiet: SELF_APP becomes undefined behind a non-null assertion, and the site lists itself as one of its own siblings. as const satisfies keeps the literal key union, so the typo is a compile error.
While I am here, the paragraph that introduces the family section says the unflattering thing deliberately:
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.
A cross-promotion block that implies a shared audience is selling. Saying the products are unrelated and explaining what they do share is the only version worth putting in front of someone who arrived from one of the others.
Now check it from outside
Here is the part that matters. Paste this into the console on any of the five about pages:
[...document.querySelectorAll('script[type="application/ld+json"]')]
.map((s) => s.textContent)
.join('\n')
.match(/"@id":"[^"]*#(organization|cogniprep-ltd|sonacode)"/g);
Run it on these three:
The first two agree. On cogniprep.app/about the company @id appears six times and the five app Organization nodes all carry it. munchable.app/about emits the identical https://cogniprep.app/about#cogniprep-ltd, from its own copy of the same file, on a different domain and a different deploy.
pub-trivia.app/about does not. It still emits https://cogniprep.app/about#sonacode, and its visible heading still reads "Also from Sonacode Ltd", because the company was renamed and that repository's copy of the file has not been updated and redeployed. Four of five agree, and the fifth is quietly asserting a parent that no longer exists anywhere else.
That is the whole lesson in one observation. The file is correct in the repository I am looking at. It is also wrong on the internet, on a domain I own, and no typecheck, test or lint rule in this repository could have told me, because the thing being tested is a relationship between five separately deployed sites. The check has to be an external one, and the two-line console snippet above is the cheapest version of it.
The fix is boring: update the fifth copy and deploy. The habit is the useful bit. When your signal is "these independent things agree", the only honest verification runs against the deployed artefacts, not the source.
CogniPrep itself is at cogniprep.app, where the same module supplies the publisher node on every page, and cogniprep.app/about is where you can read the sentence that the structured data is allowed to repeat.
Top comments (0)