Documentation
¶
Overview ¶
Package v2 implements the Internal Linking v2 architecture defined in plans/ADR-internal-linking-v2.md. It is a clean parallel namespace to the legacy internal/linking package — they will coexist until Phase 4 retires v1. Single-tenant per the Path B migration; nothing in this package takes a tenant ID parameter.
Index ¶
- Constants
- Variables
- func DeleteAllRules(ctx context.Context, db *database.DB) (int, error)
- func DeleteRule(ctx context.Context, db *database.DB, id uuid.UUID) error
- func DeleteRulesByIDs(ctx context.Context, db *database.DB, ids []uuid.UUID) (int, error)
- func DeleteRulesBySource(ctx context.Context, db *database.DB, source string) (int, error)
- func Reindex(ctx context.Context, db *database.DB, sourceEntityID uuid.UUID) error
- func Resolve(b URLBuilder, schemaSlug, entitySlug, parentSlug string) (string, error)
- func ResolveWithAttrs(b URLBuilder, schemaSlug string, v urlpattern.Vars, attrs map[string]string) (string, error)
- func RewriteForEntity(ctx context.Context, db *database.DB, targetEntityID uuid.UUID, ...) error
- func RewriteForEntityWithCount(ctx context.Context, db *database.DB, targetEntityID uuid.UUID, ...) (int, error)
- func RewriteSingleLink(ctx context.Context, db *database.DB, sourceID, targetID uuid.UUID, ...) error
- type CreateRuleInput
- type EquityDetailRow
- type EquityReport
- type EquityTargetRow
- type InsertInput
- type InsertResult
- type LinkRule
- type LinkSpec
- type NoopSuggester
- type PaginatedRulesResult
- type ParsedLink
- type RuleFilter
- type RulePreviewMatch
- type RulePreviewResult
- type RuleType
- type Suggester
- type Suggestion
- type URLBuilder
- type UpdateRuleInput
- type WrapOutcome
- type WrapResult
Constants ¶
const ( ReasonSlugChange = "slug_change" ReasonBulkRetarget = "bulk_retarget" ReasonManual = "manual" )
Reason values for the audit log. Keep this list in sync with the migration comment on hatamoto_entity_link_audit.reason.
const ( SkipNoMatch = "no_match" SkipAlreadyLinked = "already_linked" SkipInHeading = "in_heading" SkipInCode = "in_code" SkipExcludedSchema = "excluded_schema" SkipResolverError = "resolver_error" SkipEmptyAnchor = "empty_anchor" )
SkipReason enumerates every reason an anchor cannot be wrapped. The strings are stable — the /linking/suggest API response and the v2 backfill report format both read them by name. (Backfill tool itself was retired post- cutover; see cmd/normalize-link-markers/ for the residual one-shot.)
const RuleSourceManual = "manual"
RuleSourceManual identifies a rule that was created by hand through the admin UI (as opposed to one of the auto-generation strategies that existed before migration 093). Persisted in the source column so "delete by source" can target legacy auto-generated cohorts.
Variables ¶
var ( // ErrEmptyEntitySlug guards against producing pathological hrefs like "/" // that would render as the home page. ErrEmptyEntitySlug = errors.New("entity slug is required") // ErrSchemaNotLinkable is returned for schemas that are not standalone // pages: faq (rendered inline within parents), location (not published // as standalone), organization (singleton site identity). The picker and // suggester filter these out per ADR §4.1.1. ErrSchemaNotLinkable = errors.New("schema is not linkable") // ErrUnknownSchema is returned for a schema slug the URL builder does not // know. Resolver refuses rather than guessing — silent fallback was // part of why v1 leaked redirects. ErrUnknownSchema = errors.New("unknown schema slug") )
Resolver errors.
var ( ErrRuleNotFound = errors.New("link rule not found") // ErrDuplicateRule is returned when (target_entity_id, rule_type, // match_value) already exists — the table's unique constraint. ErrDuplicateRule = errors.New("a rule with this phrase already exists for this target") )
Common errors.
var ( ErrMatchValueRequired = errors.New("match_value is required") ErrTargetEntityRequired = errors.New("target_entity_id is required") ErrSourceRequired = errors.New("source is required") )
Validation errors surfaced by the rule writers.
var ErrNotImplemented = errors.New("not implemented")
ErrNotImplemented remains for callers still wired against the Phase 1 stub surface (NoopSuggester). Real Suggest() uses concrete error returns.
var ErrSourceNotFound = errors.New("source entity not found")
ErrSourceNotFound signals the source entity row is missing. The /insert handler maps this to 404 so the editor knows to refresh.
Functions ¶
func DeleteAllRules ¶
DeleteAllRules removes every linking rule from the site and returns the number deleted. Used by the admin "clean slate" toolbar action.
func DeleteRule ¶
DeleteRule removes a linking rule.
func DeleteRulesByIDs ¶
DeleteRulesByIDs removes a specific set of rules by ID and returns the number deleted. Missing IDs are silently ignored.
func DeleteRulesBySource ¶
DeleteRulesBySource removes every rule whose source column matches the given value. Used to clean up legacy auto-generated rule cohorts in one click.
func Reindex ¶
Reindex replaces the hatamoto_entity_link_index rows for a single source entity with whatever data-entity-id occurrences currently live in its stored attributes.content. Runs in a single transaction — readers always see either the old or new set, never a half-written intermediate.
The hook in entity/service.go (added by Phase 1 §E) fires this after every UpdateEntity so the projection stays strongly consistent with content. The rewriter (rewriter.go) calls reindexTx inline to avoid nested transactions.
func Resolve ¶
func Resolve(b URLBuilder, schemaSlug, entitySlug, parentSlug string) (string, error)
Resolve returns the canonical relative URL for a published entity, as ResolveWithAttrs with no attributes and only a {parent} slug.
func ResolveWithAttrs ¶
func ResolveWithAttrs(b URLBuilder, schemaSlug string, v urlpattern.Vars, attrs map[string]string) (string, error)
ResolveWithAttrs returns the canonical relative URL for a published entity.
The URL comes from the schema's URL pattern via b. faq, location and organization are never standalone pages and are refused.
The function does no I/O of its own; b decides what is looked up.
func RewriteForEntity ¶
func RewriteForEntity( ctx context.Context, db *database.DB, targetEntityID uuid.UUID, newCanonicalURL string, reason string, actor *uuid.UUID, ) error
RewriteForEntity walks every source entity that links to targetEntityID (per hatamoto_entity_link_index) and rewrites the href on every matching <a data-entity-id="targetEntityID"> to newCanonicalURL. The anchor text and any other attributes are preserved.
Concurrency model: per-source SELECT ... FOR UPDATE row lock for the duration of the rewrite transaction. ADR §8 documents that hatamoto_entities.version is auto-incremented but NOT enforced as an optimistic-concurrency gate (see entity/repository.go:451), so a row lock is the cheapest correct option without touching every other writer.
One transaction per source: a failure to rewrite source A does not block rewriting source B. The caller (entity update hook) treats this best-effort — partial progress is logged, the next save reruns it.
The reason argument is recorded in hatamoto_entity_link_audit and gates the one-click revert flow surfaced by Phase 5.
func RewriteForEntityWithCount ¶
func RewriteForEntityWithCount( ctx context.Context, db *database.DB, targetEntityID uuid.UUID, newCanonicalURL string, reason string, actor *uuid.UUID, ) (int, error)
RewriteForEntityWithCount is RewriteForEntity but also returns the number of source entities touched. Used by the equity dashboard's bulk-retarget action so the UI can surface "N entities updated".
func RewriteSingleLink ¶
func RewriteSingleLink( ctx context.Context, db *database.DB, sourceID, targetID uuid.UUID, occurrenceIndex int, newCanonicalURL, reason string, actor *uuid.UUID, ) error
RewriteSingleLink rewrites exactly one inbound link — the Nth occurrence of `<a data-entity-id=targetID>` inside sourceID's content — to use newCanonicalURL. Every other link in the source is left alone, including other occurrences of the same target.
Used by the equity dashboard's per-link retarget action: an admin clicking "Retarget" on a single drill-down row changes ONLY that row.
Concurrency: same FOR UPDATE lock pattern as rewriteOneSource so a concurrent editor save and the rewriter can't clobber each other. Audits the change with reason="manual" by default (caller may pass a different reason, e.g. ReasonBulkRetarget for a future bulk-per-link flow).
Returns nil on a no-op (the Nth occurrence's href already matches newCanonicalURL or the target/occurrence pair no longer exists in the source content; in the latter case the index is also reindexed so the stale row is dropped).
Types ¶
type CreateRuleInput ¶
type CreateRuleInput struct {
TargetEntityID uuid.UUID `json:"target_entity_id"`
RuleType RuleType `json:"rule_type"`
MatchValue string `json:"match_value"`
Priority int `json:"priority"`
MaxLinksPerSource int `json:"max_links_per_source"`
IsActive bool `json:"is_active"`
AnchorText *string `json:"anchor_text,omitempty"`
Source string `json:"source,omitempty"`
}
CreateRuleInput represents input for creating a new linking rule.
type EquityDetailRow ¶
type EquityDetailRow struct {
SourceEntityID uuid.UUID `json:"source_entity_id"`
SourceName string `json:"source_name"`
SourceSlug string `json:"source_slug"`
SourceSchema string `json:"source_schema"`
AnchorText string `json:"anchor_text"`
Href string `json:"href"`
OccurrenceIndex int `json:"occurrence_index"`
}
EquityDetailRow is one inbound link, joined with its source entity's display fields, surfaced when the dashboard drills into a target.
func ListInboundLinks ¶
func ListInboundLinks(ctx context.Context, db *database.DB, targetID uuid.UUID) ([]EquityDetailRow, error)
ListInboundLinks returns every source-side row pointing at targetID. Drill-down view from the equity dashboard. Sorted by source name so the list reads stably between refreshes.
type EquityReport ¶
type EquityReport struct {
Targets []EquityTargetRow `json:"targets"`
// LinksTotal is the total row count in hatamoto_entity_link_index — a
// quick sanity number for the dashboard header.
LinksTotal int `json:"links_total"`
// UniqueSources is the number of distinct source entities that contain
// any inbound link.
UniqueSources int `json:"unique_sources"`
}
EquityReport is the full payload for /admin/settings/linking/equity. The targets slice is sorted by inbound total descending so the dashboard shows hotspots first.
func ComputeEquity ¶
ComputeEquity aggregates hatamoto_entity_link_index by target. Single query using JSON aggregation so the per-source-schema breakdown lands in the same round trip. No N+1.
type EquityTargetRow ¶
type EquityTargetRow struct {
TargetEntityID uuid.UUID `json:"target_entity_id"`
TargetName string `json:"target_name"`
TargetSlug string `json:"target_slug"`
TargetSchema string `json:"target_schema"`
// InboundTotal is the total number of <a> wrappers pointing at this
// target across every source entity.
InboundTotal int `json:"inbound_total"`
// InboundBySourceSchema breaks the total down by the schema of the
// linking source (blog, service, page, …) so the dashboard can answer
// "where are these links coming from?" without a second query.
InboundBySourceSchema map[string]int `json:"inbound_by_source_schema"`
// Tier is derived from the max priority of any active manual rule
// targeting this entity: 100→"primary", 90→"secondary", 70-89→"cluster",
// else "". Drives the pillar dashboard filter and badges on the equity
// page. The hierarchy itself lives in hatamoto_internal_link_rules.priority
// so adding a new pillar is a single UPDATE, not a code change.
Tier string `json:"tier"`
}
EquityTargetRow is one row in the equity dashboard — a single target entity with its aggregate inbound link counts across all sources.
ADR plans/ADR-internal-linking-v2.md §4.5. The dashboard reads exclusively from hatamoto_entity_link_index (single indexed scan, ~ms), never re-walks JSONB.
type InsertInput ¶
type InsertInput struct {
SourceEntityID uuid.UUID
TargetEntityID uuid.UUID
TargetSlug string
TargetSchemaSlug string
AnchorText string
CanonicalURL string
}
InsertInput is the per-call input the /linking/insert endpoint and the suggestion-panel "Insert" action push through Insert(). The caller has already resolved CanonicalURL via Resolve() — Insert is deliberately agnostic to schema so it can wrap any pre-resolved URL.
TargetSlug + TargetSchemaSlug populate the generated `<a>` tag's `data-target-slug` / `data-target-schema` attributes. The handler reads these off the target entity row it already loaded, so no extra DB hits.
type InsertResult ¶
InsertResult reports what Insert did. Inserted is true exactly when stored content changed; SkipReason is set otherwise (mirrors the WrapAll skip taxonomy).
func Insert ¶
func Insert(ctx context.Context, db *database.DB, in InsertInput) (InsertResult, error)
Insert wraps a single anchor span in a source entity's stored content with an <a href data-entity-id class="entity-link"> element and refreshes the reverse-lookup index in the same transaction.
Concurrency: takes SELECT ... FOR UPDATE on hatamoto_entities for the source row, mirroring rewriter.go's strategy. This serialises Insert against the rewriter and any concurrent editor save touching the same row.
Idempotent: WrapAll's hasEntityLink guard reports SkipAlreadyLinked when the source already contains an anchor to TargetEntityID, so re-firing Insert with the same input is a no-op (no second wrap, no DB write).
On skip (no_match, in_heading, etc.) the function returns success with Inserted=false and SkipReason set — the caller (UI) decides whether to surface that to the user. No transaction is committed when nothing changes.
type LinkRule ¶
type LinkRule struct {
ID uuid.UUID `json:"id"`
TargetEntityID uuid.UUID `json:"target_entity_id"`
RuleType RuleType `json:"rule_type"`
MatchValue string `json:"match_value"`
Priority int `json:"priority"`
MaxLinksPerSource int `json:"max_links_per_source"`
IsActive bool `json:"is_active"`
AnchorText *string `json:"anchor_text,omitempty"`
Source string `json:"source"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
TargetEntityName string `json:"target_entity_name,omitempty"`
TargetEntitySlug string `json:"target_entity_slug,omitempty"`
TargetSchemaSlug string `json:"target_schema_slug,omitempty"`
LinkCount int `json:"link_count"` // Number of active v2 links pointing at the target
}
LinkRule represents a reusable rule for generating internal links.
func CreateRule ¶
CreateRule creates a new linking rule.
type LinkSpec ¶
type LinkSpec struct {
TargetEntityID uuid.UUID
TargetSlug string
TargetSchemaSlug string
AnchorText string
CanonicalURL string
}
LinkSpec is the per-link input the wrap algorithm consumes. The migration driver and the /linking/insert endpoint both assemble these — the latter with a single spec at a time.
CanonicalURL must already be resolved by Resolve(). AnchorText is the caller-supplied phrase; the wrapper normalises HTML entities (M&S → M&S) before searching so equivalent encodings still match.
TargetSlug and TargetSchemaSlug, when non-empty, are persisted on the generated <a> tag as `data-target-slug` and `data-target-schema`. These markers decouple the link's target identity from whatever the resolver baked into CanonicalURL, so the v2 slug-rename rewriter can find what it needs without parsing hrefs. The historical v2 backfill (retired post-cutover) left them empty; the one-shot normaliser (cmd/normalize-link-markers) populates them for those legacy rows. New inserts via Insert() should populate both.
type NoopSuggester ¶
type NoopSuggester struct{}
NoopSuggester remains for tests / wiring that don't want a DB-backed implementation. Real callers (the HTTP handler) construct a DBSuggester.
func (NoopSuggester) Suggest ¶
func (NoopSuggester) Suggest(_ context.Context, _ uuid.UUID) ([]Suggestion, error)
Suggest satisfies Suggester for NoopSuggester.
type PaginatedRulesResult ¶
type PaginatedRulesResult struct {
Rules []*LinkRule `json:"rules"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
PaginatedRulesResult wraps a page of rules with pagination metadata.
func ListRulesPaginated ¶
func ListRulesPaginated(ctx context.Context, db *database.DB, filter *RuleFilter) (*PaginatedRulesResult, error)
ListRulesPaginated returns linking rules with pagination, search, and sorting.
type ParsedLink ¶
type ParsedLink struct {
TargetEntityID uuid.UUID
AnchorText string
Href string
OccurrenceIndex int
}
ParsedLink is a single occurrence of an <a data-entity-id="…"> tag inside rendered content. The OccurrenceIndex disambiguates multiple links from the same source to the same target — necessary because the index PK is (source, target, occurrence_index).
func ParseLinks ¶
func ParseLinks(content string) ([]ParsedLink, error)
ParseLinks walks an HTML fragment and returns every <a> tag carrying a valid UUID data-entity-id, preserving document order. Tags without data-entity-id (hand-typed hrefs) are intentionally skipped — they are outside v2's index because we can't safely rewrite them on slug rename.
HTML attribute names are normalised to lower-case by x/net/html, so the case of the source attribute (data-entity-id, DATA-ENTITY-ID, etc.) doesn't matter.
func ParseLinksFromAttributes ¶
func ParseLinksFromAttributes(attrs []byte) ([]ParsedLink, error)
ParseLinksFromAttributes parses the full attributes JSON and returns every `<a data-entity-id>` tag from any wrap-target text leaf (top-level `content`/`full_description` plus every page_sections[*].content.* body/description/answer/etc.). The OccurrenceIndex is per-target across the entire entity so the index PK stays unique even when the same target is linked from multiple sections.
Returns nil + nil if attrs is empty or has no linkable leaves.
type RuleFilter ¶
type RuleFilter struct {
RuleType RuleType `json:"rule_type,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Source string `json:"source,omitempty"`
Query string `json:"q"`
TargetEntityID *uuid.UUID `json:"target_entity_id"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Sort string `json:"sort"`
Order string `json:"order"`
}
RuleFilter filters rules in list queries with optional pagination.
type RulePreviewMatch ¶
type RulePreviewMatch struct {
EntityID uuid.UUID `json:"entity_id"`
EntityName string `json:"entity_name"`
EntitySlug string `json:"entity_slug"`
SchemaSlug string `json:"schema_slug"`
Status string `json:"status"`
SkipReason string `json:"skip_reason,omitempty"`
}
RulePreviewMatch is one source entity whose content mentions a rule's phrase, with the outcome a wrap would have on it today.
Status is one of:
- "insertable" — the phrase appears in linkable body text and the source does not yet link to the target. The suggester would offer it.
- "already_linked" — the source already links to the target.
- "skipped" — the phrase only appears where links are never inserted (headings, code); SkipReason carries the detail.
type RulePreviewResult ¶
type RulePreviewResult struct {
// MatchCount is the number of source entities mentioning the phrase
// (every status). InsertableCount is the subset the suggester would offer.
MatchCount int `json:"match_count"`
InsertableCount int `json:"insertable_count"`
LinkedCount int `json:"linked_count"`
Matches []RulePreviewMatch `json:"matches"`
}
RulePreviewResult is the response of PreviewRule.
func PreviewRule ¶
func PreviewRule(ctx context.Context, db *database.DB, phrase string, targetEntityID uuid.UUID) (RulePreviewResult, error)
PreviewRule dry-runs a rule phrase against every published entity and reports which ones mention it and what a wrap would do there. It is the backend for the admin "eye" preview and the live preview in the rule editor. Read-only.
The target entity itself and every variant sharing its (schema, slug) are excluded — linking a service page to itself is never a suggestion. A cheap ILIKE prefilter on the attributes JSON narrows the candidate set before the DOM-aware WrapAllJSONB dry-run decides the outcome; the prefilter is skipped for phrases with characters that may be HTML- or JSON-escaped in storage (so it can only widen, never miss).
type RuleType ¶
type RuleType string
RuleType defines how a linking rule matches content.
Only keyword rules exist. The legacy "tag" and "schema_affinity" types were never read by the v2 suggester (it matches the phrase in content regardless of type), so they were removed to stop implying behaviour that does not exist. The rule_type column stays for schema compatibility and always holds "keyword".
const (
RuleTypeKeyword RuleType = "keyword"
)
type Suggester ¶
type Suggester interface {
Suggest(ctx context.Context, sourceEntityID uuid.UUID) ([]Suggestion, error)
}
Suggester proposes links for review without mutating content.
type Suggestion ¶
type Suggestion struct {
TargetEntityID uuid.UUID `json:"target_entity_id"`
TargetName string `json:"target_name"`
TargetSchemaSlug string `json:"target_schema_slug"`
AnchorPhrase string `json:"anchor_phrase"`
URL string `json:"url"`
// Status is "insertable" when WrapAll would successfully wrap on a fresh
// copy of the source content, else "skipped" with SkipReason populated.
// The panel UI uses this to render an Insert button vs a greyed row with
// the reason ("already linked to /blog/seo", "inside <code>", …).
Status string `json:"status"`
SkipReason string `json:"skip_reason,omitempty"`
Priority int `json:"priority"`
}
Suggestion is a single proposed link the editor may accept or skip. The suggestion panel renders one row per Suggestion; "Insert" sends the (Source, Target, Anchor) tuple to POST /linking/insert.
func Suggest ¶
func Suggest(ctx context.Context, db *database.DB, urls URLBuilder, sourceEntityID uuid.UUID, limit int) ([]Suggestion, error)
Suggest runs the non-mutating suggestion engine for sourceEntityID.
Algorithm (ADR §4.2):
- Load source's content (from attributes JSONB).
- Load active hatamoto_internal_link_rules joined with their target entity's slug + schema (rules are repurposed as alias hints in v2).
- Load the set of targets already linked from this source via the hatamoto_entity_link_index projection — exclude these from suggestions.
- For each remaining rule, dry-run WrapAll against the unmodified source content with that rule's anchor phrase. The single-LinkSpec call tells us whether this rule would have landed had it been applied alone.
- Return one Suggestion per rule with status=insertable|skipped, ordered by rule priority DESC (rules already store priority).
The function is read-only. Insertion happens via the separate /linking/insert path so the panel UX can defer the write to an explicit click.
func SuggestWithContent ¶
func SuggestWithContent(ctx context.Context, db *database.DB, urls URLBuilder, sourceEntityID uuid.UUID, limit int, contentOverride *string) ([]Suggestion, error)
SuggestWithContent is Suggest with an optional live-editor override for attributes.content. The editor's suggestion panel sends the HTML it is currently holding so suggestions (and the already-linked filter) reflect unsaved edits rather than the last persisted row. When contentOverride is non-nil the persisted link index is NOT consulted — WrapAllJSONB's own entityLinkedAnywhere guard reports already_linked from the live content, so a link the editor just removed becomes suggestible again immediately.
type URLBuilder ¶
type URLBuilder interface {
// BuildURL renders the path for an entity of schemaSlug. attrs are the
// entity's attributes flattened to strings (pattern overrides compare
// against them; nil is fine). known is false when no schema matches.
BuildURL(schemaSlug string, attrs map[string]string, v urlpattern.Vars) (path string, known bool, err error)
}
URLBuilder renders the public path of an entity from the schema URL patterns. It is injected so this package stays free of storage concerns.
type UpdateRuleInput ¶
type UpdateRuleInput struct {
TargetEntityID *uuid.UUID `json:"target_entity_id,omitempty"`
MatchValue *string `json:"match_value,omitempty"`
Priority *int `json:"priority,omitempty"`
MaxLinksPerSource *int `json:"max_links_per_source,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
AnchorText *string `json:"anchor_text,omitempty"`
}
UpdateRuleInput represents input for updating a linking rule.
AnchorText: nil leaves it unchanged; "" clears it (the suggester then falls back to match_value).
type WrapOutcome ¶
WrapOutcome is the result of one wrap attempt: exactly one of Inserted or SkipReason is set.
type WrapResult ¶
WrapResult is the aggregate after attempting every spec for one source. NewHTML is empty when no wraps were applied (caller can skip the write).
func WrapAll ¶
func WrapAll(contentHTML string, specs []LinkSpec) (WrapResult, error)
WrapAll runs the per-source DOM walk for every spec in priority order and returns the rewritten HTML plus per-reason skip counts. Each spec is attempted against the live document — earlier wraps mutate the tree that later wraps walk, which is how "only the first occurrence per (source, target)" emerges. If no wraps are applied, NewHTML is empty.
func WrapAllJSONB ¶
func WrapAllJSONB(attrs []byte, specs []LinkSpec) (WrapResult, []byte, error)
WrapAllJSONB walks an entity's full attributes JSON and applies each spec to the first wrappable text leaf, mirroring the v1 render-time wrap topology. Returns aggregate counts and the mutated attributes JSON (same shape as input). If no wrap landed, returns the input bytes unchanged.
Algorithm:
- Unmarshal the attributes into a tree.
- Collect every string leaf whose parent key is in `wrapTargetFields`, in document order.
- For each spec, check if the target is already linked in ANY leaf — if so, count as already_linked and move on (matches single-string WrapAll's hasEntityLink guard, scaled across leaves).
- Otherwise walk leaves in order and call `wrapFirstOccurrence` on each until one succeeds; stop on first hit.
- Re-serialize and return.
Cross-leaf safety: the wrap walker uses the existing skip-tag list (a, h1-h6, code, pre) per leaf. Field-name-based heading exclusion (e.g. "headline") is handled by the allowlist above — those fields are never even considered as wrap targets.