feat: internal links widget with counts and suggestions

- E10 golem15-seo-links in the SEO tab after the graph preview (types
  with a body): inbound and outbound counts from the last finished crawl,
  the orphan warning, up to 10 pages to link to (Copy address) and 10
  that could link here
- classes/linkgraph.go builds the report from the crawl's pages and
  links; the crawler's orphan suggestions share NormalText
- GET /_seo/api/v1/links and seo_get_link_suggestions; widget action
  seo_links mapped in AdminOperations
- element bundle rebuilt
This commit is contained in:
Jakub Zych
2026-11-04 16:40:16 +01:00
parent ab4e83416d
commit 49cc241f58
20 changed files with 1131 additions and 12 deletions

View File

@@ -38,7 +38,7 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
- **CSV import and export** of redirects, URL rules and the SEO of records (by URL), in the **Import and export** screen (element `golem15-seo-transfer`) and through MCP. Files are UTF-8 with a BOM, comma or semicolon; exported cells that start a formula (`=`, `+`, `-`, `@`, tab, CR), or apostrophes followed by one, are prefixed with an apostrophe, and the import drops exactly that one again, so a re-imported export is unchanged. An import is previewed row by row (new, changed, unchanged, refused, with notes) and applied as one batch with via `import`, or not at all while a row is refused. The screen's apply token is an HMAC-SHA256 under the app key over the file and a fingerprint of the data the preview read; it is refused with `transfer.stale` when the data changed. The screen previews files up to 60 KB and downloads exports up to 250 KB; MCP imports take up to 900 KB per call and exports come in pages of 1000 rows.
- **Record events.** A deleted record loses its entries after commit; a created record gets its address snapshot; an updated record whose address changed gets its 301.
- **Entry generation.** The `golem15.seo.generate_entries` job (conga) creates missing entries with the current address of every published record and refreshes the effective-value cache; a record whose address changed without a record event gets its address change (with its 301) like an admin save. Editor values are never changed.
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage`, `robotstxt`, `seoschema` and `seoparent`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor), `golem15-seo-robotstester` (the tester widget), `golem15-seo-transfer` (the import and export widget), `golem15-seo-schema` (structured data), `golem15-seo-graph` (the structured data preview widget) and `golem15-seo-parent` (the primary category).
- **Field types** `seotext`, `mlseotext`, `seorobots`, `mlseorobots`, `seoimage`, `robotstxt`, `seoschema` and `seoparent`, with the custom elements `golem15-seo-text`, `golem15-seo-robots`, `golem15-seo-serp` (the result preview widget), `golem15-seo-image`, `golem15-seo-robotstxt` (the robots.txt editor), `golem15-seo-robotstester` (the tester widget), `golem15-seo-transfer` (the import and export widget), `golem15-seo-schema` (structured data), `golem15-seo-graph` (the structured data preview widget), `golem15-seo-parent` (the primary category) and `golem15-seo-links` (the internal links widget).
- **Structured data.** Every `ok` resolve answer carries one JSON-LD `@graph` built from the content descriptor and the settings: the page node (WebPage or its subtype), the article with its authors, dates, images, word count and section, the person of a profile page, a Service with its provider and offer, the BreadcrumbList, one VideoObject per YouTube video, the WebSite (with a SearchAction when `search_url` is set) and the publisher. Editors pick the schema type per page, fill the Service fields and add custom JSON-LD; the SEO tab previews the graph with Google's required and recommended properties and links the Rich Results Test.
- **Publisher and local business** settings: an organisation or a person with logo, website, contact and sameAs profiles, optionally a local business with address, coordinates, opening hours and price range.
- **Breadcrumbs, hreflang and pagination.** The resolve answer carries the breadcrumb trail (through the record's primary category), the hreflang alternates with `x-default`, `html_lang` and `og:locale`, and the page of a paginated address in the site's form (`?page=N` or `/<segment>/N`).
@@ -51,6 +51,7 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t
- **Site answer** (`GET /_seo/api/v1/site`) with the facts the frontend layer needs, and **llms.txt** (off by default) listing the indexable pages from the sitemaps.
- **Links** settings: links to other sites open in a new tab (on by default), and domains whose links always get `nofollow` or `sponsored`; the resolve answer carries them as `outbound`.
- **Auto-links** (`golem15_seo_autolinks`, SEO › **Auto-links**): phrases that texts link to a page, per locale or for all, with an active switch, an origin and a note. Every `ok` resolve answer carries the phrases of its locale (never one linking to the page itself) as `autolinks`, with `autolink_max` from the Links tab (3 by default, 0 turns auto-linking off); the frontend layer links each phrase's first occurrence. **Add focus keyphrases** turns the records' focus keyphrases into phrases. See [Auto-links](#auto-links).
- **Internal links** in the SEO tab (E10): the page's inbound and outbound link counts from the last finished crawl, a warning when no page links to it, and up to 10 pages its text could link to and 10 pages that could link to it (titles and focus keyphrases found in the crawled text), each with **Copy address**; `GET /_seo/api/v1/links` and `seo_get_link_suggestions` answer the same.
- **Site cache purge.** Every SEO write (admin, MCP, batch, import, undo, automatic) asks the frontend to drop its cached pages after the commit, once per transaction, through the framework's site-cache purger (`site_cache.purge_url`, `site_cache.purge_token`).
## Usage
@@ -198,6 +199,8 @@ The **robots.txt** screen (a singleton) has the editor, the tester and **What cr
**SEO › Auto-links** (controller `golem15.seo.autolinks`, after Pages, icon `wand-sparkles`) lists the phrases (50 per page) with the address they link to, the language (**All languages** or a locale), the origin (**Manual**, **Focus keyphrase**, **MCP**) and the update time; an inactive phrase shows **Off**. Filters: language, origin and **Active only**. The form takes the phrase, the address, the language and the **Active** switch, and a note. A phrase needs 3 characters (`autolinks.phrase_short`); a phrase exists once per language, a phrase of every language once at all (`autolinks.phrase_taken` names the address it already links to); the address is a path starting with one `/` or a full `http(s)` address (`autolinks.url_invalid`: `javascript:`, `data:` and protocol-relative addresses are refused). The toolbar's **Add focus keyphrases** adds one phrase per record whose focus keyphrase in a locale is not yet a phrase there, linking to the record's address in that locale (origin **Focus keyphrase**), and toasts the count; a second run adds nothing new. Every change is recorded in History and purges the site's cached pages.
The SEO tab's **Internal links** (widget `golem15-seo-links`, action `seo_links`, after the structured data preview, on the update form of content types whose records have a body: not for collections, profiles or the page registry) reads the last finished crawl: **Links to this page** (pages linking here) and **Links from this page** (pages it links to on the site), a warning when nothing links here, **Pages you could link to from this text** (pages whose H1 or focus keyphrase the crawled text contains and that it does not link to yet, with the public address and **Copy address**) and **Pages that could link here** (pages whose crawled text contains this page's title, H1 or focus keyphrase and that do not link here yet, with their admin form), 10 each. Before the first crawl it says when the counts appear.
The layer links a phrase at its first occurrence in a text, as whole words and ignoring letter case, never inside headings, links, `code`, `pre`, `figcaption` or buttons, and never on the page it links to, at most `autolink_max` phrases per text (the Links tab's **Auto-links per page**, 0 to 20).
### The site answer and llms.txt
@@ -253,6 +256,7 @@ The management routes live under `/_seo/api/v1` (bucket `seo-api`, 300 requests
| `get_url_seo` | `GET /seo?url=` | Effective SEO of a URL with the source of every value, the redirect decision, the owner and the SEO tab's warnings. |
| `set_url_seo` | `PUT /seo` | Values for a URL: the record behind it in the URL's locale, else an exact URL rule; `force_rule` writes a rule. `primary_parent` (`<type>:<id>`) and `schema` (the structured data JSON string) are records only and shared by every locale. Answers `written_to` (`record` or `rule`), `type`, `id`, `locale`, `rule_id`. |
| `get_graph` | `GET /graph?url=&schema=` | The JSON-LD graph of a URL and every node's missing required and recommended properties (`rules` false for a type Google has no rules for); `schema` previews an unsaved value. Answers `{locale, url, graph, nodes}`. |
| `get_link_suggestions` | `GET /links?url=` | A page's internal links from the last finished crawl: `{crawledAt, inbound, outbound, orphan, out [{phrase, title, url}], in [{phrase, title, adminUrl}]}`, each list at most 10; `crawledAt`, `inbound` and `outbound` are `null` before the first crawl. |
| `list_rules`, `show_rule`, `create_rule`, `update_rule`, `delete_rule` | `GET/POST /rules`, `GET/PUT/DELETE /rules/{id}` | URL rules. |
| `list_redirects`, `show_redirect`, `create_redirect`, `update_redirect`, `delete_redirect` | `GET/POST /redirects`, `GET/PUT/DELETE /redirects/{id}` | Redirects; a chain is saved with a warning. |
| `list_not_found`, `redirect_not_found`, `gone_not_found`, `delete_not_found` | `GET /notfound`, `POST /notfound/{id}/redirect`, `POST /notfound/{id}/gone`, `DELETE /notfound/{id}` | The 404 log and its actions; an address a rule answers is `409`. |
@@ -283,7 +287,7 @@ Lists take `page`, `per_page` (at most 200) and `search`, and answer `{"data": [
- **Crawls** lists the runs newest first (started, status, pages, issues, started by, duration). The preview shows one status callout (running, queued, failed, cancelled, limit reached), the staging and rendered-comparison lines, the stats strip and the issues per kind, each count linking to the filtered Site audit; **Cancel crawl** while queued or running. There is no live progress: reload the page.
- The SEO settings' **Site audit** tab (before Verification): **Automatic crawl** (off, daily, weekly), **Pages per crawl** (10–50 000), **Pause between requests** (100–5000 ms), **Crawls kept** (1–50) and **Generic link texts** (seeded with the English and Polish lists).
- The SEO settings have a **Pagination** tab (after Addresses), a **Links** tab (**Auto-links per page**, 0 to 20, the new-tab switch and the nofollow and sponsored domain lists, one domain per line; `settings.domain_invalid` names a bad line), a **Publisher** tab with the generated publisher node and its findings, and a **Local business** tab. A person cannot be a local business (`settings.local_person`).
- The SEO tab adds **Primary category** (article types), **Structured data** and its **preview** after the social image; its status warns when a stored primary category is no longer assigned, and the effective values show the schema type and the breadcrumb trail.
- The SEO tab adds **Primary category** (article types), **Structured data** and its **preview** after the social image, then **Internal links** (types with a body); its status warns when a stored primary category is no longer assigned, and the effective values show the schema type and the breadcrumb trail.
- Settings tile **SEO settings** (`seo_settings`), a link to the settings singleton controller.
- Every SEO screen and the SEO tab require `golem15.seo.manage`. Without it an administrator sees no SEO tab, and a posted SEO value changes nothing.
@@ -308,6 +312,7 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr
| `schema.Build`, `schema.Input`, `schema.Validate`, `schema.Normalize`, `schema.DecodeCustom`, `schema.Merge` | Package `classes/schema`: the JSON-LD graph of a page, Google's required and recommended properties per type, the structured data value and custom JSON-LD. |
| `classes.Alternate`, `classes.Alternates`, `classes.LangTag` | hreflang alternates and language tags; `Service.OGLocale`. |
| `classes.Pagination`, `classes.PageConfig` | The resolve answer's pagination block and a site's page form (`Service.PageConfigOf`). |
| `classes.LinkReport`, `classes.LinkOut`, `classes.LinkIn`, `classes.RecordLinksAction`, `classes.LinksActionName`, `classes.MaxLinkSuggestions`, `classes.NormalText` | The internal links of a page (`Service.LinkSuggestions`, `Service.LinksOfURL`, `Service.LinksOfRecord`) and the SEO tab's `seo_links` action. |
| `classes.GraphAnswer` | `Service.GraphOfURL` and `Service.GraphOfRecord`, the graph and its findings; `classes.RecordGraphAction` is the SEO tab's preview action. |
| `classes.PrimaryParent`, `classes.ParentOption` | A record's primary category and its choices (`Service.ParentOptions`); `Service.Trail` builds the breadcrumb trail, `Service.PublisherOf` the publisher. |
| `classes.SitemapExclusion` | `Service.RecordSitemapExclusions`, why the sitemaps leave out a record's addresses; `Service.SitemapListed` reads the listed addresses; `Service.SitemapLines` the robots.txt `Sitemap:` addresses. |
@@ -392,4 +397,4 @@ npm ci && npm run typecheck && npm test && bash scripts/check-dist.sh
The element tests (`tests/elements`, vitest on happy-dom) drive the widget elements through a fake of the admin's widget host (`tests/elements/host.ts`), including a host that drops an action.
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). The sitemap, the registry and the site-level endpoints are covered by `TestSitemapFilterTracer`, `TestFilterReasons`, `TestNoindexPublishedConflict`, `TestSitemapBlockedIssue`, `TestRegistry`, `TestRouteReport`, `TestSiteEndpoint`, `TestLLMSTxt`, `TestRobotsSitemapLine`, `TestSettingsLinks` and `TestPurgeAfterWrite`; the fixture boots the sitemap plugin next to the SEO plugin. The site audit is covered by `TestCrawlerTracer`, `TestCrawlerChecks` (a planted site with one page per kind and a clean control page, served to the crawler through `fetchguard.WithTransport`), `TestIssueMerge`, `TestStagingCrawl`, `TestRenderSkipped`, `TestCrawlAdmin`, `TestCrawlSchedule` and `TestCrawlTools`, with the fake content plugin `acme.crawl` naming fixing tools. Auto-links are covered by `TestAutolinksTracer` (the phrases of a page's locale in the resolve answer, never the page's own, `autolink_max` 0) and `TestAutolinks` (the screen, its refusals, Add focus keyphrases, the API and MCP). `TestChromeRenderer` renders a page in a real headless Chrome (it runs when `SEO_TEST_CHROME` names a binary or `chromium`, `chromium-browser` or `google-chrome` is on `PATH`, and is skipped otherwise) and `TestRendererGuards` checks the refused paths and private addresses. `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET`, `TestIssueKindLabels` and the static parts of `TestCrawlTools` and `TestCrawlSchedule` run without Docker.
The smoke tests boot the plugin with fake content plugins (`acme.pages`, and `acme.blog` with a category segment for strict URLs) and exercise the SEO tab, the settings tabs, the resolver precedence, history, generation, the URL rule form, redirects (engine, loops and chains, address changes from the admin and from events), the 404 log, normalisation and strict URLs, robots.txt and verification files through the admin and public APIs, and the management API as an MCP client (`TestMCPSetURLSeoTracer`, `TestManagementAPI`, `TestIssues`), batches, undo and CSV (`TestBatch`, `TestUndo`, `TestCSVTransfer`, and the `mcp` and `import` cases of `TestLoopRefused`). The sitemap, the registry and the site-level endpoints are covered by `TestSitemapFilterTracer`, `TestFilterReasons`, `TestNoindexPublishedConflict`, `TestSitemapBlockedIssue`, `TestRegistry`, `TestRouteReport`, `TestSiteEndpoint`, `TestLLMSTxt`, `TestRobotsSitemapLine`, `TestSettingsLinks` and `TestPurgeAfterWrite`; the fixture boots the sitemap plugin next to the SEO plugin. The site audit is covered by `TestCrawlerTracer`, `TestCrawlerChecks` (a planted site with one page per kind and a clean control page, served to the crawler through `fetchguard.WithTransport`), `TestIssueMerge`, `TestStagingCrawl`, `TestRenderSkipped`, `TestCrawlAdmin`, `TestCrawlSchedule` and `TestCrawlTools`, with the fake content plugin `acme.crawl` naming fixing tools. Auto-links are covered by `TestAutolinksTracer` (the phrases of a page's locale in the resolve answer, never the page's own, `autolink_max` 0) and `TestAutolinks` (the screen, its refusals, Add focus keyphrases, the API and MCP); `TestLinkSuggestions` crawls a planted site and checks E10's counts and suggestions through the API and the widget action. `TestChromeRenderer` renders a page in a real headless Chrome (it runs when `SEO_TEST_CHROME` names a binary or `chromium`, `chromium-browser` or `google-chrome` is on `PATH`, and is skipped otherwise) and `TestRendererGuards` checks the refused paths and private addresses. `TestEveryAdminOperationHasTool`, `TestMCPReadToolsAreGET`, `TestIssueKindLabels` and the static parts of `TestCrawlTools` and `TestCrawlSchedule` run without Docker.

View File

@@ -45,6 +45,7 @@ func (*Plugin) Navigation() []pact.NavigationItem {
Permissions: []string{controllers.PermissionManage},
Controller: controllers.PagesControllerID,
},
// Auto-links, controller golem15.seo.autolinks (UI-SPEC C15).
{
Code: "autolinks",
Label: "golem15.seo::lang.nav.autolinks",

2
assets/dist/seo.css vendored

File diff suppressed because one or more lines are too long

4
assets/dist/seo.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import './styles.css'
import { GRAPH_TAG, SeoGraphElement } from './graph'
import { IMAGE_TAG, SeoImageElement } from './image'
import { LINKS_TAG, SeoLinksElement } from './links'
import { PARENT_TAG, SeoParentElement } from './parent'
import { ROBOTS_TAG, SeoRobotsElement } from './robots'
import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester'
@@ -24,6 +25,7 @@ const elements: Array<[string, CustomElementConstructor]> = [
['golem15-seo-schema', SeoSchemaElement],
['golem15-seo-graph', SeoGraphElement],
['golem15-seo-parent', SeoParentElement],
['golem15-seo-links', SeoLinksElement],
]
for (const [tag, ctor] of elements) {
if (!customElements.get(tag)) {
@@ -34,9 +36,11 @@ for (const [tag, ctor] of elements) {
export {
GRAPH_TAG,
IMAGE_TAG,
LINKS_TAG,
PARENT_TAG,
SCHEMA_TAG,
SeoGraphElement,
SeoLinksElement,
SeoParentElement,
SeoSchemaElement,
ROBOTS_TAG,

View File

@@ -1,5 +1,6 @@
// Element copy of the SEO field elements (UI-SPEC copywriting E1 to E6,
// E7 for the social image, E8 and E9 for structured data), chosen by the ui-locale attribute. Plural
// E7 for the social image, E8 and E9 for structured data, E10 for internal
// links), chosen by the ui-locale attribute. Plural
// texts are CLDR maps; placeholders use :name.
/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */
@@ -184,6 +185,22 @@ export interface Labels {
layerNodes: string
graphAction: string
graphFailed: string
// E10 golem15-seo-links
linksInbound: string
linksOutbound: string
linksCrawled: string
linksNotCrawled: string
linksOrphanTitle: string
linksOrphanText: string
linksOut: string
linksIn: string
linksPhrase: string
linksCopy: string
linksCopied: string
linksNone: string
linksNoRecord: string
linksChecking: string
linksFailed: string
// golem15-seo-parent
primaryDefault: string
}
@@ -353,6 +370,21 @@ const en: Labels = {
layerNodes: 'The site may add nodes of its own, such as FAQPage. The site audit checks the rendered result.',
graphAction: 'Check structured data',
graphFailed: 'The structured data could not be built. Try again.',
linksInbound: 'Links to this page',
linksOutbound: 'Links from this page',
linksCrawled: 'From the crawl of :time.',
linksNotCrawled: 'Link counts appear after the first crawl (SEO › Site audit).',
linksOrphanTitle: 'No page links here',
linksOrphanText: 'Search engines and visitors may not find this page. Link to it from one of the pages below.',
linksOut: 'Pages you could link to from this text',
linksIn: 'Pages that could link here',
linksPhrase: '“:phrase” → :title',
linksCopy: 'Copy address',
linksCopied: 'Address copied.',
linksNone: 'No suggestions. They come from titles and focus keyphrases found in the saved text.',
linksNoRecord: 'Suggestions appear after the first save.',
linksChecking: 'Loading the internal links…',
linksFailed: 'The internal links could not be loaded. Try again.',
primaryDefault: 'Default (first category)',
}
@@ -531,6 +563,21 @@ const pl: Labels = {
layerNodes: 'Witryna może dodać własne węzły, np. FAQPage. Audyt witryny sprawdza wynik na stronie.',
graphAction: 'Sprawdź dane strukturalne',
graphFailed: 'Nie udało się zbudować danych strukturalnych. Spróbuj ponownie.',
linksInbound: 'Linki do tej strony',
linksOutbound: 'Linki z tej strony',
linksCrawled: 'Według skanowania z :time.',
linksNotCrawled: 'Liczby linków pojawią się po pierwszym skanowaniu (SEO › Audyt witryny).',
linksOrphanTitle: 'Żadna strona tu nie linkuje',
linksOrphanText: 'Wyszukiwarki i odwiedzający mogą nie znaleźć tej strony. Dodaj do niej link z jednej ze stron poniżej.',
linksOut: 'Strony, do których możesz dodać link w tym tekście',
linksIn: 'Strony, które mogą tu linkować',
linksPhrase: '„:phrase” → :title',
linksCopy: 'Kopiuj adres',
linksCopied: 'Skopiowano adres.',
linksNone: 'Brak propozycji. Powstają z tytułów i fraz kluczowych znalezionych w zapisanym tekście.',
linksNoRecord: 'Propozycje pojawią się po pierwszym zapisie.',
linksChecking: 'Wczytywanie linków wewnętrznych…',
linksFailed: 'Nie udało się wczytać linków wewnętrznych. Spróbuj ponownie.',
primaryDefault: 'Domyślna (pierwsza kategoria)',
}

View File

@@ -0,0 +1,261 @@
import { ActionQueue } from './actions'
import { el, fmt, icon, labelsFor, num, uiLocaleOf, type Labels } from './labels'
export const LINKS_TAG = 'golem15-seo-links'
/** A page this text could link to. */
export interface LinkOut {
phrase: string
title: string
url: string
}
/** A page that could link here. */
export interface LinkIn {
phrase: string
title: string
adminUrl: string
}
/** The seo_links action's answer (UI-SPEC C19). */
export interface LinksData {
crawledAt: string | null
inbound: number | null
outbound: number | null
orphan: boolean
out: LinkOut[]
in: LinkIn[]
}
interface Payload {
op: 'links'
locale: string
}
/** Whether the page can write to the clipboard (a secure context). */
function canCopy(): boolean {
return typeof navigator !== 'undefined' && !!navigator.clipboard && typeof navigator.clipboard.writeText === 'function'
}
/** A crawl time in the ui-locale. */
export function crawlTime(value: string, locale: string): string {
const d = new Date(value)
if (Number.isNaN(d.getTime())) {
return value
}
try {
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(d)
} catch {
return d.toISOString()
}
}
/**
* E10 golem15-seo-links (UI-SPEC C19, D-82): the page's inbound and
* outbound link counts from the last crawl, the orphan warning, up to 10
* pages this text could link to (with Copy address) and 10 pages that
* could link here. It asks through summer-action only ({op: links,
* locale}), on connect and on the ML locale switch, and changes nothing
* itself.
*/
export class SeoLinksElement extends HTMLElement {
static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled']
private data: LinksData | null = null
private failed = false
private pending = false
private shownLocale = ''
private copied = ''
private copiedTimer: ReturnType<typeof setTimeout> | undefined
private connected = false
private readonly actions = new ActionQueue<Payload>(this, () => this.fail())
private readonly onResult = (ev: Event): void => {
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
this.actions.settle()
const data = detail?.data as LinksData | undefined
if (data && Array.isArray(data.out) && Array.isArray(data.in)) {
this.data = data
this.failed = false
}
this.pending = this.actions.current !== null
this.render()
}
private readonly onLocale = (ev: Event): void => {
const locale = (ev as CustomEvent).detail
if (typeof locale === 'string' && locale !== '' && locale !== this.shownLocale) {
this.shownLocale = locale
this.load()
}
}
connectedCallback(): void {
this.connected = true
this.addEventListener('summer-result', this.onResult)
window.addEventListener('summer:ml-locale', this.onLocale)
this.render()
queueMicrotask(() => {
if (this.connected) {
this.load()
}
})
}
disconnectedCallback(): void {
this.connected = false
this.removeEventListener('summer-result', this.onResult)
window.removeEventListener('summer:ml-locale', this.onLocale)
clearTimeout(this.copiedTimer)
this.actions.reset()
this.pending = false
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
if (!this.connected) {
return
}
if (name === 'busy' && value === null) {
this.actions.flush()
return
}
if (name === 'state' && value === 'error') {
if (this.actions.settle()) {
this.fail()
}
return
}
this.render()
}
private labels(): Labels {
return labelsFor(uiLocaleOf(this))
}
/** Asks for the links of the shown locale (one queued request at most). */
load(): void {
this.pending = true
this.actions.request({ op: 'links', locale: this.shownLocale })
this.render()
}
private fail(): void {
this.failed = true
this.pending = this.actions.current !== null
this.render()
}
/** Writes url to the clipboard and says so for 2 s. */
private copy(url: string): void {
if (!canCopy()) {
return
}
void navigator.clipboard.writeText(url).then(() => {
this.copied = url
clearTimeout(this.copiedTimer)
this.copiedTimer = setTimeout(() => {
this.copied = ''
this.render()
}, 2000)
this.render()
}, () => undefined)
}
/** The suggestion line “:phrase” → :title with the title as a link (or text). */
private line(labels: Labels, phrase: string, title: string, href: string): HTMLElement {
const line = el('span', 'seo-links__line')
const [before, after = ''] = labels.linksPhrase.split(':title')
line.append(fmt(before ?? '', { phrase }))
if (href) {
const a = el('a', 'seo-links__title', title)
a.href = href
a.target = '_blank'
a.rel = 'noopener'
line.append(a)
} else {
line.append(el('span', 'seo-links__title', title))
}
line.append(fmt(after, { phrase }))
return line
}
private render(): void {
if (!this.connected) {
return
}
const labels = this.labels()
const locale = uiLocaleOf(this)
const sections: HTMLElement[] = []
this.setAttribute('aria-busy', String(this.pending))
if (this.pending) {
const line = el('p', 'seo-links__meta seo-links__checking', labels.linksChecking)
line.setAttribute('role', 'status')
sections.push(line)
}
const data = this.data
if (data) {
const counts = el('div', 'seo-links__counts')
if (data.crawledAt !== null && data.inbound !== null && data.outbound !== null) {
const stats = el('div', 'summer-stats')
for (const [value, label] of [
[data.inbound, labels.linksInbound],
[data.outbound, labels.linksOutbound],
] as Array<[number, string]>) {
const stat = el('div', 'summer-stat')
stat.append(el('span', 'summer-stat__value', num(value, locale)), el('span', 'summer-stat__label', label))
stats.append(stat)
}
counts.append(stats, el('p', 'seo-links__meta', fmt(labels.linksCrawled, { time: crawlTime(data.crawledAt, locale) })))
} else {
counts.append(el('p', 'seo-links__meta seo-links__not-crawled', labels.linksNotCrawled))
}
sections.push(counts)
if (data.orphan) {
const callout = el('div', 'summer-callout summer-callout--warning seo-links__orphan')
callout.append(el('p', 'summer-callout__title', labels.linksOrphanTitle), el('p', 'summer-callout__text', labels.linksOrphanText))
sections.push(callout)
}
const copyable = canCopy()
const out = el('div', 'seo-links__list seo-links__list--out')
out.append(el('p', 'seo-links__heading', labels.linksOut))
for (const s of data.out.slice(0, 10)) {
const row = el('div', 'seo-links__row')
row.append(this.line(labels, s.phrase, s.title, s.url))
if (copyable && !this.hasAttribute('disabled')) {
const button = el('button', 'seo-links__copy', labels.linksCopy)
button.type = 'button'
button.addEventListener('click', () => this.copy(s.url))
row.append(button)
} else {
row.append(el('code', 'seo-links__url', s.url))
}
out.append(row)
}
if (data.out.length === 0) {
out.append(el('p', 'seo-links__meta seo-links__none', labels.linksNone))
}
sections.push(out)
const inbound = el('div', 'seo-links__list seo-links__list--in')
inbound.append(el('p', 'seo-links__heading', labels.linksIn))
for (const s of data.in.slice(0, 10)) {
const row = el('div', 'seo-links__row')
row.append(this.line(labels, s.phrase, s.title, s.adminUrl))
inbound.append(row)
}
if (data.in.length === 0) {
inbound.append(el('p', 'seo-links__meta seo-links__none', labels.linksNone))
}
sections.push(inbound)
}
if (this.failed) {
const line = el('p', 'seo-links__error')
line.setAttribute('role', 'status')
line.append(icon('circle-alert'), el('span', '', labels.linksFailed))
sections.push(line)
}
const status = el('p', 'seo-links__status', this.copied ? labels.linksCopied : '')
status.setAttribute('role', 'status')
sections.push(status)
this.replaceChildren(...sections)
}
}

View File

@@ -1126,3 +1126,116 @@ golem15-seo-graph .seo-graph__button {
golem15-seo-graph .seo-graph__button:hover {
background: var(--c-subtle);
}
/* E10 golem15-seo-links (UI-SPEC C19) */
golem15-seo-links {
display: flex;
flex-direction: column;
gap: 24px;
}
golem15-seo-links .seo-links__counts,
golem15-seo-links .seo-links__list {
display: flex;
flex-direction: column;
gap: 8px;
}
golem15-seo-links .seo-links__counts {
gap: 16px;
}
golem15-seo-links .seo-links__meta {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--c-muted);
}
golem15-seo-links .seo-links__heading {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
color: var(--c-text);
}
golem15-seo-links .seo-links__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 14px;
line-height: 1.5;
color: var(--c-text);
}
@media (max-width: 600px) {
golem15-seo-links .seo-links__row {
flex-direction: column;
align-items: flex-start;
}
}
golem15-seo-links .seo-links__line {
min-width: 0;
overflow-wrap: anywhere;
}
golem15-seo-links .seo-links__title {
color: var(--c-text);
text-decoration: underline;
}
golem15-seo-links .seo-links__url {
font-family: 'DM Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: var(--c-muted);
user-select: all;
overflow-wrap: anywhere;
}
golem15-seo-links .seo-links__copy {
flex: none;
height: 34px;
padding: 0 12px;
border: 1px solid var(--c-border-strong);
border-radius: 8px;
background: var(--c-surface);
color: var(--c-text);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms;
}
golem15-seo-links .seo-links__copy:hover {
background: var(--c-subtle);
}
golem15-seo-links .seo-links__copy:focus-visible,
golem15-seo-links .seo-links__title:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--c-ring);
}
golem15-seo-links .seo-links__error {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--c-danger);
}
golem15-seo-links .seo-links__status {
margin: 0;
font-size: 13px;
color: var(--c-muted);
}
golem15-seo-links .seo-links__status:empty {
display: none;
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/hex"
"strings"
"git.golem15.com/golem15/sm-seo-plugin/classes"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -142,4 +143,4 @@ func Fingerprint(kind, url, locale, check string) string {
}
// normal is text compared without case and extra whitespace.
func normal(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }
func normal(s string) string { return classes.NormalText(s) }

364
classes/linkgraph.go Normal file
View File

@@ -0,0 +1,364 @@
package classes
import (
"context"
"encoding/json"
"errors"
"net/url"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-seo-plugin/models"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
// MaxLinkSuggestions caps each suggestion list of the internal links
// widget (UI-SPEC C19).
const MaxLinkSuggestions = 10
// minLinkPhrase is the shortest phrase a link suggestion matches, in
// bytes (the orphan suggestions' limit).
const minLinkPhrase = 3
// LinksActionName is the SEO tab's internal links widget action (E10).
const LinksActionName = "seo_links"
// LinkOut is a page this text could link to: the phrase found in this
// page's crawled text, the target's title and its public address.
type LinkOut struct {
Phrase string `json:"phrase"`
Title string `json:"title"`
URL string `json:"url"`
}
// LinkIn is a page that could link here: the phrase of this page found in
// its crawled text, its title and its admin form ("" when no record of
// the admin is behind it).
type LinkIn struct {
Phrase string `json:"phrase"`
Title string `json:"title"`
AdminURL string `json:"adminUrl"`
}
// LinkReport is the internal links of a page from the last finished crawl
// (D-82, UI-SPEC C19): the time of that crawl, the pages linking here and
// the pages linked from here (both nil before the first crawl), whether
// nothing links to the page, and up to 10 pages this text could link to
// and 10 that could link here.
type LinkReport struct {
CrawledAt *time.Time `json:"crawledAt"`
Inbound *int `json:"inbound"`
Outbound *int `json:"outbound"`
Orphan bool `json:"orphan"`
Out []LinkOut `json:"out"`
In []LinkIn `json:"in"`
}
// NormalText is text in lower case with its whitespace collapsed to one
// space: the form the crawler's suggestions compare phrases in.
func NormalText(s string) string { return strings.ToLower(strings.Join(strings.Fields(s), " ")) }
// errEnough stops a batched scan once a list is full.
var errEnough = errors.New("golem15.seo: enough suggestions")
// lastFinishedCrawl is the newest finished crawl, nil before the first.
func lastFinishedCrawl(db *gorm.DB) (*models.Crawl, error) {
var rows []models.Crawl
if err := db.Where("status = ?", models.CrawlFinished).Order("finished_at DESC NULLS LAST, id DESC").Limit(1).Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
return &rows[0], nil
}
// pageHeading is a crawled page's first H1, else its title.
func pageHeading(p models.CrawlPage) (h1, title string) {
if list := p.H1.Get(); len(list) > 0 {
h1 = strings.TrimSpace(list[0])
}
title = h1
if title == "" {
title = strings.TrimSpace(p.Title)
}
return h1, title
}
// entryKeyphrases maps each address with an SEO entry to its focus
// keyphrase.
func entryKeyphrases(db *gorm.DB) (map[string]string, error) {
var rows []struct {
URL string
Keyphrase string
}
if err := db.Raw(`SELECT url, keyphrase FROM golem15_seo_entries WHERE url <> '' AND keyphrase <> ''`).Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]string, len(rows))
for _, r := range rows {
out[r.URL] = r.Keyphrase
}
return out, nil
}
// phraseList keeps the phrases of at least 3 bytes, once each (by their
// normal form), in order.
func phraseList(phrases ...string) []string {
var out []string
seen := map[string]bool{}
for _, p := range phrases {
p = strings.Join(strings.Fields(p), " ")
n := NormalText(p)
if len(n) < minLinkPhrase || seen[n] {
continue
}
seen[n] = true
out = append(out, p)
}
return out
}
// mentioned returns the first phrase whose normal form normal (a page's
// NormalText) contains, "" when none.
func mentioned(normal string, phrases []string) string {
for _, p := range phrases {
if strings.Contains(normal, NormalText(p)) {
return p
}
}
return ""
}
// adminURLOf is the admin form of the record behind a site path, "" when
// no content type with an admin controller owns it.
func (s *Service) adminURLOf(ctx context.Context, path string) string {
reg := s.Registry()
if reg == nil {
return ""
}
p, _, _ := strings.Cut(path, "?")
key, match, err := reg.MatchPath(ctx, p, s.UnprefixedLocale())
if err != nil || match == nil || !match.Exact {
return ""
}
typ, ok := reg.ContentType(key)
if !ok || typ.Content == nil || strings.TrimSpace(typ.Content.Controller) == "" {
return ""
}
return s.adminFormURL(typ.Content.Controller, match.ID)
}
// LinkSuggestions builds the internal links of the page at path from the
// last finished crawl (D-82, UI-SPEC C19), like the crawl's orphan
// suggestions: the pages whose crawled text contains one of phrases (the
// page's title and focus keyphrase; its crawled H1 is added) and do not
// link here yet, and the pages whose H1 or focus keyphrase this page's
// crawled text contains and that it does not link to yet. Before the
// first crawl the counts are nil and the lists empty.
func (s *Service) LinkSuggestions(ctx context.Context, db *gorm.DB, path string, phrases []string) (LinkReport, error) {
out := LinkReport{Out: []LinkOut{}, In: []LinkIn{}}
db = db.WithContext(ctx)
c, err := lastFinishedCrawl(db)
if err != nil || c == nil {
return out, err
}
out.CrawledAt = c.FinishedAt
var links []models.CrawlLink
if err := db.Select("from_url", "to_url").Where("crawl_id = ? AND internal AND from_url <> to_url AND (from_url = ? OR to_url = ?)", c.ID, path, path).Find(&links).Error; err != nil {
return out, err
}
linkedFrom, linksTo := map[string]bool{}, map[string]bool{}
for _, l := range links {
if l.FromURL == path {
linksTo[l.ToURL] = true
} else {
linkedFrom[l.FromURL] = true
}
}
inbound, outbound := len(linkedFrom), len(linksTo)
out.Inbound, out.Outbound = &inbound, &outbound
var self []models.CrawlPage
if err := db.Where("crawl_id = ? AND url = ?", c.ID, path).Limit(1).Find(&self).Error; err != nil {
return out, err
}
keyphrases, err := entryKeyphrases(db)
if err != nil {
return out, err
}
site := s.SiteURL()
if len(self) > 0 {
out.Orphan = inbound == 0 && path != "/"
h1, _ := pageHeading(self[0])
phrases = append(phrases, h1)
text := NormalText(self[0].Text)
var others []models.CrawlPage
if err := db.Select("id", "url", "title", "h1").Where("crawl_id = ? AND url <> ? AND status = 200", c.ID, path).Order("url ASC").Find(&others).Error; err != nil {
return out, err
}
for _, o := range others {
if len(out.Out) >= MaxLinkSuggestions {
break
}
if linksTo[o.URL] {
continue
}
oh1, title := pageHeading(o)
if p := mentioned(text, phraseList(oh1, keyphrases[o.URL])); p != "" {
out.Out = append(out.Out, LinkOut{Phrase: p, Title: title, URL: site + o.URL})
}
}
}
own := phraseList(append(phrases, keyphrases[path])...)
if len(own) == 0 {
return out, nil
}
var batch []models.CrawlPage
err = db.Select("id", "url", "title", "h1", "text").Where("crawl_id = ? AND url <> ? AND status = 200", c.ID, path).
FindInBatches(&batch, 100, func(*gorm.DB, int) error {
for _, p := range batch {
if len(out.In) >= MaxLinkSuggestions {
return errEnough
}
if linkedFrom[p.URL] {
continue
}
if phrase := mentioned(NormalText(p.Text), own); phrase != "" {
_, title := pageHeading(p)
out.In = append(out.In, LinkIn{Phrase: phrase, Title: title, AdminURL: s.adminURLOf(ctx, p.URL)})
}
}
return nil
}).Error
if err != nil && !errors.Is(err, errEnough) {
return out, err
}
return out, nil
}
// sitePath is the path of a path or an absolute URL; ErrBadURL otherwise.
func sitePath(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if strings.HasPrefix(raw, "/") && !strings.HasPrefix(raw, "//") {
path, _, _ := strings.Cut(raw, "#")
return path, nil
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", ErrBadURL
}
path := u.EscapedPath()
if path == "" {
path = "/"
}
if u.RawQuery != "" {
path += "?" + u.RawQuery
}
return path, nil
}
// LinksOfURL is the internal links of a page addressed by URL (a path or
// an absolute URL of the site): the record behind it gives its title in
// the matched locale.
func (s *Service) LinksOfURL(ctx context.Context, db *gorm.DB, raw string) (LinkReport, error) {
path, err := sitePath(raw)
if err != nil {
return LinkReport{}, err
}
var phrases []string
if reg := s.Registry(); reg != nil {
p, _, _ := strings.Cut(path, "?")
if key, match, err := reg.MatchPath(ctx, p, s.UnprefixedLocale()); err == nil && match != nil && match.Exact {
if rec, err := reg.Describe(ctx, key, match.ID, s.UnprefixedLocale()); err == nil && rec != nil {
for _, l := range rec.Locales {
if l.Locale == match.Locale {
phrases = append(phrases, l.Title)
}
}
}
}
}
return s.LinkSuggestions(ctx, db, path, phrases)
}
// LinksOfRecord is the internal links of record id of content type key in
// locale: its address and title in that locale and its focus keyphrase. A
// record without an address in the locale answers the crawl time only.
func (s *Service) LinksOfRecord(ctx context.Context, db *gorm.DB, key, id, locale string) (LinkReport, error) {
reg := s.Registry()
if reg == nil {
return LinkReport{Out: []LinkOut{}, In: []LinkIn{}}, nil
}
rec, err := reg.Describe(ctx, key, id, s.UnprefixedLocale())
if err != nil {
return LinkReport{}, err
}
var loc *pact.ContentLocale
if rec != nil {
for i := range rec.Locales {
if rec.Locales[i].Locale == locale {
loc = &rec.Locales[i]
}
}
}
if loc == nil || strings.TrimSpace(loc.URL) == "" {
out := LinkReport{Out: []LinkOut{}, In: []LinkIn{}}
c, err := lastFinishedCrawl(db.WithContext(ctx))
if err == nil && c != nil {
out.CrawledAt = c.FinishedAt
}
return out, err
}
phrases := []string{loc.Title}
if typ, ok := reg.ContentType(key); ok && typ.Content != nil {
if entry, found, err := LoadEntry(db, typ.Content.Model, id, locale); err == nil && found {
phrases = append(phrases, entry.Keyphrase)
}
}
return s.LinkSuggestions(ctx, db, loc.URL, phrases)
}
// linksPayload is the seo_links action's payload.
type linksPayload struct {
Op string `json:"op"`
Locale string `json:"locale"`
}
// RecordLinksAction is the SEO tab's internal links widget action (E10,
// UI-SPEC C19) for content type key: {op: links, locale} answers the
// record's LinkReport in that locale (the default when empty).
func RecordLinksAction(svc func() *Service, key, label, permission string) pact.AdminAction {
return pact.AdminAction{
Name: LinksActionName,
Label: label,
Permissions: []string{permission},
Run: func(ctx context.Context, in pact.AdminActionInput) (pact.AdminActionResult, error) {
s := svc()
if s == nil {
return pact.AdminActionResult{}, ErrNotBooted
}
var p linksPayload
if len(in.Payload) > 0 {
if err := json.Unmarshal(in.Payload, &p); err != nil || (p.Op != "" && p.Op != "links") {
return pact.AdminActionResult{}, &cabana.ValidationError{Details: map[string]any{in.Field: []string{"golem15.seo::lang.api.value_invalid"}}}
}
}
db, err := s.DB(ctx)
if err != nil {
return pact.AdminActionResult{}, err
}
if in.RecordID == nil {
return pact.AdminActionResult{Data: LinkReport{Out: []LinkOut{}, In: []LinkIn{}}}, nil
}
locale := p.Locale
if locale == "" {
locale = s.DefaultLocale(ctx, db)
}
report, err := s.LinksOfRecord(ctx, db, key, strconv.FormatUint(*in.RecordID, 10), locale)
return pact.AdminActionResult{Data: report}, err
},
}
}

33
controllers/api/links.go Normal file
View File

@@ -0,0 +1,33 @@
package api
import (
"context"
"net/http"
"git.golem15.com/golem15/sm-seo-plugin/classes"
)
// GetLinksInput is the query of GET /_seo/api/v1/links.
type GetLinksInput struct {
URL string `json:"url" query:"url" jsonschema:"the page: a path such as /blog/post or an absolute URL of the site"`
}
// Links serves GET /_seo/api/v1/links?url= (D-82, UI-SPEC C19): the
// page's internal links from the last finished crawl, {crawledAt,
// inbound, outbound, orphan, out [{phrase, title, url}], in [{phrase,
// title, adminUrl}]}; before the first crawl crawledAt, inbound and
// outbound are null and the lists empty. A url that is not a path or an
// http(s) URL is 422.
func Links(service func() *classes.Service) http.HandlerFunc {
return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) {
db, err := read(ctx, svc)
if err != nil {
return 0, nil, err
}
report, err := svc.LinksOfURL(ctx, db, r.URL.Query().Get("url"))
if err != nil {
return 0, nil, err
}
return http.StatusOK, report, nil
})
}

