Skip to content

Locale-suffixed filenames break slug resolution: 22 false positives on a bilingual repo #1

Description

@nahuelsoria

What happened

I ran audit.mjs and gate.mjs against a bilingual Next.js blog: 10 posts, 5 slugs times 2 locales, named <slug>.<locale>.md (agent-fleet.es.md, agent-fleet.en.md). Content dir is content/blog, extension .md.

The audit reported 12 errors and 10 advisories. All 22 are false positives, and they trace back to one line.

errors:      broken-internal-link x8, title-length x4
advisories:  orphan-page x10

The 8 broken links are links between my own posts. They resolve fine in production:

200  https://nahuelsoria.com/es/blog/agent-fleet
200  https://nahuelsoria.com/en/blog/agent-fleet
200  https://nahuelsoria.com/es/blog/honest-ai-visibility-score
200  https://nahuelsoria.com/en/blog/honest-ai-visibility-score

Root cause

slugForFile in skill/scripts/linkgraph.mjs:41:

export function slugForFile(filePath, extRe = /\.mdx?$/i) {
  return path.basename(filePath).replace(extRe, "");
}

agent-fleet.es.md becomes the slug agent-fleet.es, so a link to /es/blog/agent-fleet matches nothing in the slug set. My site derives the route slug with ^(.+)\.([a-z]{2})\.md$ and serves /[locale]/blog/[slug], which is a common i18n convention in Next, Astro and Hugo.

Everything else cascades from that:

  • broken-internal-link (8) fires on every cross-post link.
  • orphan-page (10) fires on every post, because no link resolves anywhere, so nothing has inbound links.
  • In the gate, internal-links-min fires too, since resolving links are counted after resolution.

The gate blocks a post that has been live for weeks, on two rules that are the same failure:

$ node gate.mjs content/blog/nightly-bug-hunter.es.md --term "bug hunter nocturno"
  broken-internal-link: "/es/blog/agent-fleet" resolves to no existing post
  internal-links-min: 0 resolving internal link(s), need at least 2
exit=1

There is no configuration that avoids this. The failure is in slug derivation, not in the mapping.

Suggested fix

An optional slugPattern in config.json, a regex with one capture group applied to the basename, falling back to current behavior when absent:

"slugPattern": "^(.+)\\.[a-z]{2}$"

That keeps slugForFile a pure function and leaves the default untouched for single-locale repos. A narrower alternative is a locales array in config, stripping a trailing .<locale> only for the locales the repo declares, which is harder to misconfigure.

Worth deciding alongside it: whether two locale variants of the same slug are one post or two for cannibalization-fuzzy. Right now agent-fleet.es and agent-fleet.en are separate posts with different titles, so they do not collide, but a repo with two locales that share a language would.

Happy to send the PR if you tell me which of the two shapes you prefer.

Config

{
  "schemaVersion": 1,
  "siteUrl": "https://nahuelsoria.com",
  "language": "es",
  "locationCode": 2032,
  "framework": "next",
  "contentDir": "content/blog",
  "extension": ".md",
  "frontmatter": {
    "title": "title",
    "description": "description",
    "date": "date",
    "updatedDate": null,
    "image": null,
    "tags": "tags",
    "draft": null,
    "tldr": null,
    "faqs": null
  },
  "bodyH1": false,
  "internalLinksMin": 2,
  "thinContentWords": 500,
  "titleMax": 60,
  "descriptionMax": 160,
  "tldrMin": 100,
  "tldrMax": 400,
  "faqsMin": 3,
  "faqAnswerMax": 500
}

Script output

linkgraph.mjs inventory, one post, trimmed:

{
  "path": "content/blog/nightly-bug-hunter.es.md",
  "slug": "nightly-bug-hunter.es",
  "outboundInternal": [],
  "brokenInternal": ["/es/blog/agent-fleet"],
  "wordCount": 1265
}

audit.mjs, two of the failures:

{
  "rule": "broken-internal-link",
  "file": "content/blog/automated-newsroom.es.md",
  "message": "internal link \"/es/blog/agent-fleet\" resolves to no existing post",
  "severity": "error"
}
{
  "rule": "orphan-page",
  "file": "content/blog/agent-fleet.es.md",
  "message": "no other post links to this one",
  "severity": "advisory"
}

detect.mjs got the repo right, for what it is worth: next, content/blog, .md, and the frontmatter keys.

Framework and file extension

Next.js (App Router, file-based markdown loader), .md

Node version and harness

node 22.23.1, run headless from the CLI

Minimal repro

Two files in contentDir:

post-a.es.md

---
title: Post A
description: whatever
date: 2026-08-01
---

Body.

post-b.es.md

---
title: Post B
description: whatever
date: 2026-08-02
---

A link to [post A](/es/blog/post-a).

audit.mjs reports broken-internal-link on post-b and orphan-page on both.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions