DEV Community

Никита Кривда
Никита Кривда

Posted on

Every claim on my site carries its sources. Here is the schema that forces it.

I run a fact-check site for an unreleased game. That genre is a swamp: half the pages you find are somebody's guess reprinted six times until it reads like news. I wanted the opposite, so I made provenance a schema requirement instead of an editorial habit. If a claim has no source, the build fails.

Here is how that works in Astro, and what it cost me.

Sources live in the content schema, not in the prose

Every entity on the site is a YAML file validated by a Zod schema. The interesting part is that sources is not optional:

const sourceSchema = z.object({
  url: z.string().url(),
  date: z.string(),          // when the source said it, not when I read it
});

const base = {
  status: z.enum(['confirmed', 'trailer-spotted', 'rumor', 'debunked']),
  updated: z.string(),
  sources: z.array(sourceSchema).min(1),
};

export const entitySchema = z.object({
  name: z.string(),
  description: z.string(),
  sections: z.array(z.object({
    heading: z.string(),
    text: z.string(),
    status: z.enum(['confirmed', 'trailer-spotted', 'rumor', 'debunked']),
    sources: z.array(sourceSchema).min(1),   // per section, not per page
  })).optional(),
  ...base,
});
Enter fullscreen mode Exit fullscreen mode

Two decisions in there matter more than they look.

Sources are per section, not per page. A page usually mixes a confirmed fact with a plausible reading of a trailer. One source list at the bottom lets those blur together. Per-section sources force me to say which sentence rests on what.

Status is a required enum, not a boolean. rumor and debunked are first-class. The page renders a badge from the same field, so the reader sees the confidence level next to the claim instead of a disclaimer nobody scrolls to.

The cost is real: adding a paragraph means finding a citable source for it. Several times I have deleted a nice sentence because I could not back it. That is the feature working.

Seven locales, and the empty ones stay invisible

The site ships in seven languages, and translations land at different times. Nothing is worse for trust than a language switcher that dumps you on an English page, so the content layer gates on what actually exists in your locale:

export async function byLocale(collection: DbCollection, locale: Locale) {
  const all = await getCollection(collection);
  return all.filter((e) => e.id.startsWith(`${locale}/`));
}

// A section appears in the nav only where it has content in this locale.
export async function nonEmptyDbCollections(locale: Locale) {
  const pairs = await Promise.all(
    DB_COLLECTIONS.map(async (c) => [c, (await byLocale(c, locale)).length]),
  );
  return pairs.filter(([, n]) => n > 0).map(([c]) => c);
}
Enter fullscreen mode Exit fullscreen mode

Everything downstream reads that one function: the nav, the hub pages, the sitemap. The practical payoff is that half-finished sections cannot leak. I have collections sitting in the repo right now with a validated schema, a rendering template and zero entries, because the game they describe is not out. They are invisible until the first file lands, and then the hub, the pages, the sitemap entries and the JSON-LD all appear at once. No launch-day checklist to forget.

The same gate feeds hreflang. Alternate links are generated only for locales that really have the page, and a CI script re-reads the built HTML and fails the build if any page claims a translation that does not exist. Hreflang lying about itself is a mistake you cannot see by looking at your source, only by looking at your output.

What I would do differently

I underestimated titles. Long headlines got clipped in the SERP by the search engine's own truncation, which means the engine chose the cut, not me. Clamping the <title> at a word boundary and leaving the <h1> full length is four lines of code I should have written on day one:

const clamp = (s, n) => {
  const x = s.trim();
  if (x.length <= n) return x;
  const cut = x.slice(0, n - 1);
  const sp = cut.lastIndexOf(' ');
  return (sp > n * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s.,;:!?-]+$/, '') + '';
};
Enter fullscreen mode Exit fullscreen mode

Decorative images are a trap in both directions. I had scene art behind a video player marked alt="" aria-hidden="true", which is correct for a purely decorative wash. Two different site auditors flagged it as a missing alt anyway. The right fix was not to silence the auditors but to admit the art was not decorative: it is the only visual representing that page, so it deserved a real description.

Static output plus IndexNow is a good pairing. The build pings IndexNow with the URLs from the freshly generated sitemap as the last CI step, marked best-effort so a bad ping never fails a green deploy. Ownership is a key file at the domain root, so there is no dashboard login in the pipeline.

The part that has nothing to do with code

None of the above moves you up the results page on its own. I have a technically clean site sitting on page two, because a young domain with no links is a young domain with no links, and no amount of schema fixes that. What the discipline does buy is the thing you can act on later: when a journalist or a wiki editor checks whether you are worth citing, per-claim sources are the difference between a maybe and a yes.

If you want to see the shape of it, the sourced fact list is the rawest view of the data, and this piece on the game's setting shows the status-per-claim idea applied to an article where about half the interesting statements are honestly unprovable.

Happy to answer questions about the Astro side in the comments.

Top comments (0)