Help us shape the future of Go! Take the 2026 Go Developer Survey

v2

package
v1.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

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

View Source
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.

View Source
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.)

View Source
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

View Source
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.

View Source
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.

View Source
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.

View Source
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.

View Source
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

func DeleteAllRules(ctx context.Context, db *database.DB) (int, error)

DeleteAllRules removes every linking rule from the site and returns the number deleted. Used by the admin "clean slate" toolbar action.

func DeleteRule

func DeleteRule(ctx context.Context, db *database.DB, id uuid.UUID) error

DeleteRule removes a linking rule.

func DeleteRulesByIDs

func DeleteRulesByIDs(ctx context.Context, db *database.DB, ids []uuid.UUID) (int, error)

DeleteRulesByIDs removes a specific set of rules by ID and returns the number deleted. Missing IDs are silently ignored.

func DeleteRulesBySource

func DeleteRulesBySource(ctx context.Context, db *database.DB, source string) (int, error)

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

func Reindex(ctx context.Context, db *database.DB, sourceEntityID uuid.UUID) error

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(
	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(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

func ComputeEquity(ctx context.Context, db *database.DB) (*EquityReport, error)

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

type InsertResult struct {
	Inserted    bool
	SkipReason  string
	NewContent  string
	ContentSize int
}

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

func CreateRule(ctx context.Context, db *database.DB, input *CreateRuleInput) (*LinkRule, error)

CreateRule creates a new linking rule.

func GetRule

func GetRule(ctx context.Context, db *database.DB, id uuid.UUID) (*LinkRule, error)

GetRule returns a single rule by ID.

func UpdateRule

func UpdateRule(ctx context.Context, db *database.DB, id uuid.UUID, input *UpdateRuleInput) (*LinkRule, error)

UpdateRule updates an existing 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&amp;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 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(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):

  1. Load source's content (from attributes JSONB).
  2. Load active hatamoto_internal_link_rules joined with their target entity's slug + schema (rules are repurposed as alias hints in v2).
  3. Load the set of targets already linked from this source via the hatamoto_entity_link_index projection — exclude these from suggestions.
  4. 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.
  5. 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

type WrapOutcome struct {
	Inserted   bool
	SkipReason string
}

WrapOutcome is the result of one wrap attempt: exactly one of Inserted or SkipReason is set.

type WrapResult

type WrapResult struct {
	NewHTML  string
	Inserted int
	Skips    map[string]int
}

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:

  1. Unmarshal the attributes into a tree.
  2. Collect every string leaf whose parent key is in `wrapTargetFields`, in document order.
  3. 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).
  4. Otherwise walk leaves in order and call `wrapFirstOccurrence` on each until one succeeds; stop on first hit.
  5. 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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL