diff --git a/README.md b/README.md index b98040e..dd54c46 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t - **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 `//N`). -- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, robots.txt, settings, history and the site audit, in the admin and through MCP. +- **One permission**, `golem15.seo.manage`, for the SEO tab, rules, redirects, the 404 log, pages, auto-links, robots.txt, settings, history and the site audit, in the admin and through MCP. - **Management API and MCP.** Every SEO admin operation has a JSON route under `/_seo/api/v1` and an MCP tool bound to it (pier, prefix `seo_`). Tools address pages by URL: a change goes to the record behind the URL, else to an exact URL rule, and the answer says where. A read grant sees only the read tools. `AdminOperations` maps each admin operation to its tool, and a test fails when one has none. - **Site audit** (SEO › **Site audit** and **Crawls**): a built-in crawler fetches the site's own pages like a search engine and reports per URL what it finds (statuses, sitemaps, canonicals, titles and descriptions, headings, structured data, hreflang, links, images, content that needs JavaScript, orphan pages), stored with the CMS-data checks: missing and duplicate titles and descriptions, URL rules overriding a record, redirect chains, canonicals pointing to a noindex or redirected address, noindex addresses robots.txt blocks, sitemap addresses the production robots.txt keeps Googlebot from (`sitemap_blocked`, severity `error`, with the deciding line), record images without alt text and H1 headings in content. Every issue names the MCP tool that fixes it, or `fix: code` with the template element to change. See [Site audit and crawler](#site-audit-and-crawler). - **Sitemap filter.** The sitemaps leave out every address whose effective SEO is noindex, whose canonical URL points elsewhere or that redirects (normalisation or a redirect rule), with the reason `noindex`, `canonical` or `redirect`. The filter decides on the production values, so staging and production list the same set. The SEO tab's status shows **Not in the sitemaps** with the reason, also when the content type is switched off in the sitemap settings. @@ -50,6 +50,7 @@ The frontend (a Nuxt layer or app) asks `GET /_seo/api/v1/resolve?url=...` for t - **Route report.** The frontend server reports its static routes; unknown ones become registry pages, a reported page missing from a newer report is marked **Not on the site**, and nothing is ever deleted or overwritten. - **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). - **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 @@ -111,12 +112,16 @@ The type then gets the SEO tab, a tab in the SEO settings and a place in the res "twitter": {"card": "summary_large_image", "site": "@acme"}, "verification": [] }, - "outbound": {"new_tab": true, "nofollow_domains": ["example.org"], "sponsored_domains": []} + "outbound": {"new_tab": true, "nofollow_domains": ["example.org"], "sponsored_domains": []}, + "autolinks": [{"phrase": "lwia grzywa", "url": "/grzyby/lwia-grzywa"}], + "autolink_max": 3 } ``` `outbound` (present on `ok` answers) carries the settings' Links tab: open links to other sites in a new tab, and the domains (subdomains included) whose links always get `nofollow` or `sponsored`. The layer treats an absent key as empty. +`autolinks` and `autolink_max` (present on `ok` answers) are the auto-link phrases of the page's locale and of every locale, active ones only, without those linking to the page itself, longest phrase first, at most 1000; `autolink_max` is the most the layer links in one text. With `autolink_max` 0 the answer carries no phrases. + The pipeline runs normalisation, then redirects on the normalised path (followed to the final target), then strict URLs, then the SEO. When the URL is not in its final form the answer is a redirect, reached in one hop: ```json @@ -189,6 +194,12 @@ The **robots.txt** screen (a singleton) has the editor, the tester and **What cr `POST /_seo/api/v1/registry/routes` takes `{"routes": ["/", "/kontakt", "/o-nas"]}` from the frontend server with `Authorization: Bearer `: `golem15.seo.route_report_token`, else `golem15.seo.notfound_token` (compared in constant time). It answers `200` with `{"inserted", "updated", "skipped", "reported_at"}`; a missing or wrong token is `401`, no token configured `404`, more than 2000 paths `422`. An unknown path becomes a page with origin `reported`, kind `page` and a name from its last segment; a known page only gets `reported_at`; a path another content type owns, or one that is no path, is skipped. A report never deletes a page or changes a value an editor set; a reported page the newest report did not name shows **Not on the site** and a warning on its form. Throttle bucket `seo-notfound`. A content type whose file group is also `pages` shares the file with the registry's pages. +### Auto-links + +**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 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 `GET /_seo/api/v1/site` (public, bucket `seo-resolve`, `X-Robots-Tag: noindex`) answers the facts the frontend layer needs, all public: @@ -246,7 +257,7 @@ The management routes live under `/_seo/api/v1` (bucket `seo-api`, 300 requests | `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`. | | `show_robots`, `update_robots`, `test_robots` | `GET/PUT /robots`, `GET /robots/test?url=&agent=` | robots.txt, the AI bot switches, `llms_enabled` and `llms_intro`, and the tester on the saved file. | -| `show_settings`, `update_settings` | `GET/PUT /settings` | The SEO settings (including `links_new_tab`, `nofollow_domains`, `sponsored_domains`, `breadcrumb_home`, the `page_*` pagination keys, the `publisher_*` and `local_*` keys) and, under `types`, the per-type settings. | +| `show_settings`, `update_settings` | `GET/PUT /settings` | The SEO settings (including `links_new_tab`, `nofollow_domains`, `sponsored_domains`, `autolink_max`, `breadcrumb_home`, the `page_*` pagination keys, the `publisher_*` and `local_*` keys) and, under `types`, the per-type settings. | | `list_history`, `show_history` | `GET /history`, `GET /history/{id}` | History, filtered by `via`, `kind` and `batch`. | | `undo_change`, `undo_batch` | `POST /history/{id}/undo`, `POST /history/batches/{batch}/undo` | Undo; refusals are `409` with `undo_stale`, `undo_loop`, `undo_gone` or `already_undone`. | | `apply_batch` | `POST /batch` | Changes (`set_seo`, `create_redirect`, `update_redirect`, `delete_redirect`, `create_rule`, `update_rule`, `delete_rule`) all or nothing; `dry_run` answers the diff and the conflicts. A refused change is `422` (`redirect_loop` with the change and the path, or `batch_refused`). | @@ -259,17 +270,19 @@ The management routes live under `/_seo/api/v1` (bucket `seo-api`, 300 requests | `list_crawls`, `get_crawl` | `GET /crawls`, `GET /crawls/{id}` | Crawls, newest first, with `render {status, pages, note, message}` (`skipped`, `done` or `failed`); one crawl adds `staging`, `issues`, `severities` and `kinds`. | | `cancel_crawl` | `POST /crawls/{id}/cancel` | Cancels a queued or running crawl, keeping what it fetched; another answers `409 crawls.not_running`. | | `delete_crawls` | `DELETE /crawls?ids=` | Deletes crawls with their pages and issues; a queued or running one refuses the call with `403 crawls.delete_running`. | +| `list_autolinks`, `create_autolink`, `update_autolink`, `delete_autolinks` | `GET/POST /autolinks`, `PUT /autolinks/{id}`, `DELETE /autolinks?ids=` | The auto-link phrases: `phrase`, `url`, `locale` (`""` or `all` for every locale), `enabled`, `note`; the list filters by `search`, `locale`, `origin` and `active`. A phrase created through MCP has origin `mcp`. | +| `autolinks_from_keyphrases` | `POST /autolinks/from-keyphrases` | Adds the records' focus keyphrases as phrases; answers `{added, message}`. | | `list_pages`, `create_page`, `update_page`, `delete_page` | `GET/POST /pages`, `PUT/DELETE /pages/{id}` | The page registry: `url` and `title` per locale (a plain string is the default locale's), `kind`, `parent_id`, `content_updated_at`, `note`; the list filters by `id`, `kind`, `origin` and `missing`. A page created through MCP has origin `mcp`. | -Lists take `page`, `per_page` (at most 200) and `search`, and answer `{"data": [...], "page", "per_page", "total"}`. GET tools are read-only; the deletes (`delete_crawls` included) and `gone_not_found` are destructive. +Lists take `page`, `per_page` (at most 200) and `search`, and answer `{"data": [...], "page", "per_page", "total"}`. GET tools are read-only; the deletes (`delete_crawls` and `delete_autolinks` included) and `gone_not_found` are destructive. ### Admin -- Navigation item **SEO** (icon `search`), landing on **Redirects**, with the side menu **Redirects**, **404 log**, **URL rules**, **Pages**, **Site audit** (`gauge`), **Crawls** (`globe`), **robots.txt**, **History** and **Import and export**. +- Navigation item **SEO** (icon `search`), landing on **Redirects**, with the side menu **Redirects**, **404 log**, **URL rules**, **Pages**, **Auto-links** (`wand-sparkles`), **Site audit** (`gauge`), **Crawls** (`globe`), **robots.txt**, **History** and **Import and export**. - **Site audit** lists the current issues, errors first, with severity, problem, address, details, found by and fix; filters severity, problem, found by, fix and status (Open or Ignored; ignored rows carry the **Ignored** badge). The preview shows what was found, how to fix it (the tool to ask an MCP client for, or the template element) and the page's address, with **Check this address again**, **Ignore** and **Stop ignoring**. The toolbar has **Start crawl**, the list the bulk **Ignore issues**. - **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 (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 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. - 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. @@ -301,6 +314,8 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr | `classes.SiteCacheKind` | `seo`, the kind of the site-cache purge after an SEO write. | | `classes.IssueSitemapBlocked`, `classes.SeverityError` | The `sitemap_blocked` issue kind and its severity. | | `controllers.PagesControllerID` | The Pages screen. | +| `controllers.AutolinksControllerID`, `controllers.ActionFromKeyphrases`, `controllers.KeyphrasesMessage` | The Auto-links screen, its toolbar action and the action's toast. | +| `classes.AutolinkLink`, `classes.AutolinkWrite`, `classes.AutolinkValues`, `classes.NormalizeAutolink`, `classes.ValidAutolinkURL`, `classes.LoadAutolink`, `classes.AutolinkOrigin`, `classes.MaxPageAutolinks` | An auto-link in the resolve answer, a write, its values, the address check and the origin of a write. Service: `AutolinkProblems`, `SaveAutolink`, `DeleteAutolinks`, `RecordAutolink`, `ForPage`, `FromKeyphrases`. | | `controllers.IssuesControllerID`, `controllers.CrawlsControllerID`, `controllers.ActionStartCrawl`, `controllers.CrawlActionCancel` | Site audit and Crawls, their toolbar start and the cancel record action. | | `crawl.Crawl`, `crawl.Options`, `crawl.Result`, `crawl.Page`, `crawl.Fetcher`, `crawl.NewFetcher`, `crawl.Parse`, `crawl.PageFacts` | Package `classes/crawl`: one crawl's frontier and limits, the guarded fetcher and the facts of an HTML document. | | `crawl.Check`, `crawl.Env`, `crawl.Finding`, `crawl.Route`, `crawl.Fingerprint`, `crawl.Kinds`, `crawl.Severity`, `crawl.Record` | The checks, a finding's fix route (tool or code) and identity, and the kinds with their severities. | @@ -329,7 +344,7 @@ The element bundle (`assets/dist/seo.js` and `seo.css`) is built from `assets/sr | `classes.FieldTypes` | The SEO admin field types. | | `classes.WithVia`, `classes.ViaOf` | The writer history records (`admin`, `mcp`, `import`, `auto`, `undo`). | | `controllers.PermissionManage` | `golem15.seo.manage`. | -| `models.Entry`, `models.Rule`, `models.Redirect`, `models.NotFound`, `models.Robots`, `models.Settings`, `models.TypeSetting`, `models.Revision`, `models.Page`, `models.Crawl`, `models.CrawlPage`, `models.CrawlLink`, `models.Issue`, `models.IssueIgnore` | The plugin's models. | +| `models.Entry`, `models.Rule`, `models.Redirect`, `models.NotFound`, `models.Robots`, `models.Settings`, `models.TypeSetting`, `models.Revision`, `models.Page`, `models.Autolink`, `models.Crawl`, `models.CrawlPage`, `models.CrawlLink`, `models.Issue`, `models.IssueIgnore` | The plugin's models. | ## Configuration @@ -377,4 +392,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. `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). `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. diff --git a/admin_navigation.go b/admin_navigation.go index 4dc9d40..564df40 100644 --- a/admin_navigation.go +++ b/admin_navigation.go @@ -45,6 +45,13 @@ func (*Plugin) Navigation() []pact.NavigationItem { Permissions: []string{controllers.PermissionManage}, Controller: controllers.PagesControllerID, }, + { + Code: "autolinks", + Label: "golem15.seo::lang.nav.autolinks", + Icon: "wand-sparkles", + Permissions: []string{controllers.PermissionManage}, + Controller: controllers.AutolinksControllerID, + }, { Code: "audit", Label: "golem15.seo::lang.nav.audit", diff --git a/classes/autolinks.go b/classes/autolinks.go index 0de15c1..a62fdef 100644 --- a/classes/autolinks.go +++ b/classes/autolinks.go @@ -294,3 +294,40 @@ func (s *Service) ForPage(ctx context.Context, db *gorm.DB, locale, selfPath str } return out, nil } + +// FromKeyphrases adds one auto-link per record whose focus keyphrase in a +// locale is not yet a phrase in that locale (UI-SPEC C22): the record's +// address in that locale, origin keyphrase, active. A keyphrase another +// row already has, one under 3 characters or one whose address is not a +// path is skipped. It runs inside tx and returns the rows added; a second +// run adds nothing. +func (s *Service) FromKeyphrases(ctx context.Context, tx *gorm.DB) (int, error) { + var rows []struct { + Keyphrase string + Locale string + URL string + } + err := tx.Raw(`SELECT keyphrase, locale, url FROM golem15_seo_entries e +WHERE e.keyphrase <> '' AND e.url <> '' +AND NOT EXISTS (SELECT 1 FROM golem15_seo_autolinks a WHERE lower(a.phrase) = lower(e.keyphrase) AND (a.locale = e.locale OR a.locale = '')) +ORDER BY e.locale ASC, e.updated_at DESC, e.id DESC`).Scan(&rows).Error + if err != nil { + return 0, err + } + added := 0 + for _, row := range rows { + a := models.Autolink{Phrase: row.Keyphrase, URL: row.URL, Locale: row.Locale, Enabled: true, Origin: models.AutolinkOriginKeyphrase} + problems, err := s.AutolinkProblems(ctx, tx, &a) + if err != nil { + return added, err + } + if len(problems) > 0 { + continue + } + if err := s.SaveAutolink(ctx, tx, &a); err != nil { + return added, err + } + added++ + } + return added, nil +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index b4d2392..3321c0c 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -14,7 +14,8 @@ import ( ) // PermissionManage is the one SEO permission (D-15): the SEO tab, URL -// rules, redirects, the 404 log, robots.txt, settings and history. +// rules, redirects, the 404 log, pages, auto-links, the site audit, +// robots.txt, settings and history. const PermissionManage = "golem15.seo.manage" // The admin controller IDs. @@ -33,6 +34,7 @@ func AdminControllers(app func() *backpack.App) []pact.AdminController { notFoundAdminController{app: app}, rulesAdminController{app: app}, pagesAdminController{app: app}, + autolinksAdminController{app: app}, issuesAdminController{app: app}, crawlsAdminController{app: app}, robotsAdminController{app: app}, diff --git a/controllers/api/autolinks.go b/controllers/api/autolinks.go new file mode 100644 index 0000000..7dec97e --- /dev/null +++ b/controllers/api/autolinks.go @@ -0,0 +1,197 @@ +package api + +import ( + "context" + "net/http" + "strings" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/models" + "gorm.io/gorm" +) + +// ListAutolinksInput is the query of GET /_seo/api/v1/autolinks. +type ListAutolinksInput struct { + Page int `json:"page,omitempty" query:"page" jsonschema:"the page number, from 1"` + PerPage int `json:"per_page,omitempty" query:"per_page" jsonschema:"rows per page, at most 200 (50 by default)"` + Search string `json:"search,omitempty" query:"search" jsonschema:"text the phrase or the address contains"` + Locale string `json:"locale,omitempty" query:"locale" jsonschema:"a content locale code, or all for the phrases of every locale"` + Origin string `json:"origin,omitempty" query:"origin" jsonschema:"manual, keyphrase or mcp"` + Active string `json:"active,omitempty" query:"active" jsonschema:"true: active phrases only; false: inactive ones only"` +} + +// CreateAutolinkInput is the body of POST /_seo/api/v1/autolinks. +type CreateAutolinkInput struct { + Phrase string `json:"phrase" jsonschema:"the phrase texts link at its first occurrence (whole words, any letter case), at least 3 characters"` + URL string `json:"url" jsonschema:"the address it links to: a path such as /grzyby/lwia-grzywa or a full http(s) address"` + Locale *string `json:"locale,omitempty" jsonschema:"the content locale it applies in; empty or all for every locale (the default)"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"whether texts link it (default true)"` + Note *string `json:"note,omitempty" jsonschema:"an internal note"` +} + +// UpdateAutolinkInput is PUT /_seo/api/v1/autolinks/{id}: only the fields +// sent change. +type UpdateAutolinkInput struct { + ID uint `json:"id" path:"id" jsonschema:"the auto-link id"` + Phrase *string `json:"phrase,omitempty" jsonschema:"the phrase, at least 3 characters"` + URL *string `json:"url,omitempty" jsonschema:"a path or a full http(s) address"` + Locale *string `json:"locale,omitempty" jsonschema:"a content locale code; empty or all for every locale"` + Enabled *bool `json:"enabled,omitempty" jsonschema:"whether texts link it"` + Note *string `json:"note,omitempty" jsonschema:"an internal note"` +} + +// DeleteAutolinksInput is the query of DELETE /_seo/api/v1/autolinks. +type DeleteAutolinksInput struct { + IDs string `json:"ids" query:"ids" jsonschema:"the auto-link ids to delete, comma-separated, e.g. 3,4"` +} + +// AutolinksFromKeyphrasesInput is POST /_seo/api/v1/autolinks/from-keyphrases +// (no fields). +type AutolinksFromKeyphrasesInput struct{} + +// AutolinkJSON is an auto-link in an answer; locale is "" for every +// locale. +func AutolinkJSON(a models.Autolink) map[string]any { + out := classes.AutolinkValues(a) + out["id"] = a.ID + out["created_at"] = a.CreatedAt + out["updated_at"] = a.UpdatedAt + return out +} + +// ListAutolinks serves GET /_seo/api/v1/autolinks: by phrase, filtered by +// search text, locale, origin and the active state. +func ListAutolinks(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 + } + in := listParams(r) + q := db.Model(&models.Autolink{}).Order("lower(phrase) ASC, locale ASC, id ASC") + if in.Search != "" { + p := like(in.Search) + q = q.Where("lower(phrase) LIKE ? OR lower(url) LIKE ?", p, p) + } + query := r.URL.Query() + if l := strings.TrimSpace(query.Get("locale")); l != "" { + if l == models.AutolinkLocaleAll { + l = "" + } + q = q.Where("locale = ?", l) + } + if o := strings.TrimSpace(query.Get("origin")); o != "" { + q = q.Where("origin = ?", o) + } + switch strings.TrimSpace(query.Get("active")) { + case "true", "1": + q = q.Where("enabled") + case "false", "0": + q = q.Where("NOT enabled") + } + var rows []models.Autolink + total, err := paginate(q, in, &rows) + if err != nil { + return 0, nil, err + } + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + out = append(out, AutolinkJSON(row)) + } + return http.StatusOK, listBody(out, in, total), nil + }) +} + +// CreateAutolink serves POST /_seo/api/v1/autolinks: 201 with the phrase, +// 422 validation_failed for a short phrase, an invalid address or a +// phrase the locale has (UI-SPEC C22). The origin is mcp through MCP. +func CreateAutolink(service func() *classes.Service) http.HandlerFunc { + return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { + var body CreateAutolinkInput + if err := decode(r, maxBody, &body); err != nil { + return 0, nil, err + } + row := models.NewAutolink() + row.Origin = classes.AutolinkOrigin(ctx) + classes.AutolinkWrite{Phrase: &body.Phrase, URL: &body.URL, Locale: body.Locale, Enabled: body.Enabled, Note: body.Note}.Apply(&row) + err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { + return svc.SaveAutolink(ctx, tx, &row) + }) + if err != nil { + return 0, nil, err + } + return http.StatusCreated, map[string]any{"data": AutolinkJSON(row)}, nil + }) +} + +// UpdateAutolink serves PUT /_seo/api/v1/autolinks/{id}: the fields sent +// change, checked like a create. +func UpdateAutolink(service func() *classes.Service) http.HandlerFunc { + return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { + id, err := pathID(r, "id") + if err != nil { + return 0, nil, err + } + var body classes.AutolinkWrite + if err := decode(r, maxBody, &body); err != nil { + return 0, nil, err + } + var row models.Autolink + err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { + var err error + if row, err = classes.LoadAutolink(tx, id); err != nil { + return err + } + body.Apply(&row) + return svc.SaveAutolink(ctx, tx, &row) + }) + if err != nil { + return 0, nil, err + } + return http.StatusOK, map[string]any{"data": AutolinkJSON(row)}, nil + }) +} + +// DeleteAutolinks serves DELETE /_seo/api/v1/autolinks?ids=: the phrases +// named, each recorded in history; unknown ids are skipped. +func DeleteAutolinks(service func() *classes.Service) http.HandlerFunc { + return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { + ids, err := idList(r.URL.Query().Get("ids")) + if err != nil { + return 0, nil, err + } + var deleted []uint + err = write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { + var err error + deleted, err = svc.DeleteAutolinks(ctx, tx, ids) + return err + }) + if err != nil { + return 0, nil, err + } + return http.StatusOK, map[string]any{"deleted": deleted}, nil + }) +} + +// AutolinksFromKeyphrases serves POST +// /_seo/api/v1/autolinks/from-keyphrases: one phrase per record whose +// focus keyphrase in a locale is not yet a phrase there (origin +// keyphrase), with the count added and the toast text. +func AutolinksFromKeyphrases(service func() *classes.Service) http.HandlerFunc { + return manage(service, func(ctx context.Context, svc *classes.Service, r *http.Request) (int, any, error) { + added := 0 + err := write(ctx, svc, func(ctx context.Context, tx *gorm.DB) error { + var err error + added, err = svc.FromKeyphrases(ctx, tx) + return err + }) + if err != nil { + return 0, nil, err + } + message := svc.PhraseCtx(ctx, "golem15.seo::lang.autolinks.keyphrases_none", nil) + if added > 0 { + message = svc.ChoiceCtx(ctx, "golem15.seo::lang.autolinks.keyphrases_added", added, nil) + } + return http.StatusOK, map[string]any{"added": added, "message": message}, nil + }) +} diff --git a/controllers/autolinks.go b/controllers/autolinks.go new file mode 100644 index 0000000..60358be --- /dev/null +++ b/controllers/autolinks.go @@ -0,0 +1,236 @@ +package controllers + +import ( + "context" + "strings" + + "git.golem15.com/golem15/sm-seo-plugin/classes" + "git.golem15.com/golem15/sm-seo-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// AutolinksControllerID is the Auto-links screen (UI-SPEC C22). +const AutolinksControllerID = "golem15.seo.autolinks" + +// ActionFromKeyphrases is the Auto-links toolbar action that adds the +// records' focus keyphrases (UI-SPEC C22). +const ActionFromKeyphrases = "from_keyphrases" + +// autolinksAdminController lists and edits the auto-link phrases (D-82). +// History of a save comes from the plugin's own field-less form extension +// on this controller, which runs inside the save transaction once the row +// is written. +type autolinksAdminController struct { + app func() *backpack.App +} + +func (autolinksAdminController) ID() string { return AutolinksControllerID } +func (autolinksAdminController) ModelName() string { return `Golem15\SEO\Models\Autolink` } +func (autolinksAdminController) ConfigDir() string { return "controllers/autolinks" } +func (autolinksAdminController) RequiredPermissions() []string { + return []string{PermissionManage} +} + +// NewRecord is a phrase with the form's defaults: every locale, active, +// entered by hand. +func (autolinksAdminController) NewRecord() any { + a := models.NewAutolink() + return &a +} + +// FormBeforeCreate refuses a short phrase, an invalid address and a +// phrase the locale already has (UI-SPEC C22); a create is manual. +func (c autolinksAdminController) FormBeforeCreate(ctx context.Context, model any) error { + if a, ok := model.(*models.Autolink); ok && a != nil { + a.Origin = models.AutolinkOriginManual + } + return c.check(ctx, model) +} + +// FormBeforeUpdate checks an update like a create. +func (c autolinksAdminController) FormBeforeUpdate(ctx context.Context, model any) error { + return c.check(ctx, model) +} + +func (c autolinksAdminController) check(ctx context.Context, model any) error { + a, ok := model.(*models.Autolink) + if !ok || a == nil { + return nil + } + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return err + } + db, err := database(ctx, c.app) + if err != nil { + return err + } + problems, err := svc.AutolinkProblems(ctx, db, a) + if err != nil { + return err + } + return problemsError(ctx, translator(c.app), problems) +} + +// FormAfterDelete records the deleted phrase in history inside the +// delete's transaction. +func (c autolinksAdminController) FormAfterDelete(ctx context.Context, model any) error { + a, ok := model.(*models.Autolink) + if !ok || a == nil { + return nil + } + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return err + } + db, err := database(ctx, c.app) + if err != nil { + return err + } + return svc.RecordAutolink(ctx, db, classes.AutolinkValues(*a), *a, true) +} + +// ListRowStates marks an inactive phrase disabled (autolinks.state_off). +func (autolinksAdminController) ListRowStates(_ context.Context, _ *gorm.DB, records []any) ([][]pact.RowState, error) { + out := make([][]pact.RowState, len(records)) + for i, record := range records { + if a, ok := record.(*models.Autolink); ok && a != nil && !a.Enabled { + out[i] = []pact.RowState{pact.RowStateDisabled} + } + } + return out, nil +} + +// autolinkOriginLabels are the phrase keys of the origins. +var autolinkOriginLabels = map[string]string{ + models.AutolinkOriginManual: "golem15.seo::lang.autolinks.origin_manual", + models.AutolinkOriginKeyphrase: "golem15.seo::lang.autolinks.origin_keyphrase", + models.AutolinkOriginMCP: "golem15.seo::lang.autolinks.origin_mcp", +} + +// locales returns the site's content locales, the default first. +func (c autolinksAdminController) locales(ctx context.Context) []string { + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return nil + } + db, err := database(ctx, c.app) + if err != nil { + return nil + } + return svc.Locales(ctx, db) +} + +// FilterOptions serves the choices of the locale and origin filters. +func (c autolinksAdminController) FilterOptions(scope string) []pact.Option { + switch scope { + case models.FilterAutolinkLocale: + out := []pact.Option{{Value: models.AutolinkLocaleAll, Label: "golem15.seo::lang.autolinks.locale_all"}} + for _, code := range c.locales(context.Background()) { + out = append(out, pact.Option{Value: code, Label: strings.ToUpper(code)}) + } + return out + case models.FilterAutolinkOrigin: + out := make([]pact.Option, 0, len(models.AutolinkOrigins)) + for _, o := range models.AutolinkOrigins { + out = append(out, pact.Option{Value: o, Label: autolinkOriginLabels[o]}) + } + return out + } + return nil +} + +// DropdownOptions serves the locale dropdown: each content locale (the +// empty option is every locale). +func (c autolinksAdminController) DropdownOptions(field string) []pact.Option { + if field != "locale" { + return nil + } + codes := c.locales(context.Background()) + out := make([]pact.Option, 0, len(codes)) + for _, code := range codes { + out = append(out, pact.Option{Value: code, Label: strings.ToUpper(code)}) + } + return out +} + +// ListExtendRecords shows the locale (all languages when empty) and the +// origin of the list's rows as their labels in the request locale. The +// rows are list values only and never saved. +func (c autolinksAdminController) ListExtendRecords(ctx context.Context, _ *gorm.DB, records []any) error { + tr := translator(c.app) + for _, record := range records { + a, ok := record.(*models.Autolink) + if !ok || a == nil { + continue + } + if a.Locale == "" { + a.Locale = Phrase(ctx, tr, "golem15.seo::lang.autolinks.locale_all", nil) + } else { + a.Locale = strings.ToUpper(a.Locale) + } + if key, ok := autolinkOriginLabels[a.Origin]; ok { + a.Origin = Phrase(ctx, tr, key, nil) + } + } + return nil +} + +// AdminActions is the toolbar's Add focus keyphrases. +func (c autolinksAdminController) AdminActions() []pact.AdminAction { + return []pact.AdminAction{{ + Name: ActionFromKeyphrases, + Label: "golem15.seo::lang.autolinks.from_keyphrases", + Permissions: []string{PermissionManage}, + Run: func(ctx context.Context, _ pact.AdminActionInput) (pact.AdminActionResult, error) { + svc, err := classes.NewService(appOf(c.app)) + if err != nil { + return pact.AdminActionResult{}, err + } + db, err := database(ctx, c.app) + if err != nil { + return pact.AdminActionResult{}, err + } + added := 0 + err = lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { + var err error + added, err = svc.FromKeyphrases(classes.WithTx(ctx, tx), tx) + return err + }) + if err != nil { + return pact.AdminActionResult{}, err + } + return pact.AdminActionResult{Message: KeyphrasesMessage(ctx, c.app, added)}, nil + }, + }} +} + +// KeyphrasesMessage is the Add focus keyphrases toast: the count added, +// or that there was nothing to add. +func KeyphrasesMessage(ctx context.Context, app func() *backpack.App, added int) string { + tr := translator(app) + if added == 0 { + return Phrase(ctx, tr, "golem15.seo::lang.autolinks.keyphrases_none", nil) + } + if tr == nil { + return "golem15.seo::lang.autolinks.keyphrases_added" + } + return tr.Choice(ctx, "golem15.seo::lang.autolinks.keyphrases_added", added, nil) +} + +var ( + _ pact.AdminController = autolinksAdminController{} + _ pact.AdminPermissioned = autolinksAdminController{} + _ pact.AdminRecordSource = autolinksAdminController{} + _ pact.FormBeforeCreate = autolinksAdminController{} + _ pact.FormBeforeUpdate = autolinksAdminController{} + _ pact.FormAfterDelete = autolinksAdminController{} + _ pact.ListRowStates = autolinksAdminController{} + _ pact.FilterOptions = autolinksAdminController{} + _ pact.ListExtendRecords = autolinksAdminController{} + _ pact.DropdownOptionsProvider = autolinksAdminController{} + _ pact.HasAdminActions = autolinksAdminController{} +) diff --git a/controllers/autolinks/config_filter.yaml b/controllers/autolinks/config_filter.yaml new file mode 100644 index 0000000..1e95ac7 --- /dev/null +++ b/controllers/autolinks/config_filter.yaml @@ -0,0 +1,16 @@ +# Auto-links filters (UI-SPEC C22): locale, origin and active only. +scopes: + locale: + label: golem15.seo::lang.autolinks.locale + modelClass: Golem15\SEO\Models\Autolink + nameFrom: locale + scope: FilterAutolinkLocale + origin: + label: golem15.seo::lang.autolinks.origin + modelClass: Golem15\SEO\Models\Autolink + nameFrom: origin + scope: FilterAutolinkOrigin + active: + label: golem15.seo::lang.autolinks.filter_active + type: switch + column: enabled diff --git a/controllers/autolinks/config_form.yaml b/controllers/autolinks/config_form.yaml new file mode 100644 index 0000000..97b9fe7 --- /dev/null +++ b/controllers/autolinks/config_form.yaml @@ -0,0 +1,18 @@ +# Auto-link form (UI-SPEC C22). +name: golem15.seo::lang.autolinks.name +form: ~/plugins/golem15/seo/models/autolink/fields.yaml +modelClass: Golem15\SEO\Models\Autolink +defaultRedirect: golem15/seo/autolinks + +create: + redirect: golem15/seo/autolinks/update/:id + redirectClose: golem15/seo/autolinks + +update: + redirect: golem15/seo/autolinks + redirectClose: golem15/seo/autolinks + +messages: + create: golem15.seo::lang.autolinks.new + update: golem15.seo::lang.autolinks.name + deleteConfirm: golem15.seo::lang.autolinks.delete_confirm diff --git a/controllers/autolinks/config_list.yaml b/controllers/autolinks/config_list.yaml new file mode 100644 index 0000000..a4577cf --- /dev/null +++ b/controllers/autolinks/config_list.yaml @@ -0,0 +1,26 @@ +# Auto-links list (UI-SPEC C22): the phrases by phrase; the toolbar +# creates, deletes and adds the records' focus keyphrases. +list: ~/plugins/golem15/seo/models/autolink/columns.yaml +modelClass: Golem15\SEO\Models\Autolink +title: golem15.seo::lang.autolinks.list_title +recordUrl: golem15/seo/autolinks/update/:id +noRecordsMessage: golem15.seo::lang.autolinks.list_empty +recordsPerPage: 50 +showCheckboxes: true +showSetup: true +filter: config_filter.yaml + +defaultSort: + column: phrase + direction: asc + +toolbar: + buttons: [create, delete, from_keyphrases] + search: + prompt: backend::lang.list.search_prompt + +messages: + create: golem15.seo::lang.autolinks.new + empty: golem15.seo::lang.autolinks.list_empty + deleteConfirm: golem15.seo::lang.autolinks.delete_selected_confirm + rowStateDisabled: golem15.seo::lang.autolinks.state_off diff --git a/extensions.go b/extensions.go index bfff363..1015aa2 100644 --- a/extensions.go +++ b/extensions.go @@ -43,6 +43,7 @@ func (p *Plugin) AdminActionExtensions() []pact.AdminActionExtension { } out = append(out, pact.AdminActionExtension{Controller: controllers.RulesControllerID, Form: p.ruleHistory()}) out = append(out, pact.AdminActionExtension{Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()}) + out = append(out, pact.AdminActionExtension{Controller: controllers.AutolinksControllerID, Form: p.autolinkHistory()}) out = append(out, pact.AdminActionExtension{Controller: controllers.RobotsControllerID, Form: p.robotsForm()}) return out } @@ -116,6 +117,34 @@ func (p *Plugin) ruleHistory() *pact.AdminFormExtension { } } +// autolinkHistory is a field-less extension on the plugin's own Auto-links +// controller: its Save sees the save's changes inside the save +// transaction and records them in history, which purges the site's cached +// pages (deletes are recorded by the controller). +func (p *Plugin) autolinkHistory() *pact.AdminFormExtension { + return &pact.AdminFormExtension{ + Permissions: []string{controllers.PermissionManage}, + Save: func(ctx context.Context, in pact.AdminFormSave) (pact.AdminFormSaveResult, error) { + svc := p.Service() + if svc == nil { + return pact.AdminFormSaveResult{}, classes.ErrNotBooted + } + a, ok := in.Record.(*models.Autolink) + if !ok || a == nil { + return pact.AdminFormSaveResult{}, nil + } + before, after := classes.ChangesValues(in.Changes, in.Created) + return pact.AdminFormSaveResult{}, svc.Record(ctx, in.DB, classes.Change{ + Kind: models.SubjectAutolink, + SubjectID: in.ID, + What: svc.Phrase("golem15.seo::lang.history.what_autolink", map[string]string{"phrase": a.Phrase}), + Before: before, + After: after, + }) + }, + } +} + func (p *Plugin) registry() *signpost.Registry { if p == nil || p.app == nil { return nil diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 8516cc4..bae68a0 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -5,6 +5,7 @@ nav: audit: "Site audit" crawls: "Crawls" pages: Pages + autolinks: Auto-links robots: robots.txt notfound: 404 log redirects: Redirects @@ -15,7 +16,7 @@ nav: permissions: tab: SEO manage: Manage SEO - manage_comment: Edit the SEO tab of every content type, URL rules, redirects, robots.txt and the SEO settings, in the admin and through MCP. + manage_comment: Edit the SEO tab of every content type, URL rules, redirects, pages, auto-links, robots.txt and the SEO settings, and run the site audit, in the admin and through MCP. tab: seo: SEO field: @@ -102,7 +103,7 @@ settings: placeholders_meaning: Replaced with category: SEO label: SEO settings - description: Title templates, default descriptions, indexing rules, address format and search engine verification. + description: Title templates, default descriptions, indexing, addresses, pagination, links, publisher data, site audit and search engine verification. tab_general: General tab_indexing: Indexing tab_urls: Addresses diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 4cfbee1..5c51c7b 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -5,6 +5,7 @@ nav: audit: "Audyt witryny" crawls: "Skanowania" pages: Strony + autolinks: Automatyczne linki robots: robots.txt notfound: Dziennik 404 redirects: Przekierowania @@ -15,7 +16,7 @@ nav: permissions: tab: SEO manage: Zarządzanie SEO - manage_comment: Edycja zakładki SEO we wszystkich typach treści, reguł adresów, przekierowań, robots.txt i ustawień SEO, w panelu i przez MCP. + manage_comment: Edycja zakładki SEO we wszystkich typach treści, reguł adresów, przekierowań, stron, automatycznych linków, robots.txt i ustawień SEO oraz uruchamianie audytu witryny, w panelu i przez MCP. tab: seo: SEO field: @@ -104,7 +105,7 @@ settings: placeholders_meaning: Zastępowany przez category: SEO label: Ustawienia SEO - description: Szablony tytułów, domyślne opisy, zasady indeksowania, format adresów i weryfikacja w wyszukiwarkach. + description: Szablony tytułów, domyślne opisy, indeksowanie, adresy, stronicowanie, linki, dane wydawcy, audyt witryny i weryfikacja w wyszukiwarkach. tab_general: Ogólne tab_indexing: Indeksowanie tab_urls: Adresy diff --git a/linking_test.go b/linking_test.go index e85e60c..01258ce 100644 --- a/linking_test.go +++ b/linking_test.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" + "strconv" + "strings" "testing" "git.golem15.com/golem15/sm-seo-plugin/classes" @@ -32,6 +35,17 @@ func autolinksOf(t *testing.T, got map[string]any) (map[string]string, int) { return out, most } +// numID is a decoded JSON id as text. +func numID(v any) string { + if n, ok := v.(float64); ok { + return strconv.FormatFloat(n, 'f', 0, 64) + } + if n, ok := v.(json.Number); ok { + return n.String() + } + return "" +} + // saveAutolink writes an auto-link through the service, as the screen and // the API do. func (e *testEnv) saveAutolink(t *testing.T, a models.Autolink) models.Autolink { @@ -97,3 +111,114 @@ func TestAutolinksTracer(t *testing.T) { t.Fatalf("autolink_max 21 = %d %s, want 422", rec.Code, rec.Body.String()) } } + +// autolinksAPI is the Auto-links admin controller's API. +var autolinksAPI = adminAPI("/golem15/seo/autolinks") + +// TestAutolinks walks the Auto-links screen and its API (UI-SPEC C22, +// D-82, T-33-50): the refusals (short phrase, a phrase the locale has +// naming its address, javascript:, data: and protocol-relative +// addresses), the list by phrase with the disabled row state, labels and +// filters, history, Add focus keyphrases twice (the second run adds +// nothing), the MCP origin, update and delete. +func TestAutolinks(t *testing.T) { + env := bootEnv(t) + token := env.admin(t, true) + + refused := func(rec *httptest.ResponseRecorder, field, text string) { + t.Helper() + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), field) || !strings.Contains(rec.Body.String(), text) { + t.Fatalf("refusal = %d %s, want 422 on %s with %q", rec.Code, rec.Body.String(), field, text) + } + } + refused(env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "ab", "url": "/x"}), "phrase", "Use at least 3 characters.") + for _, bad := range []string{"javascript:alert(1)", "data:text/html,x", "//evil.example/x", "ftp://example.com/x", "/a b"} { + refused(env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "boczniak", "url": bad}), "url", "Use a path starting with / or a full http(s) address.") + } + env.expect(t, http.StatusCreated, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "Lwia grzywa", "url": "/grzyby/lwia-grzywa", "locale": "", "enabled": true}) + refused(env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "lwia GRZYWA", "url": "/inna", "locale": "pl", "enabled": true}), "phrase", "This phrase already links to “/grzyby/lwia-grzywa” in this language.") + env.expect(t, http.StatusCreated, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "boczniak", "url": "https://example.com/boczniak", "locale": "pl", "enabled": false, "note": "later"}) + + list := env.adminList(t, autolinksAPI, token) + rows := map[string]map[string]any{} + for _, row := range list.Data { + rows[row["phrase"].(string)] = row + } + if len(list.Data) != 2 || rows["boczniak"] == nil || rows["Lwia grzywa"] == nil { + t.Fatalf("list = %v, want boczniak and Lwia grzywa", list.Data) + } + if rows["boczniak"]["locale"] != "PL" || rows["Lwia grzywa"]["locale"] != "All languages" || rows["boczniak"]["origin"] != "Manual" { + t.Fatalf("list labels = %v", list.Data) + } + off := numID(rows["boczniak"]["id"]) + if states := list.Meta.RowStates[off]; len(states) != 1 || states[0] != "disabled" { + t.Fatalf("row states = %v, want %s disabled", list.Meta.RowStates, off) + } + if states := list.Meta.RowStates[numID(rows["Lwia grzywa"]["id"])]; len(states) != 0 { + t.Fatalf("the active row has states %v", states) + } + if got := env.adminList(t, autolinksAPI+"?filter[active]=true", token); len(got.Data) != 1 || got.Data[0]["phrase"] != "Lwia grzywa" { + t.Fatalf("active filter = %v", got.Data) + } + if got := env.adminList(t, autolinksAPI+"?filter[locale]=all", token); len(got.Data) != 1 || got.Data[0]["phrase"] != "Lwia grzywa" { + t.Fatalf("locale all filter = %v", got.Data) + } + var revisions int64 + env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE subject_kind = 'autolink'`).Scan(&revisions) + if revisions != 2 { + t.Fatalf("history rows = %d, want 2", revisions) + } + + // The admin form: create through the screen, refused like the API. + rec := env.call(t, http.MethodPost, autolinksAPI, token, map[string]any{"phrase": "xy", "url": "/x"}) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "Use at least 3 characters.") { + t.Fatalf("admin refusal = %d %s", rec.Code, rec.Body.String()) + } + + // Focus keyphrases: two records with one, one shared with a phrase. + env.createPage(t, token, map[string]any{"title": "Shiitake", "slug": "shiitake", "published": true}) + env.createPage(t, token, map[string]any{"title": "Lion", "slug": "lion", "published": true}) + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/pages/shiitake", "values": map[string]any{"keyphrase": "twardnik japoński"}}) + env.expect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/seo", token, map[string]any{"url": "/pages/lion", "values": map[string]any{"keyphrase": "lwia grzywa"}}) + rec = env.expect(t, http.StatusOK, http.MethodPost, autolinksAPI+"/toolbar/from_keyphrases", token, map[string]any{}) + if !strings.Contains(rec.Body.String(), "1 phrase added from focus keyphrases.") { + t.Fatalf("from_keyphrases = %s", rec.Body.String()) + } + rec = env.expect(t, http.StatusOK, http.MethodPost, autolinksAPI+"/toolbar/from_keyphrases", token, map[string]any{}) + if !strings.Contains(rec.Body.String(), "No new focus keyphrases to add.") { + t.Fatalf("second from_keyphrases = %s", rec.Body.String()) + } + got := env.expect(t, http.StatusOK, http.MethodGet, "/_seo/api/v1/autolinks?origin=keyphrase", token, nil).Body.String() + if !jsonContains(got, `"total":1`) || !jsonContains(got, `"phrase":"twardnik japoński"`) || !jsonContains(got, `"url":"/pages/shiitake"`) || !jsonContains(got, `"locale":"en"`) { + t.Fatalf("keyphrase rows = %s", got) + } + if out := env.expect(t, http.StatusOK, http.MethodPost, "/_seo/api/v1/autolinks/from-keyphrases", token, map[string]any{}).Body.String(); !jsonContains(out, `"added":0`) { + t.Fatalf("API from-keyphrases again = %s", out) + } + + // MCP: the origin is mcp; update and delete. + agent := env.principal(t, env.admin(t, false, "golem15.seo.manage")) + created := env.mcpExpect(t, http.StatusCreated, http.MethodPost, "/_seo/api/v1/autolinks", agent, map[string]any{"phrase": "maitake", "url": "/maitake"}) + data, _ := created["data"].(map[string]any) + if data["origin"] != "mcp" || data["enabled"] != true || data["locale"] != "" { + t.Fatalf("MCP create = %v", created) + } + id := numID(data["id"]) + updated := env.mcpExpect(t, http.StatusOK, http.MethodPut, "/_seo/api/v1/autolinks/"+id, agent, map[string]any{"enabled": false}) + if d, _ := updated["data"].(map[string]any); d["enabled"] != false || d["phrase"] != "maitake" { + t.Fatalf("MCP update = %v", updated) + } + env.mcpExpect(t, http.StatusOK, http.MethodDelete, "/_seo/api/v1/autolinks?ids="+id+","+off, agent, nil) + if got := env.adminList(t, autolinksAPI, token); len(got.Data) != 2 { + t.Fatalf("after delete = %v", got.Data) + } + env.db.Raw(`SELECT COUNT(*) FROM golem15_seo_revisions WHERE subject_kind = 'autolink'`).Scan(&revisions) + if revisions != 7 { + t.Fatalf("history rows = %d, want 7 (2 created, 1 from keyphrases, mcp create, update, 2 deletes)", revisions) + } + var vias []string + env.db.Raw(`SELECT DISTINCT via FROM golem15_seo_revisions WHERE subject_kind = 'autolink' ORDER BY via`).Scan(&vias) + if strings.Join(vias, ",") != "admin,mcp" { + t.Fatalf("history vias = %v", vias) + } +} diff --git a/mcp.go b/mcp.go index aae416c..2e237c3 100644 --- a/mcp.go +++ b/mcp.go @@ -208,6 +208,59 @@ func (p *Plugin) MCP() pact.MCP { Destructive: &destructive, }, + // Auto-links (D-82): phrases texts link to a page. + { + Name: "list_autolinks", + Title: "List auto-links", + Description: "Lists the auto-link phrases by phrase: each links its first occurrence in a text to its address, in one locale or all (locale \"\"), unless inactive. Filters: search text, locale (all for every locale), origin (manual, keyphrase, mcp) and active.", + Guard: mcpGuard, + Method: http.MethodGet, + Path: "/_seo/api/v1/autolinks", + Input: apicontrollers.ListAutolinksInput{}, + Permissions: []string{mcpPermission}, + }, + { + Name: "create_autolink", + Title: "Create an auto-link", + Description: "Adds a phrase that texts link at its first occurrence (whole words, any letter case, never in headings, existing links or on the page it links to), at most autolink_max per text (seo_update_settings). The phrase needs 3 characters and may exist once per locale; the address is a path or a full http(s) address.", + Guard: mcpGuard, + Method: http.MethodPost, + Path: "/_seo/api/v1/autolinks", + Input: apicontrollers.CreateAutolinkInput{}, + Permissions: []string{mcpPermission}, + }, + { + Name: "update_autolink", + Title: "Update an auto-link", + Description: "Changes the fields sent of one auto-link (phrase, address, locale, active, note), checked like a create.", + Guard: mcpGuard, + Method: http.MethodPut, + Path: "/_seo/api/v1/autolinks/{id}", + Input: apicontrollers.UpdateAutolinkInput{}, + Permissions: []string{mcpPermission}, + }, + { + Name: "delete_autolinks", + Title: "Delete auto-links", + Description: "Deletes auto-links by id; texts stop linking their phrases when their pages are next rendered.", + Guard: mcpGuard, + Method: http.MethodDelete, + Path: "/_seo/api/v1/autolinks", + Input: apicontrollers.DeleteAutolinksInput{}, + Permissions: []string{mcpPermission}, + Destructive: &destructive, + }, + { + Name: "autolinks_from_keyphrases", + Title: "Add auto-links from focus keyphrases", + Description: "Adds one auto-link per record whose focus keyphrase in a locale is not yet a phrase in that locale, linking to the record's address (origin keyphrase), and answers the count added. Running it again adds only new keyphrases.", + Guard: mcpGuard, + Method: http.MethodPost, + Path: "/_seo/api/v1/autolinks/from-keyphrases", + Input: apicontrollers.AutolinksFromKeyphrasesInput{}, + Permissions: []string{mcpPermission}, + }, + // 404 log (D-20). { Name: "list_not_found", diff --git a/models/autolink/columns.yaml b/models/autolink/columns.yaml new file mode 100644 index 0000000..7e22e4e --- /dev/null +++ b/models/autolink/columns.yaml @@ -0,0 +1,25 @@ +# Auto-links list columns (UI-SPEC C22). locale and origin show their +# labels (the controller's ListExtendRecords) and sort by the stored values. +columns: + phrase: + label: golem15.seo::lang.autolinks.phrase + type: text + searchable: true + sortable: true + url: + label: golem15.seo::lang.autolinks.url + type: text + searchable: true + sortable: false + locale: + label: golem15.seo::lang.autolinks.locale + type: text + sortable: false + origin: + label: golem15.seo::lang.autolinks.origin + type: text + sortable: false + updated_at: + label: golem15.seo::lang.autolinks.updated + type: datetime + sortable: true diff --git a/models/autolink/fields.yaml b/models/autolink/fields.yaml new file mode 100644 index 0000000..097a42d --- /dev/null +++ b/models/autolink/fields.yaml @@ -0,0 +1,35 @@ +# Auto-link form (UI-SPEC C22): the phrase and the address it links to, +# the locale (every locale by default) and the active switch, a note. +fields: + phrase: + label: golem15.seo::lang.autolinks.phrase + comment: golem15.seo::lang.autolinks.phrase_comment + type: text + span: left + required: true + + url: + label: golem15.seo::lang.autolinks.url + comment: golem15.seo::lang.autolinks.url_comment + type: text + span: right + required: true + + locale: + label: golem15.seo::lang.autolinks.locale + type: dropdown + span: left + emptyOption: golem15.seo::lang.autolinks.locale_all + options: localeOptions + + enabled: + label: golem15.seo::lang.autolinks.enabled + type: switch + span: right + default: true + + note: + label: golem15.seo::lang.autolinks.note + type: textarea + size: tiny + span: full diff --git a/operations.go b/operations.go index ad43b2f..dac2f72 100644 --- a/operations.go +++ b/operations.go @@ -96,6 +96,16 @@ var AdminOperations = []AdminOperation{ {controllers.PagesControllerID, OpDelete, "delete_page"}, {controllers.PagesControllerID, OpExtensionSave, "update_page"}, + // Auto-links: the phrase table, its history extension and the + // toolbar's Add focus keyphrases. + {controllers.AutolinksControllerID, OpList, "list_autolinks"}, + {controllers.AutolinksControllerID, OpShow, "list_autolinks"}, + {controllers.AutolinksControllerID, OpCreate, "create_autolink"}, + {controllers.AutolinksControllerID, OpUpdate, "update_autolink"}, + {controllers.AutolinksControllerID, OpDelete, "delete_autolinks"}, + {controllers.AutolinksControllerID, OpExtensionSave, "update_autolink"}, + {controllers.AutolinksControllerID, OpToolbarAction + ":" + controllers.ActionFromKeyphrases, "autolinks_from_keyphrases"}, + // Site audit: the current issues, their record and bulk actions, and // the toolbar's Start crawl. {controllers.IssuesControllerID, OpList, "list_issues"}, diff --git a/operations_test.go b/operations_test.go index daac6a4..6cdc772 100644 --- a/operations_test.go +++ b/operations_test.go @@ -147,6 +147,7 @@ func requiredOperations(t *testing.T, p *Plugin) []AdminOperation { {Controller: controllers.RedirectsControllerID, Form: p.redirectChecks()}, {Controller: controllers.RobotsControllerID, Form: p.robotsForm()}, {Controller: controllers.PagesControllerID, Form: p.pagesForm()}, + {Controller: controllers.AutolinksControllerID, Form: p.autolinkHistory()}, } for _, ext := range exts { if ext.Form == nil { diff --git a/routes.go b/routes.go index b4e2dda..2c464a9 100644 --- a/routes.go +++ b/routes.go @@ -64,6 +64,11 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/pages", apicontrollers.CreatePage(p.Service), api) g.Put("/pages/{id}", apicontrollers.UpdatePage(p.Service), api) g.Delete("/pages/{id}", apicontrollers.DeletePage(p.Service), api) + g.Get("/autolinks", apicontrollers.ListAutolinks(p.Service), api) + g.Post("/autolinks", apicontrollers.CreateAutolink(p.Service), api) + g.Put("/autolinks/{id}", apicontrollers.UpdateAutolink(p.Service), api) + g.Delete("/autolinks", apicontrollers.DeleteAutolinks(p.Service), api) + g.Post("/autolinks/from-keyphrases", apicontrollers.AutolinksFromKeyphrases(p.Service), api) g.Get("/notfound", apicontrollers.ListNotFound(p.Service), api) g.Post("/notfound/{id}/redirect", apicontrollers.RedirectNotFound(p.Service), api) g.Post("/notfound/{id}/gone", apicontrollers.GoneNotFound(p.Service), api)