View File

@@ -235,10 +235,32 @@ fields:
tab: golem15.seo::lang.tab.seo
`
// seoLinksYAML is E10, the internal links widget (UI-SPEC C16, C19).
const seoLinksYAML = ` seo_links:
label: golem15.seo::lang.field.links
type: widget
widget: golem15-seo-links
action: seo_links
span: full
context: update
tab: golem15.seo::lang.tab.seo
`
// hasBody reports whether records of a content type of kind have a body
// to add links to (UI-SPEC C16 row 14): collections and profiles do not,
// nor do the registry's pages, which the site renders itself.
func hasBody(key, kind string) bool {
if key == classes.PageTypeKey {
return false
}
return kind != pact.ContentKindCollection && kind != pact.ContentKindProfile
}
// seoTabFor is the SEO tab's fields document of a content type (UI-SPEC
// C16): seoTabYAML with the primary category after seo_robots for article
// types (whose records have parents, P33-23), and the structured data
// field (E8) and its preview (E9) after seo_og_image.
// types (whose records have parents, P33-23), the structured data field
// (E8) and its preview (E9) after seo_og_image, then the internal links
// widget (E10) for types with a body.
func seoTabFor(key, kind string) string {
doc := seoTabYAML
quoted := strconv.Quote(key)
@@ -254,6 +276,10 @@ func seoTabFor(key, kind string) string {
seo_og_title:
`, 1)
}
links := ""
if hasBody(key, kind) {
links = seoLinksYAML
}
return strings.Replace(doc, " seo_effective:\n", ` seo_schema:
label: golem15.seo::lang.field.schema
comment: golem15.seo::lang.field.schema_comment
@@ -272,7 +298,7 @@ func seoTabFor(key, kind string) string {
span: full
context: update
tab: golem15.seo::lang.tab.seo
seo_effective:
`+links+` seo_effective:
`, 1)
}
@@ -288,6 +314,7 @@ func (p *Plugin) seoTab(key, kind string) *pact.AdminFormExtension {
Actions: []pact.AdminAction{
classes.RecordSnippetAction(p.Service, key, "golem15.seo::lang.snippet.action", controllers.PermissionManage),
classes.RecordGraphAction(p.Service, key, "golem15.seo::lang.graph.action", controllers.PermissionManage),
classes.RecordLinksAction(p.Service, key, "golem15.seo::lang.field.links", controllers.PermissionManage),
},
PartialData: func(ctx context.Context, name string, record any) (any, error) {
return p.seoTabPartial(ctx, key, name, record)

View File

@@ -20,6 +20,7 @@ permissions:
tab:
seo: SEO
field:
links: Internal links
snippet: Search result preview
description_comment: Leave empty to use the description template, then the content's own excerpt.
keyphrase: Focus keyphrase

View File

@@ -20,6 +20,7 @@ permissions:
tab:
seo: SEO
field:
links: Linki wewnętrzne
snippet: Podgląd w wynikach wyszukiwania
description_comment: Zostaw puste, aby użyć szablonu opisu, a potem zajawki treści.
keyphrase: Fraza kluczowa

View File

@@ -222,3 +222,117 @@ func TestAutolinks(t *testing.T) {
t.Fatalf("history vias = %v", vias)
}
}
// linkReport is GET /_seo/api/v1/links and the seo_links action's answer.
type linkReport struct {
CrawledAt *string `json:"crawledAt"`
Inbound *int `json:"inbound"`
Outbound *int `json:"outbound"`
Orphan bool `json:"orphan"`
Out []struct {
Phrase string `json:"phrase"`
Title string `json:"title"`
URL string `json:"url"`
} `json:"out"`
In []struct {
Phrase string `json:"phrase"`
Title string `json:"title"`
AdminURL string `json:"adminUrl"`
} `json:"in"`
}
func (e *testEnv) links(t *testing.T, token, path string) linkReport {
t.Helper()
var out linkReport
if err := json.Unmarshal(e.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/links?url="+urlQueryEscape(path), token, nil).Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
// TestLinkSuggestions covers E10's answer (UI-SPEC C19, D-82): before the
// first crawl the counts are null and the lists empty; after it, the
// inbound and outbound counts, the orphan state, the pages this text
// mentions by H1 or focus keyphrase and does not link to, and the pages
// whose text mentions this page's title or focus keyphrase and do not link
// here, with their admin forms; the SEO tab's seo_links widget answers the
// same, and E10 is in the SEO tab after the graph preview.
func TestLinkSuggestions(t *testing.T) {
env := bootCrawlEnv(t)
token := env.admin(t, true)
hericium := env.createPage(t, token, map[string]any{"title": "Hericium", "slug": "hericium", "published": true})
env.createPage(t, token, map[string]any{"title": "Shiitake", "slug": "shiitake", "published": true})
oyster := env.createPage(t, token, map[string]any{"title": "Oyster", "slug": "oyster", "published": true})
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/pages/hericium", "values": map[string]any{"keyphrase": "lwia grzywa"}})
env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/pages/oyster", "values": map[string]any{"keyphrase": "boczniak"}})
before := env.links(t, token, "/pages/hericium")
if before.CrawledAt != nil || before.Inbound != nil || before.Outbound != nil || before.Orphan || len(before.Out) != 0 || len(before.In) != 0 {
t.Fatalf("before the first crawl = %+v", before)
}
site := newFakeSite()
site.sitemap("/", "/pages/hericium", "/pages/shiitake", "/pages/oyster")
site.plant(
doc{path: "/", title: "Home", links: []string{"/pages/shiitake", "/pages/oyster"}},
doc{path: "/pages/hericium", title: "Hericium", body: "<p>Hericium grows near shiitake and boczniak.</p>"},
doc{path: "/pages/shiitake", title: "Shiitake", body: "<p>Shiitake differs from lwia grzywa.</p>", links: []string{"/pages/oyster"}},
doc{path: "/pages/oyster", title: "Oyster", body: "<p>Oyster tastes almost like Hericium.</p>", links: []string{"/pages/shiitake"}},
)
env.crawlOnce(t, token, siteCtx(t, site))
got := env.links(t, token, siteURL+"/pages/hericium")
if got.CrawledAt == nil || got.Inbound == nil || *got.Inbound != 0 || got.Outbound == nil || *got.Outbound != 0 || !got.Orphan {
t.Fatalf("hericium counts = %+v", got)
}
outs := map[string]string{}
for _, o := range got.Out {
outs[o.URL] = o.Phrase + "|" + o.Title
}
if len(got.Out) != 2 || outs[siteURL+"/pages/shiitake"] != "Shiitake|Shiitake" || outs[siteURL+"/pages/oyster"] != "boczniak|Oyster" {
t.Fatalf("hericium out = %+v", got.Out)
}
ins := map[string]string{}
for _, i := range got.In {
ins[i.Title] = i.Phrase + "|" + i.AdminURL
}
if len(got.In) != 2 || ins["Shiitake"] == "" || !strings.HasPrefix(ins["Shiitake"], "lwia grzywa|/backend/acme/pages/pages/update/") || ins["Oyster"] != "Hericium|/backend/acme/pages/pages/update/"+oyster {
t.Fatalf("hericium in = %+v", got.In)
}
// Shiitake: linked from home and oyster, links to oyster (not
// suggested again although its text names no other page).
sh := env.links(t, token, "/pages/shiitake")
if *sh.Inbound != 2 || *sh.Outbound != 1 || sh.Orphan {
t.Fatalf("shiitake counts = %+v", sh)
}
if len(sh.Out) != 1 || sh.Out[0].URL != siteURL+"/pages/hericium" || sh.Out[0].Phrase != "lwia grzywa" {
t.Fatalf("shiitake out = %+v", sh.Out)
}
if len(sh.In) != 1 || sh.In[0].Title != "Hericium" || sh.In[0].AdminURL != "/backend/acme/pages/pages/update/"+hericium {
t.Fatalf("shiitake in = %+v", sh.In)
}
if rec := env.call(t, http.MethodGet, "/_seo/api/v1/links?url=javascript:x", token, nil); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("a bad url = %d %s", rec.Code, rec.Body.String())
}
// The SEO tab: E10 after the graph preview, its action answers the
// same report.
findField(t, env.formSchema(t, token, "acme/pages/pages"), "seo_links")
n, _ := strconv.Atoi(hericium)
rec := env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/acme/pages/pages/widgets/seo_links"), token, map[string]any{"record_id": n, "payload": map[string]any{"op": "links", "locale": "en"}})
var widget struct {
Data struct {
Data linkReport `json:"data"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &widget); err != nil {
t.Fatal(err)
}
if w := widget.Data.Data; !w.Orphan || len(w.Out) != 2 || len(w.In) != 2 || w.Inbound == nil {
t.Fatalf("widget answer = %s", rec.Body.String())
}
if rec := env.call(t, http.MethodPost, adminAPI("/acme/pages/pages/widgets/seo_links"), token, map[string]any{"record_id": n, "payload": map[string]any{"op": "graph"}}); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("unknown op = %d %s", rec.Code, rec.Body.String())
}
}

11
mcp.go
View File

@@ -59,6 +59,17 @@ func (p *Plugin) MCP() pact.MCP {
Permissions: []string{mcpPermission},
},
{
Name: "get_link_suggestions",
Title: "Get the internal links of a URL",
Description: "Shows a page's internal links from the last finished crawl: how many pages link to it (inbound) and how many it links to (outbound), whether nothing links to it (orphan), up to 10 pages its text could link to (their title or focus keyphrase appears in it) and up to 10 pages that could link to it (its title or focus keyphrase appears in theirs). Before the first crawl crawledAt, inbound and outbound are null; start one with seo_start_crawl. Add a link by editing the text, or link a phrase on every page with seo_create_autolink.",
Guard: mcpGuard,
Method: http.MethodGet,
Path: "/_seo/api/v1/links",
Input: apicontrollers.GetLinksInput{},
Permissions: []string{mcpPermission},
},
// URL rules (D-02).
{
Name: "list_rules",

View File

@@ -54,6 +54,7 @@ var AdminOperations = []AdminOperation{
{ContentController, OpExtensionSave, "set_url_seo"},
{ContentController, OpWidgetAction + ":seo_snippet", "get_url_seo"},
{ContentController, OpWidgetAction + ":seo_graph", "get_graph"},
{ContentController, OpWidgetAction + ":seo_links", "get_link_suggestions"},
// The social image is set by URL through MCP (uploads are no tool).
{ContentController, OpUpload, "set_url_seo"},

View File

@@ -50,6 +50,7 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Get("/seo", apicontrollers.GetSEO(p.Service), api)
g.Put("/seo", apicontrollers.SetSEO(p.Service), api)
g.Get("/graph", apicontrollers.Graph(p.Service), api)
g.Get("/links", apicontrollers.Links(p.Service), api)
g.Get("/rules", apicontrollers.ListRules(p.Service), api)
g.Post("/rules", apicontrollers.CreateRule(p.Service), api)
g.Get("/rules/{id}", apicontrollers.ShowRule(p.Service), api)

View File

@@ -370,7 +370,7 @@ func TestSEOTab(t *testing.T) {
env := bootEnv(t)
token := env.admin(t, true)
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_schema seo_graph seo_effective"
want := "seo_status seo_snippet seo_title seo_description seo_keyphrase seo_canonical seo_robots seo_og_title seo_og_description seo_og_image seo_schema seo_graph seo_links seo_effective"
fields := env.formSchema(t, token, "acme/pages/pages")
var seoFields []string
for _, f := range fields {

View File

@@ -0,0 +1,134 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { labelsFor } from '../../assets/src/elements/labels'
import type { LinksData } from '../../assets/src/elements/links'
import { HostFailure, mountWidget, settle } from './host'
// E10 golem15-seo-links (UI-SPEC C19, D-82).
beforeAll(async () => {
await import('../../assets/src/elements/index')
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
document.body.replaceChildren()
})
const en = labelsFor('en')
const crawled: LinksData = {
crawledAt: '2026-11-04T10:00:00Z',
inbound: 0,
outbound: 3,
orphan: true,
out: [{ phrase: 'shiitake', title: 'Shiitake', url: 'https://example.com/pages/shiitake' }],
in: [{ phrase: 'lwia grzywa', title: 'Oyster', adminUrl: '/backend/acme/pages/pages/update/3' }],
}
function stubClipboard(): { writes: string[] } {
const writes: string[] = []
vi.stubGlobal('navigator', {
...navigator,
clipboard: {
writeText: (text: string) => {
writes.push(text)
return Promise.resolve()
},
},
})
return { writes }
}
describe('E10 golem15-seo-links', () => {
it('asks for the links of the shown locale on connect and renders counters, the orphan callout and both lists', async () => {
stubClipboard()
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
expect(host.posted).toEqual([{ op: 'links', locale: '' }])
const node = host.node
const values = [...node.querySelectorAll('.summer-stat__value')].map((n) => n.textContent)
expect(values).toEqual(['0', '3'])
expect([...node.querySelectorAll('.summer-stat__label')].map((n) => n.textContent)).toEqual([en.linksInbound, en.linksOutbound])
expect(node.textContent).toContain('From the crawl of ')
expect(node.querySelector('.summer-callout--warning')!.textContent).toContain(en.linksOrphanTitle)
const headings = [...node.querySelectorAll('.seo-links__heading')].map((n) => n.textContent)
expect(headings).toEqual([en.linksOut, en.linksIn])
const out = node.querySelector('.seo-links__list--out .seo-links__row')!
expect(out.textContent).toContain('“shiitake” → Shiitake')
const link = out.querySelector('a')!
expect(link.getAttribute('href')).toBe('https://example.com/pages/shiitake')
expect(link.target).toBe('_blank')
expect(link.rel).toBe('noopener')
const inbound = node.querySelector('.seo-links__list--in .seo-links__row a')!
expect(inbound.getAttribute('href')).toBe('/backend/acme/pages/pages/update/3')
expect(node.querySelector('.seo-links__none')).toBeNull()
expect(node.getAttribute('aria-busy')).toBe('false')
})
it('shows notCrawled instead of counters before a crawl and none under empty lists', async () => {
const host = mountWidget('golem15-seo-links', {
attrs: { 'ui-locale': 'en' },
answer: () => ({ crawledAt: null, inbound: null, outbound: null, orphan: false, out: [], in: [] }),
})
await settle()
const node = host.node
expect(node.querySelector('.summer-stats')).toBeNull()
expect(node.textContent).toContain(en.linksNotCrawled)
expect(node.querySelector('.summer-callout')).toBeNull()
expect(node.querySelectorAll('.seo-links__none')).toHaveLength(2)
expect(node.querySelector('.seo-links__none')!.textContent).toBe(en.linksNone)
})
it('shows the checking line with aria-busy while loading and the danger line on failure', async () => {
const host = mountWidget('golem15-seo-links', {
attrs: { 'ui-locale': 'pl' },
answer: () => {
throw new HostFailure('down')
},
})
await Promise.resolve()
expect(host.node.getAttribute('aria-busy')).toBe('true')
expect(host.node.querySelector('.seo-links__checking')!.getAttribute('role')).toBe('status')
await settle()
const error = host.node.querySelector('.seo-links__error')!
expect(error.textContent).toBe(labelsFor('pl').linksFailed)
expect(error.getAttribute('role')).toBe('status')
expect(host.node.getAttribute('aria-busy')).toBe('false')
})
it('copies the address and says so for two seconds', async () => {
const clip = stubClipboard()
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
vi.useFakeTimers()
const button = host.node.querySelector<HTMLButtonElement>('.seo-links__copy')!
expect(button.textContent).toBe(en.linksCopy)
button.click()
await Promise.resolve()
await Promise.resolve()
expect(clip.writes).toEqual(['https://example.com/pages/shiitake'])
expect(host.node.querySelector('.seo-links__status')!.textContent).toBe(en.linksCopied)
vi.advanceTimersByTime(2000)
expect(host.node.querySelector('.seo-links__status')!.textContent).toBe('')
})
it('hides Copy address without a clipboard and keeps the address as text', async () => {
vi.stubGlobal('navigator', { ...navigator, clipboard: undefined })
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
expect(host.node.querySelector('.seo-links__copy')).toBeNull()
expect(host.node.querySelector('.seo-links__url')!.textContent).toBe('https://example.com/pages/shiitake')
})
it('asks again on the ML locale switch', async () => {
const host = mountWidget('golem15-seo-links', { attrs: { 'ui-locale': 'en' }, answer: () => crawled })
await settle()
window.dispatchEvent(new CustomEvent('summer:ml-locale', { detail: 'pl' }))
await settle()
expect(host.posted).toEqual([
{ op: 'links', locale: '' },
{ op: 'links', locale: 'pl' },
])
})
})