DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five sites claim one publisher, and the single line that differs between them is typed so a typo cannot compile

Notifio is a desktop app that watches rental search pages and tells you the moment a new listing appears. It is one of five apps built by the same person, on five unrelated domains, out of five separate repositories.

That last fact is a problem for exactly one audience: a crawler. Five domains that link to each other and share nothing else look like a link scheme. Five domains that each say, in the same words, with the same identifier, that they are published by the same company look like what they are. The difference is not the links. It is whether the claim is identical everywhere.

So there is one file, lib/publisher.ts, that is copied verbatim into all five repositories, and exactly one line in it differs between the copies. This post is about how that line is typed, because the most likely failure in a copied file is a careless edit to the one thing you were supposed to edit.

The shape

The company first, as a constant:

/** 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;
Enter fullscreen mode Exit fullscreen mode

id is an identifier, not a link. Nothing in the structured data fetches it, nothing on the page hrefs it, and so there is no page anywhere in the five repositories that has to be kept in step with that string. It is a name that happens to be shaped like a URL, which is what @id is for.

Then the apps, each with a one-line blurb and a longer paragraph:

export type FamilyApp = {
  /** Stable key, used only to work out which app is the current one. */
  key: string;
  name: string;
  url: string;
  /** One line, for a reader who has never heard of it. */
  blurb: string;
  detail: string;
};
Enter fullscreen mode Exit fullscreen mode

And the array. This is the line that matters:

export const APPS = [
  { key: 'cogniprep',  name: 'CogniPrep',  url: 'https://cogniprep.app',  blurb: '...', detail: '...' },
  { key: 'munchable',  name: 'Munchable',  url: 'https://munchable.app',  blurb: '...', detail: '...' },
  { key: 'nakodo',     name: 'Nakodo',     url: 'https://nakodo.app',     blurb: '...', detail: '...' },
  { key: 'notifio',    name: 'Notifio',    url: 'https://notifio.app',    blurb: '...', detail: '...' },
  { key: 'pubtrivia',  name: 'PubTrivia',  url: 'https://pub-trivia.app', blurb: '...', detail: '...' },
] as const satisfies readonly FamilyApp[];
Enter fullscreen mode Exit fullscreen mode

Why as const satisfies and not an annotation

The obvious way to write that is:

export const APPS: readonly FamilyApp[] = [ /* ... */ ];
Enter fullscreen mode Exit fullscreen mode

It type-checks. It also throws away the only information the rest of the file needs.

FamilyApp['key'] is string. Under the annotation, APPS[number]['key'] is string too, because the annotation is what the compiler now knows about the value. Under as const satisfies, the value keeps its literal type and the satisfies clause only checks it against the shape. So this works:

type AppKey = (typeof APPS)[number]['key'];
// 'cogniprep' | 'munchable' | 'nakodo' | 'notifio' | 'pubtrivia'
Enter fullscreen mode Exit fullscreen mode

And now the one line that differs between the five copies can be typed against it:

/**
 * The app this repository builds. The one line that differs between copies.
 *
 * Typed against the keys above so that a misspelling here, the most likely
 * mistake when this file is copied into another repository, is a compile error
 * rather than a page that lists this app as a sibling of itself.
 */
export const SELF: AppKey = 'notifio';
Enter fullscreen mode Exit fullscreen mode

Consider what a typo costs without that type. SELF = 'notifo' matches nothing, so:

export const SIBLING_APPS: readonly FamilyApp[] = APPS.filter((app) => app.key !== SELF);
Enter fullscreen mode Exit fullscreen mode

returns all five. Notifio's own /about page then lists Notifio as one of the other apps from the same maker, and the family section links to the site it is on. Nobody reads their own about page often enough to catch that. With SELF: AppKey, it is a red squiggle before it is a commit.

The same type is what lets the next line drop its null check honestly:

/** 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)!;
Enter fullscreen mode Exit fullscreen mode

I am wary of ! in general, but the comment here is a real argument rather than a hope. SELF is of type AppKey, AppKey is derived from the keys present in APPS, so find cannot miss. The assertion is load-bearing on the line above it, which is the only kind of assertion worth keeping.

parentOrganization, not sameAs

The relation each site emits for itself:

export function appRelationsLd() {
  return {
    parentOrganization: { '@type': 'Organization', '@id': PUBLISHER.id, name: PUBLISHER.name },
  };
}
Enter fullscreen mode Exit fullscreen mode

That gets spread into the Organization node in the root layout:

<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify({
      "@context": "https://schema.org",
      "@type": "Organization",
      "@id": `${APP_URL}/#organization`,
      name: "Notifio",
      url: APP_URL,
      logo: `${APP_URL}/icon.png`,
      description: "Notifio monitors rental listing sites and sends instant email and desktop alerts the moment new listings appear.",
      ...appRelationsLd(),
    }),
  }}
/>
Enter fullscreen mode Exit fullscreen mode

sameAs was the first instinct and it is wrong. sameAs asserts identity: it would say Notifio and PubTrivia are the same entity, which they are not, and which would make a mess of whatever either of them has earned separately. parentOrganization says they are distinct products of one company, which is the true statement and also the useful one.

You can read the result on notifio.app/about. There are two JSON-LD blocks in that page. The site-wide one has the parentOrganization above. The second is the page's own:

const aboutSchema = {
  "@context": "https://schema.org",
  "@type": "AboutPage",
  "@id": `${APP_URL}/about`,
  url: `${APP_URL}/about`,
  name: "About Notifio",
  mainEntity: { "@id": `${APP_URL}/#organization` },
  mentions: familyLd(),
};
Enter fullscreen mode Exit fullscreen mode

mainEntity points at the @id the layout already emitted rather than restating the organization, so the page contributes one node to the graph and not a second competing copy of Notifio. mentions is the whole family:

export function familyLd() {
  return APPS.map((app) => ({
    '@type': 'Organization',
    '@id': organizationIdFor(app),
    name: app.name,
    url: app.url,
    description: app.blurb,
    ...appRelationsLd(),
  }));
}
Enter fullscreen mode Exit fullscreen mode

Note it maps APPS, not SIBLING_APPS. The structured data lists all five including this one, identically on all five sites, so a crawler that has reached any one of them has seen the entire family and the same parent claim five times. The visible list on the page uses SIBLING_APPS, because telling a reader about the site they are currently on is noise. The two lists differ on purpose, and the reason is that a crawler and a reader are owed different things.

The @id is derived rather than written:

export function organizationIdFor(app: { url: string }): string {
  return `${app.url}/#organization`;
}
Enter fullscreen mode Exit fullscreen mode

so the identifier cannot drift from the url it is built from. One source of truth per app, one function to turn it into a node name. I have had the opposite of this before: an @id typed out next to a url, both edited by hand, and a trailing-slash mismatch that quietly split one entity into two. That particular lesson I wrote up separately in Same document, two URL spaces, because intent is not content.

The page has to say it too

This is the part that is a policy decision rather than a typing trick. Structured data that asserts something the page does not say is a manual action waiting to happen. So the sentence under the family list is generated 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}).`;
Enter fullscreen mode Exit fullscreen mode

The company number is in there because it is checkable. Anyone can look up 17407266 and get the same answer the JSON-LD gives.

APP_COUNT is a small thing I have come to like:

const COUNT_IN_WORDS = ['No', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine'];

/** 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);
Enter fullscreen mode Exit fullscreen mode

"All 5 are published by" reads like a template that failed to render. "All five are published by" reads like a sentence. The count comes from APPS.length either way, so adding a sixth app changes the prose on five about pages and nobody has to remember that the word "five" appears in a string.

And the intro paragraph says the unflattering thing deliberately:

export const FAMILY_INTRO = `${APP_COUNT} 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.`;
Enter fullscreen mode Exit fullscreen mode

A cross-promotion block that implied a shared audience would be selling, and a reader who arrived from a food scanner can tell. What is actually shared is a standard, so that is the claim.

What this bought

One compile error class removed, which is the one that matters: the five copies disagreeing about which app they are. Everything else in the file is identical by construction, because it is literally the same bytes.

The open cost is that satisfies checks shape and not content. Nothing stops a copy from carrying a stale blurb, or a stale company identifier, which is why the file opens with an instruction to diff it against the other four repositories before changing it and to change all five in the same sitting. That is a process guarantee rather than a type guarantee, and process guarantees are the ones that fail.

I know they fail because I checked all five while writing this post. Four of them emit the identifier above. One is still serving an older one from before the company was named, which means that site's parentOrganization currently points at a node nothing else in the graph defines. The type system had nothing to say about it: the string was well formed, the shape was right, and the only thing wrong with it was that it disagreed with four other deployments. A copied constant is only as fresh as the last deploy of the slowest site, and the honest fix is a shared package rather than a better comment at the top of a file.

If you want to see the output rather than the types: the family section and the publisher sentence are at the bottom of notifio.app/about, and the app those pages are about is on notifio.app/download. For the website side of the same codebase, the pages that are generated from a catalogue rather than written are the per-site alert pages, which I wrote about in Fifteen required fields and no optional ones, because the type decides whether a site gets a page.

Top comments (0)