DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Every long-form page on our site has a required sources array, and two of them are empty

Notifio has eight long-form pages. Five are guides, under notifio.app/guides, and three are audience pages under /for/, for people who describe themselves rather than their task. Both routes render the same type, which is a decision I wrote about in Same document, two URL spaces, because intent is not content.

The type has no optional fields at all, and this is one of them:

export type ArticleSource = {
  label: string;
  url: string;
};

export type Article = {
  slug: string;
  kind: "audience" | "guide";
  // ...
  sections: ArticleSection[];
  faqs: { q: string; a: string }[];
  sources: ArticleSource[];

  /** Slugs of /alerts/<slug> pages worth linking from here. */
  relatedSites: string[];
  /** Slugs of other articles, any kind. */
  relatedArticles: string[];
};
Enter fullscreen mode Exit fullscreen mode

sources being required is doing the same job as everything else in that type, which is to make a page impossible to half-write. I used the same approach on the per-site alert pages, where the type is the entry requirement: Fifteen required fields and no optional ones, because the type decides whether a site gets a page.

The reason citations are in the type at all is in the comment above it:

/**
 * The rule these pages are written against: the advice has to be worth reading
 * by somebody who never buys anything. A guide that is a product pitch with
 * headings does not earn links, does not get read twice, and is obvious.
 */
Enter fullscreen mode Exit fullscreen mode

A page that makes claims about how other companies' alerts work, which several of these do, is only worth reading if you can check it. So: required field.

What required actually bought

I went and counted the arrays rather than trusting the type, which is the entire point of this post. Six of the eight pages cite something. Here is the full inventory.

how-fast-do-rental-listings-go      4 sources
how-to-be-first-to-a-rental-listing 2 sources
first-message-to-a-landlord         0 sources
rental-documents-checklist          0 sources
rental-scams-netherlands            2 sources
for/students                        2 sources
for/expats                          2 sources
for/amsterdam                       2 sources
Enter fullscreen mode Exit fullscreen mode

sources: [] satisfies sources: ArticleSource[] perfectly. TypeScript asked whether the field exists, got a yes, and has no opinion about whether it says anything. Required is a statement about the shape of the object, and what I thought I was writing down was a statement about the content of the page.

This is not a subtle distinction once you see it, and I still wrote it, because the form of the fix looks like the form of the problem. Making a field required feels like making a rule. It makes a slot.

The layout is why I did not notice

{article.sources.length > 0 && (
  <section>
    <h2>Sources</h2>
    <ul>
      {article.sources.map((source) => (
        <li key={source.url}>
          <a href={source.url} rel="nofollow noopener" target="_blank">
            {source.label}
          </a>
        </li>
      ))}
    </ul>
  </section>
)}
Enter fullscreen mode Exit fullscreen mode

That guard is correct. A "Sources" heading over an empty list is worse than no heading, so the empty case renders as nothing at all.

It also means the two pages with no citations do not look like pages missing their citations. They look like pages that do not have a Sources section, which is a completely normal thing for a page to not have. The empty state being invisible in the UI is exactly what you want for readers and exactly what you do not want for yourself.

Worth noting which two they are, because it is not random. The first message to a landlord and the rental documents checklist are the two pages made out of judgement rather than out of facts about somebody else's product. There is no authority to cite for what to put in an opening message. The four-source page, how fast rental listings go, is entirely about other companies' published behaviour, so almost every sentence has somewhere to point.

Which means the rule I actually want is not "every page has a source". It is "every claim about a third party has a source", and that is not a thing a type can express. A required non-empty array would have produced a decorative citation on the two pages that do not need one, which is worse than an honest gap: a reader who clicks a source and finds it has nothing to do with the sentence above it has learned something true about how carefully the page was written.

The enforcement that is worth writing

So the useful version is narrower. The website's tests already load these exact collections, for the metadata length budget I wrote about in Our title budget is 60 characters, the layout quietly spends ten of them, and a test reads our own source files to prove it:

import { AUDIENCE_PAGES } from "../../src/lib/articles/audiences.ts";
import { GUIDE_PAGES } from "../../src/lib/articles/guides.ts";
Enter fullscreen mode Exit fullscreen mode

Which makes the check a few lines in a file that already exists:

describe("sources", () => {
  for (const article of [...AUDIENCE_PAGES, ...GUIDE_PAGES]) {
    it(`${article.slug} cites a source for every company it names`, () => {
      const prose = article.sections.flatMap((s) => s.body).join(" ");
      const named = COMPANY_NAMES.filter((name) => prose.includes(name));
      if (named.length === 0) return;
      assert.ok(
        article.sources.length > 0,
        `names ${named.join(", ")} but cites nothing`,
      );
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

A page that names no company is allowed to cite nothing. A page that names one has to point somewhere. That is a crude approximation of the real rule and it fails in the right direction, and the failure message names the company, so the person who sees it in CI knows what the test is asking for rather than just that an array is empty.

I have not added it yet, which is the honest state of things. The reason this post exists is that counting the arrays was more useful than any amount of looking at the type.

Why every source link is nofollow

<a href={source.url} rel="nofollow noopener" target="_blank">
Enter fullscreen mode Exit fullscreen mode

noopener is the uninteresting half: target="_blank" without it gives the opened page a handle on window.opener, and modern browsers imply it now, but it costs nothing to be explicit.

nofollow is a decision, and Google's own documentation describes the case it is for:

Use the nofollow value when other values don't apply, and you'd rather Google not associate your site with, or crawl the linked page from, your site.

Look at what the sources on these pages actually are. Pararius' page about what Pararius+ includes. SpareRoom's explanation of Early Bird. Rightmove's FAQ on how Property Alerts work. Idealista's help article on email alerts. Kamernet's how-it-works page. These are competitors' own help pages, cited because for a claim about how a company's alerts behave, the company's own documentation is the primary source and anything else is hearsay.

Citing them is honest and necessary. Volunteering ranking signal to them from a page that exists partly to compete with them is a separate act, and nothing about accurate citation requires it. Google's page is also clear that nofollow is not a cloak: "the linked pages may be found through other means, such as sitemaps or links from other sites, and thus they may still be crawled." Nothing is being hidden. The link works, the reader can check the claim, and the attribute only says I am not passing an endorsement along with it.

The mirror image of this is the comparison pages at notifio.app/compare, which quote other companies' prices and link to the pages those prices come from, for the same reason and with the same attribute. A page that tells you a rival charges a certain amount and does not let you go and check is just an assertion.

The other slug arrays have a quieter version of the same problem

/** Slugs of /alerts/<slug> pages worth linking from here. */
relatedSites: string[];
/** Slugs of other articles, any kind. */
relatedArticles: string[];
Enter fullscreen mode Exit fullscreen mode

Both are resolved by lookup and filter:

const sites = article.relatedSites
  .map(getAlertSite)
  .filter((site): site is NonNullable<ReturnType<typeof getAlertSite>> => site !== undefined);
Enter fullscreen mode Exit fullscreen mode
/** Resolves `related` slugs, dropping any that do not exist yet. */
export function relatedSites(site: AlertSite): AlertSite[] {
  return site.related
    .map(getAlertSite)
    .filter((related): related is AlertSite => related !== undefined && related.slug !== site.slug);
}
Enter fullscreen mode Exit fullscreen mode

The comment says what the behaviour is for: a page can name a slug before that page exists, and the link appears on the day it does. That is genuinely useful when you are adding sites one at a time.

The cost is that "kamernett" and a page that has not been built yet are the same thing to this code. A typo removes a link from the rendered page and reports nothing. Nobody notices, because the thing you would notice is an absence in a grid of related links, and nobody audits a grid of related links.

That one is a real test rather than an approximation, because the rule is exact: every slug in every relatedSites and relatedArticles either resolves or is on a short list of pages known to be coming. string is the wrong type for it and a union of literal slugs would be the right one, which is a bigger refactor than it sounds like because the slugs are declared in the same arrays that reference them.

The short version

A required field stops a page shipping without the field. It does not stop it shipping without the content, it does not stop a typo in a string that is really a reference, and a layout that handles the empty case gracefully will hide both from you for as long as you are willing to trust the type instead of counting.

Counting took five minutes. The result was that two of eight pages cite nothing, for a defensible reason I would not have been able to give you before I looked.

The pages themselves are at notifio.app/guides, the audience pages include notifio.app/for/students, and the per-site pages those related-slug arrays point into are listed at notifio.app/alerts.

Top comments (0)