Five sites, five separate repositories, one hand-copied TypeScript file. That file is the only reason a crawler can tell the five domains come from one company rather than being five unrelated sites that link to each other. It has been rewritten three times in three days, and every rewrite was driven by the same rule: the structured data is not allowed to say anything the page does not say.
Version one: one publisher, four apps
The first shape was the obvious one. A PUBLISHER constant with a name and an @id, a list of the apps, and every site emitting the same publisher identifier. Agreement across the copies is the whole mechanism. If cogniprep.app and pub-trivia.app both emit the same @id for the company, a crawler that has seen either site has seen the relationship.
Version two: the legal reality was messier than the graph
Then the apps stopped agreeing with it. One of them was published by a limited company. The other four were run by an individual as a sole trader, and each app's own terms and privacy notice named whoever actually ran it. So the file could not claim the company published all four, because four of the five pages said otherwise, and markup that contradicts its own page is a manual-action risk rather than a formatting nit.
What the five genuinely shared at that point was the person who built them. So the family got tied together by founder, one Person node every app's Organization pointed at, and the company appeared only as the one app's parentOrganization. The type got a union to carry the distinction:
export type Operator = {
type: 'Organization' | 'Person';
name: string;
id: string;
};
Each app then declared which operator it belonged to, and the graph followed the paperwork instead of the branding.
Version three: the person node had to go
A day later the company became the publisher of all five, so the Person node was not merely unnecessary, it was wrong. The file now says so in its own header comment: no person is named here or in the structured data. Every app is a child of one company node, and the union type went with the person.
export function appRelationsLd() {
return {
parentOrganization: { '@type': 'Organization', '@id': PUBLISHER.id, name: PUBLISHER.name },
};
}
parentOrganization rather than sameAs, deliberately. sameAs asserts two nodes are the same entity, and a practice platform is not the same entity as a rental-listing watcher. They are separate products of one company, which is exactly what parentOrganization means.
The company's @id is https://cogniprep.app/about#cogniprep-ltd: a fragment, not a page. An @id is an identifier, and it does not have to resolve. Pointing it at a real URL would create a page that then has to be kept in step with the graph forever, in five repositories at once.
The one line that is allowed to differ
The copies are identical apart from SELF, which names the app the repository builds. Get that wrong and a site lists itself as its own sibling, which is the single most likely mistake when a file is copied by hand. So the app list is declared with as const satisfies readonly FamilyApp[] rather than a : readonly FamilyApp[] annotation. The annotation widens every key to string and a typo in SELF compiles cleanly. With satisfies, the keys stay literal and the typo is a type error.
The visible side of the same rule is one sentence under the family list, generated from the same constants:
All five are published by CogniPrep Ltd, a company registered in England and Wales (company number 17407266).
The parent claim in the graph is only safe because that sentence says the same thing to a person, and the terms of service say it a third time.
See it
Open cogniprep.app/about and read the JSON-LD in the page source. There are three blocks. The AboutPage one carries a mentions array with five Organization nodes, each with the same parentOrganization @id and no founder, author or Person anywhere in it. Then scroll the rendered page to the sentence above the contact section, and open cogniprep.app/terms, where the same company number appears next to a version string. Three surfaces, one claim. That is the only state this file is allowed to be in.
Top comments (0)