DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Two URL spaces render from one type, and the ternary that tells them apart returns the same thing twice

The long form pages on notifio.app live in two URL spaces. /guides is for someone describing a task, like how fast rental listings go. /for/ is for someone describing themselves, like /for/students.

They are the same document. One type, one layout component, two routes, and the whole distinction is a string field. This post is what that bought, and the three things I found wrong with it while writing this.

The type

export type Article = {
  slug: string;
  /** Which route this belongs to. */
  kind: "audience" | "guide";
  eyebrow: string;
  title: string;
  description: string;
  h1: string;
  standfirst: string;
  /** Rough minutes, shown under the title. Keep it honest. */
  readingMinutes: number;
  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

Thirteen fields, none optional. That is a deliberate copy of a pattern already used elsewhere in this codebase for the per site pages: if a field is optional, a page ships without it, and the page that is missing its FAQ block is never the page you are looking at when you decide to go and fix it.

The route is a one liner:

export function articleHref(article: Article): string {
  return article.kind === "audience" ? `/for/${article.slug}` : `/guides/${article.slug}`;
}
Enter fullscreen mode Exit fullscreen mode

Everything that links to an article calls that function. No template literal anywhere else in the codebase builds one of these URLs, which means an article can move between the two spaces by changing one character of data, and every inbound internal link follows it.

Both route files are thin. Both are force-static, both generateStaticParams from their own array, both find by slug and 404 otherwise, and both hand the whole object to one layout:

const article = AUDIENCE_PAGES.find((page) => page.slug === audience);
if (!article) notFound();
return <ArticleLayout article={article} href={`/for/${article.slug}`} />;
Enter fullscreen mode Exit fullscreen mode

The sitemap does not know either route exists. It maps over the combined array and asks the same function:

export const ALL_ARTICLES: readonly Article[] = [...AUDIENCE_PAGES, ...GUIDE_PAGES];

const articlePages = ALL_ARTICLES.map((article) => ({
  url: `${APP_URL}${articleHref(article)}`,
  ...
}));
Enter fullscreen mode Exit fullscreen mode

Eight articles today, three audience and five guide, and adding a ninth is one object in one file.

Finding one: the ternary

Here is the breadcrumb builder at the top of the shared layout.

const crumbs: Crumb[] = [
  { label: "Home", href: "/" },
  article.kind === "audience"
    ? { label: "Guides", href: "/guides" }
    : { label: "Guides", href: "/guides" },
  { label: article.eyebrow === "Guide" ? article.h1 : article.eyebrow },
];
Enter fullscreen mode Exit fullscreen mode

Both branches are identical. That ternary is a decision that was going to be made and then was not, left in the shape of a decision, and it has been sitting in the one component every long form page renders through.

I know what it was for. /for/ pages have no hub of their own; there is no /for index. So the honest parent for an audience page is either the guides hub, which is what both branches say, or the homepage. The ternary was where I was going to work out which, and the version that shipped answers the question twice with the same answer.

It is not a bug in the sense that anything renders wrong. The breadcrumb trail is sensible and the BreadcrumbList structured data generated from the same array matches it. It is worse than a bug in one way: it looks like it is handling a case. The next person to read it, which was me, spends a minute working out what the difference is before realising there is not one.

The third crumb has a real conditional doing real work right next to it, which is probably why the dead one survived. article.eyebrow === "Guide" ? article.h1 : article.eyebrow is there because guides all carry the same eyebrow, so for them the leaf has to be the headline, while audience pages carry a specific eyebrow that is already the right label.

Finding two: 54 cross references and nothing checking them

The layout resolves both reference arrays by lookup, and drops anything it cannot find:

const sites = article.relatedSites
  .map(getAlertSite)
  .filter((site): site is NonNullable<ReturnType<typeof getAlertSite>> => site !== undefined);

const articles = relatedArticles(article);
Enter fullscreen mode Exit fullscreen mode
export function relatedArticles(article: Article): Article[] {
  return article.relatedArticles
    .map(getArticle)
    .filter((other): other is Article => other !== undefined && other.slug !== article.slug);
}
Enter fullscreen mode Exit fullscreen mode

Both filters are correct and both are silent. A typo in a site slug does not fail the build, does not warn, and does not render; the card grid is simply one card shorter. The self reference filter is the same kind of guard, catching the case where an article lists itself and would otherwise offer the reader a link to the page they are on.

I counted what is riding on that. Eight articles, 30 site references and 24 article references, every one of them a hand typed string with no compile time relationship to the thing it names. I wrote a throwaway script to resolve all of them against the actual slug sets, and today every single one resolves. That is luck, or more precisely it is a small enough data set that I have been getting away with care.

Compare it with the metadata budgets on the same pages, which are enforced:

const TITLE_MAX = 60;
const DESCRIPTION_MAX = 160;
Enter fullscreen mode Exit fullscreen mode

That test imports AUDIENCE_PAGES and GUIDE_PAGES directly, reads the title template out of the root layout so the suffix counts against the budget, and fails on a page whose rendered title would be truncated. It covers every article. It just does not look at the two fields that are pure references.

The fix is about fifteen lines in the same test file, in the same style, asserting that every relatedSites entry resolves against the alert site catalogue and every relatedArticles entry against ALL_ARTICLES. The reason it is not written yet is the reason these things are never written yet: nothing has broken, because nothing has had to.

Finding three: a required array that two pages leave empty

sources: ArticleSource[] is required by the type, and the layout renders the section conditionally:

{article.sources.length > 0 && (
  <section>
    <h2>Sources</h2>
    ...
Enter fullscreen mode Exit fullscreen mode

Required and non-empty are different claims, and I had been reading the first as if it were the second. Two of the eight articles have sources: [], and I can defend one of them more easily than the other. A documents checklist is a list of things to have ready, and a first message template is a template; neither leans on an external fact. The other six cite between one and four places each, which is the shape I actually wanted for pages whose job is to be worth reading by someone who never buys anything.

If I wanted the type to carry the rule, the field would be a tuple type requiring at least one entry, [ArticleSource, ...ArticleSource[]], and two pages would stop compiling until I decided what they stand on. I have not done it, because I do not believe every one of these pages owes a citation. What it needs instead is for the choice to be visible, which is what writing it down here is for.

One deliberate detail in that section: the citations render with rel="nofollow noopener". These pages link out to the sites being written about, and I would rather the outbound links say nothing about endorsement in either direction.

The part worth copying

The kind field plus the one articleHref function is the piece I would do again without changing anything. Two URL spaces with different search intent, one content model, one layout, one sitemap entry point, and no second place that knows how to build the URL.

What I would do differently is to treat referential fields as code rather than content. Every string in relatedSites and relatedArticles is a function call waiting to fail silently, and the test that would catch it is shorter than this paragraph. You can see the output of all 54 of those references working at the bottom of any article page, for instance the scams guide, where the site cards and the keep reading list are both generated from them.

Top comments (0)