Link Improver

Automatic internal linking: the first mention of another page’s name in your rendered content becomes a link to that page. Nothing is written into your source Markdown — disable the option and every auto link is gone.

Install

composer require pushword/link-improver

The filter joins every app’s main_content chain (right after Markdown) but stays inert until you opt in per app:

pushword:
  apps:
    - hosts: [example.tld]
      link_improver: true
      # link_improver_max_links: 0.02     # the default
      # link_improver_ignored_urls: ['/'] # never a target; empty by default

The keyword map is your content

There is no CSV or admin screen to maintain: each line of a page’s name field is a keyword that links to it. The first line is the name Pushword displays (breadcrumb, listings); further lines are never displayed, which makes them pure linking variants — including * wildcards, which match up to 10 characters:

name: |
  Tour du Mont-Blanc
  TMB en 11 étapes
  tour du Mont*Blanc

Commas separate keywords too, as they do in the underlying library’s CSV format. Keyword matching is case-insensitive; the inserted anchor keeps the casing found in the content. Only published pages of the same host and locale are offered as targets, redirections excluded, and a page never links itself.

Choose names accordingly: a page named Blog or Contact will attract links from everywhere its name is written.

link_improver_ignored_urls drops pages from the map without touching their name, which keeps working everywhere else (breadcrumb, listings). Write the URLs as the report prints them — / for the homepage, /slug otherwise:

link_improver_ignored_urls: ['/', '/contact']

The homepage is the usual candidate: its name is often the brand, written on nearly every page (44% of the inserted links on one real site).

What it will not do

The insertion engine refuses to place a link:

  • inside a tag, a heading, code (inline or block) — only inside p, span, b, strong, em, i, li;
  • immediately after another link;
  • to a target the content already links (with or without #fragment, ?query or the absolute base_url form);
  • beyond the cap: link_improver_max_links bounds the total of in-content links, existing ones included. Below 1 it is a ratio of the word count — the default 0.02 means one link per 50 words; 1 or more is an absolute count.

Choosing the cap

Since the cap counts existing links, it is a target density: a page already above it gets nothing, a neglected page is filled up to it. The default 0.02 is half the median density of Wikipedia prose (one link per 20 words, measured on 30 fr/en articles); real Pushword corpora sit between one per 48 and one per 720 words, with 8–49% of pages holding no internal link.

Auditing what was linked

  • On the page, for a logged-in ROLE_EDITOR only: each auto link gets an indigo background tint and a title saying it was inserted. The response is then sent private, no-store; public HTML and the shared render cache are untouched.
  • In the admin: the Auto links button on the page edit screen lists what the page gained (anchor → target), the cap applied, and the keywords its own name offers. Open it when a page gained nothing.
  • In the HTML: inserted links carry a bare data-auto-link attribute (grep data-auto-link on a static export).
  • Site-wide: php bin/console pw:link-improver renders every published page and reports each inserted link. --host example.tld narrows to one app, --simulate renders as if link_improver: true were set, and --format follows the agent-output convention.

Inbound links (auto ones included) are reported by page-scanner's pw:link:graph.

Rendering and cache

Links are decorated at render time, after the Markdown/Twig pass and before the Html* link post-processors — an auto link to a page that becomes unpublished degrades exactly like an editorial one. Rendering is byte-deterministic: the map is derived from the pages ordered by slug, longest keyword first, so two renders of the same page produce the same bytes.

A page’s output depends on the other pages' names; renaming, publishing or deleting a page bumps the host’s render epoch, so static and page-cache sites regenerate what it affects.

Interplay with pages_list

Links inserted by the improver are registered with the link collector, so a pages_list(…, excludeAlreadyLinked: true) rendered after the content — in the template — will not offer a page the improver just linked in the body. A pages_list written inside the content renders before the improver and cannot know about them.