diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index f74aca4..9b797a3 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -18,6 +18,7 @@ import { Loader2, Maximize2, Minimize2, + MousePointer2, Pencil, Plug, Plus, @@ -7076,6 +7077,7 @@ function getPipelineStageAccess( hasProject: boolean; keywordMap: KeywordMapContract | null; marketEnrichment: MarketEnrichmentContract | null; + rewritePlan: RewritePlanContract | null; scan: ProjectScanSummary | null | undefined; semanticAnalysis: SemanticAnalysisRun | null; seoStrategy: SeoStrategyContract | null; @@ -7088,6 +7090,7 @@ function getPipelineStageAccess( const marketReady = Boolean(input.marketEnrichment && input.marketEnrichment.state !== "not_ready"); const keywordReady = Boolean(input.keywordMap && input.keywordMap.readiness.mappedItemCount > 0); const strategyReady = Boolean(input.seoStrategy && input.seoStrategy.state !== "blocked"); + const planReady = strategyReady || Boolean(input.rewritePlan); const synthesisReady = strategyReady && input.strategySynthesis?.state === "ready"; const qualityReady = synthesisReady && input.strategyQualityReview?.state === "ready"; @@ -7145,9 +7148,9 @@ function getPipelineStageAccess( if (stageIndex === 6) { return { - reason: strategyReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "План правок откроется после формирования стратегии.", - status: strategyReady ? "ready" : "locked", - unlocked: strategyReady + reason: planReady ? "Стратегия посадочных собрана, можно смотреть план правок." : "План правок откроется после формирования стратегии.", + status: planReady ? "ready" : "locked", + unlocked: planReady }; } @@ -8307,9 +8310,10 @@ function getWorkspaceTargetPreviewNeedles(target: WorkspaceTarget) { function buildVisualComposerGroupText( groupId: string, targets: WorkspaceTarget[], - sectionTextMap: WorkspaceSectionTextMap | undefined + sectionTextMap: WorkspaceSectionTextMap | undefined, + semanticGroupContextTexts: Record | undefined ) { - return targets + const boundText = targets .filter((target) => target.semanticGroup?.id === groupId && target.permission === "editable") .sort((left, right) => (left.semanticGroup?.fieldOrder ?? left.number) - (right.semanticGroup?.fieldOrder ?? right.number)) .map((target) => { @@ -8319,6 +8323,13 @@ function buildVisualComposerGroupText( }) .filter(Boolean) .join("\n"); + const contextText = (semanticGroupContextTexts?.[groupId] ?? []) + .map((value) => value.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .map((value) => `context: ${value}`) + .join("\n"); + + return [boundText, contextText].filter(Boolean).join("\n"); } function buildVisualComposerPageContextText(targets: WorkspaceTarget[], sectionTextMap: WorkspaceSectionTextMap | undefined) { @@ -8356,7 +8367,8 @@ function buildVisualComposerSeedInputs( pageId: string | null, pageTitle: string | null, targets: WorkspaceTarget[], - sectionTextMap: WorkspaceSectionTextMap | undefined + sectionTextMap: WorkspaceSectionTextMap | undefined, + semanticGroupContextTexts?: Record ): VisualComposerSeedInput[] { if (!pageId) { return []; @@ -8382,7 +8394,7 @@ function buildVisualComposerSeedInputs( fieldCount: target.semanticGroup.fieldCount, fieldOrder: target.semanticGroup.fieldOrder, fieldRole: target.semanticGroup.fieldRole, - groupText: buildVisualComposerGroupText(target.semanticGroup.id, targets, sectionTextMap), + groupText: buildVisualComposerGroupText(target.semanticGroup.id, targets, sectionTextMap, semanticGroupContextTexts), id: target.semanticGroup.id, source: target.semanticGroup.source, title: target.semanticGroup.title @@ -8921,7 +8933,7 @@ function getSectionWorkspaceScopeItem( section: ProjectScanSummary["siteSections"][number], fallbackOrder: number ): WorkspaceScopeItem | null { - if (!section.pageId || !section.enabled || !section.selected) { + if (!section.pageId || !section.enabled) { return null; } @@ -8952,61 +8964,133 @@ function getSectionWorkspaceScopeItem( }; } -function isSafeWorkspaceDomIdFragment(value: string | null | undefined) { - return Boolean(value && /^[a-zA-Z][\w:-]*$/.test(value)); -} - -function getWorkspaceScopedAnchorFallbackSelector(scopeItem: WorkspaceScopeItem | null | undefined) { - if ( - !scopeItem || - scopeItem.kind !== "section" || - (scopeItem.previewTargetIndex ?? 0) <= 0 || - !scopeItem.sectionId || - !scopeItem.previewTargetSelector?.startsWith("#") - ) { - return null; - } - - const anchorId = scopeItem.previewTargetSelector.slice(1); - - if (!isSafeWorkspaceDomIdFragment(scopeItem.sectionId) || !isSafeWorkspaceDomIdFragment(anchorId)) { - return null; - } - - return `#${scopeItem.sectionId}-${anchorId}`; -} - function getWorkspaceScopePreviewFocusSelector(scopeItem: WorkspaceScopeItem | null | undefined) { - return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ?? scopeItem?.previewTargetSelector ?? null; + return scopeItem?.previewTargetSelector ?? null; } function getWorkspaceScopePreviewFocusIndex(scopeItem: WorkspaceScopeItem | null | undefined) { - return getWorkspaceScopedAnchorFallbackSelector(scopeItem) ? 0 : scopeItem?.previewTargetIndex ?? null; + return scopeItem?.previewTargetIndex ?? null; } function normalizeWorkspacePreviewSourceKey(value: string | null | undefined) { return (value ?? "") .replace(/\\/g, "/") - .replace(/^\/+/, "") - .toLocaleLowerCase("ru-RU"); + .replace(/^\/+/, ""); } function getWorkspaceScopePreviewSourceKey(scopeItem: WorkspaceScopeItem | null | undefined) { return normalizeWorkspacePreviewSourceKey(scopeItem?.previewSourcePath ?? scopeItem?.sourcePath); } +type WorkspaceSectionHostRef = { + pageId: string | null; + previewSourcePath: string | null; + sourcePath: string; +}; + +function getWorkspaceSectionHostPage(scan: ProjectScanSummary, section: WorkspaceSectionHostRef) { + const exactPage = section.pageId + ? scan.pages.find( + (page) => + page.pageId === section.pageId && + page.scope !== "template_block" && + isWorkspaceScopePage(page) + ) ?? null + : null; + + if (exactPage) { + return exactPage; + } + + const previewSourceKey = normalizeWorkspacePreviewSourceKey(section.previewSourcePath ?? section.sourcePath); + + if (!previewSourceKey) { + return null; + } + + // A source path is only a safe fallback identity when the whole scan has one + // logical, non-template page for it. One selected route among several routes + // served by the same SPA index is still ambiguous and must not inherit blocks. + const logicalHostPages = scan.pages.filter( + (page) => + page.scope !== "template_block" && + normalizeWorkspacePreviewSourceKey(page.previewSourcePath ?? page.sourcePath) === previewSourceKey + ); + + return logicalHostPages.length === 1 && isWorkspaceScopePage(logicalHostPages[0]!) + ? logicalHostPages[0]! + : null; +} + +function getWorkspaceRenderedPageKey( + scopeItem: WorkspaceScopeItem | null | undefined, + scopeItems: WorkspaceScopeItem[], + scan: ProjectScanSummary +) { + if (!scopeItem) { + return ""; + } + + // Logical pages are distinct render instances even when an SPA serves all + // routes from one index.html. Sections may join a page only when the scanner + // result has one unambiguous host page for their exact, case-sensitive source. + if (scopeItem.kind === "page") { + return `page:${scopeItem.pageId}`; + } + + const hostPage = getWorkspaceSectionHostPage(scan, scopeItem); + const hostScopeItem = hostPage + ? scopeItems.find((item) => item.kind === "page" && item.pageId === hostPage.pageId) + : null; + + if (hostScopeItem) { + return `page:${hostScopeItem.pageId}`; + } + + // Ambiguous SPA/template output must remain isolated until the scanner can + // provide an opaque render-instance id. Isolation is safer than cross-page + // merging and destructive replacement during persistence. + return `scope:${scopeItem.scopeId}`; +} + +function normalizeWorkspaceScopeSearchText(value: string | null | undefined) { + return (value ?? "") + .normalize("NFKC") + .toLocaleLowerCase() + .replace(/\s+/g, " ") + .trim(); +} + +function filterWorkspaceScopeItems(scopeItems: WorkspaceScopeItem[], query: string) { + const normalizedQuery = normalizeWorkspaceScopeSearchText(query); + + if (!normalizedQuery) { + return scopeItems; + } + + return scopeItems.filter((item) => + normalizeWorkspaceScopeSearchText( + [item.title, item.pathLabel, item.sourcePath, item.previewSourcePath, ...item.headings].filter(Boolean).join(" ") + ).includes(normalizedQuery) + ); +} + function getSelectedWorkspaceScopeItems(scan: ProjectScanSummary): WorkspaceScopeItem[] { + const eligibleSections = scan.siteSections.filter( + (section) => + section.enabled && + Boolean(section.pageId) && + (section.selected || Boolean(getWorkspaceSectionHostPage(scan, section))) + ); const sectionPageIds = new Set( - scan.siteSections - .filter((section) => section.selected && section.enabled && section.pageId) - .map((section) => section.pageId!) + eligibleSections.map((section) => section.pageId!) ); const pageItems = scan.pages .map((page, index) => ({ index, page })) .filter(({ page }) => isWorkspaceScopePage(page)) .filter(({ page }) => page.scope !== "template_block" || !sectionPageIds.has(page.pageId)) .map(({ page, index }) => getPageWorkspaceScopeItem(page, index)); - const sectionItems = scan.siteSections + const sectionItems = eligibleSections .map((section, index) => getSectionWorkspaceScopeItem(section, index)) .filter((item): item is WorkspaceScopeItem => Boolean(item)); @@ -9204,6 +9288,14 @@ function getWorkspaceScopeIdFromWorkspaceKey(workspaceKey: string) { return workspaceKey.split(":").slice(1).join(":"); } +function omitProjectWorkspaceEntries(record: Record, projectId: string) { + const workspacePrefix = `${projectId}:`; + + return Object.fromEntries( + Object.entries(record).filter(([key]) => key !== projectId && !key.startsWith(workspacePrefix)) + ) as Record; +} + type WorkspaceSection = PageWorkspace["sections"][number]; type WorkspaceSectionTextMap = Record; @@ -9272,6 +9364,7 @@ const WORKSPACE_PREVIEW_MODES: Array<{ id: WorkspacePreviewMode; label: string } const SEMANTIC_BLOCK_EDITOR_ENABLED = true; const SEMANTIC_BLOCK_OVERRIDES_ENABLED = true; +const SEMANTIC_BLOCK_EDITOR_MODEL = "semantic_membership.v2"; type PreviewMarker = { targetId: string; @@ -9288,6 +9381,7 @@ type WorkspaceSemanticBlockOverride = { capturedEntities?: WorkspaceSemanticBlockCapturedEntity[]; geometry?: WorkspaceSemanticBlockGeometry; id: string; + identity?: Record; title: string; targetIds: string[]; }; @@ -9299,6 +9393,42 @@ type WorkspaceSemanticBlockDraftBox = WorkspaceSemanticBlockOverride & { width?: number; }; +function markStage7SemanticMembership(box: T): T { + return { + ...box, + identity: { + ...(box.identity ?? {}), + editorModel: SEMANTIC_BLOCK_EDITOR_MODEL, + membershipAuthority: "explicit_members" + } + }; +} + +function isStage7SemanticMembership(box: WorkspaceSemanticBlockOverride | WorkspaceSemanticBlockDraftBox) { + return box.identity?.editorModel === SEMANTIC_BLOCK_EDITOR_MODEL || box.identity?.membershipAuthority === "explicit_members"; +} + +type WorkspaceTextInspectorSelection = { + characterCount: number; + childElementCount: number; + draftApplied: boolean; + editable: boolean; + id: string; + keywordBindings: Array<{ phrase?: string; role?: string }>; + previewPatchSafe: boolean; + rect: { height: number; left: number; top: number; width: number }; + role: string; + sectionId: string; + selector: string; + selectorIndex: number; + semanticBlockId: string; + tagName: string; + targetId: string; + targetSource: string; + targetTitle: string; + text: string; +}; + type WorkspaceSemanticBlockAnchor = { entityType: string; fingerprint?: { @@ -9317,9 +9447,73 @@ type WorkspaceSemanticBlockAnchor = { text?: string; }; +function sanitizeWorkspaceTextInspectorSelection(value: unknown): WorkspaceTextInspectorSelection | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const record = value as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + const selector = typeof record.selector === "string" ? record.selector.trim() : ""; + const text = typeof record.text === "string" ? record.text.trim().slice(0, 4000) : ""; + const rectRecord = record.rect && typeof record.rect === "object" && !Array.isArray(record.rect) + ? record.rect as Record + : null; + + if ( + !id || + !selector || + !text || + typeof rectRecord?.left !== "number" || + typeof rectRecord?.top !== "number" || + typeof rectRecord?.width !== "number" || + typeof rectRecord?.height !== "number" + ) { + return null; + } + + const keywordBindings = Array.isArray(record.keywordBindings) + ? record.keywordBindings.flatMap((binding): Array<{ phrase?: string; role?: string }> => { + if (!binding || typeof binding !== "object" || Array.isArray(binding)) return []; + const bindingRecord = binding as Record; + const phrase = typeof bindingRecord.phrase === "string" ? bindingRecord.phrase.trim().slice(0, 300) : undefined; + const role = typeof bindingRecord.role === "string" ? bindingRecord.role.trim().slice(0, 80) : undefined; + return phrase || role ? [{ phrase, role }] : []; + }).slice(0, 12) + : []; + + return { + characterCount: typeof record.characterCount === "number" ? Math.max(0, record.characterCount) : text.length, + childElementCount: typeof record.childElementCount === "number" ? Math.max(0, record.childElementCount) : 0, + draftApplied: Boolean(record.draftApplied), + editable: Boolean(record.editable), + id, + keywordBindings, + previewPatchSafe: Boolean(record.previewPatchSafe), + rect: { + height: Math.max(0, rectRecord.height), + left: Math.max(0, rectRecord.left), + top: Math.max(0, rectRecord.top), + width: Math.max(0, rectRecord.width) + }, + role: typeof record.role === "string" ? record.role.slice(0, 80) : "text", + sectionId: typeof record.sectionId === "string" ? record.sectionId.slice(0, 180) : "", + selector, + selectorIndex: typeof record.selectorIndex === "number" ? Math.max(0, record.selectorIndex) : 0, + semanticBlockId: typeof record.semanticBlockId === "string" ? record.semanticBlockId.slice(0, 180) : "", + tagName: typeof record.tagName === "string" ? record.tagName.slice(0, 40) : "text", + targetId: typeof record.targetId === "string" ? record.targetId.slice(0, 240) : "", + targetSource: typeof record.targetSource === "string" ? record.targetSource.slice(0, 80) : "unbound_dom", + targetTitle: typeof record.targetTitle === "string" ? record.targetTitle.slice(0, 300) : "", + text + }; +} + type WorkspaceSemanticBlockCapturedEntity = { id: string; kind: string; + pageId?: string; + scopeId?: string; selector: string; selectorIndex: number; text?: string; @@ -9336,6 +9530,7 @@ type WorkspaceSemanticBlockGeometry = { documentWidth?: number; height: number; left: number; + mode?: "anchor" | "manual"; offsets?: { bottom: number; left: number; @@ -9349,6 +9544,12 @@ type WorkspaceSemanticBlockGeometry = { const SEMANTIC_BLOCK_OVERRIDES_STORAGE_KEY = "seo-mode.semanticBlockOverrides.v3"; const WORKSPACE_UI_CONTEXT_STORAGE_KEY = "seo-mode.workspaceUiContext.v1"; +function sanitizeSemanticBlockIdentity(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? { ...(value as Record) } + : undefined; +} + function sanitizeSemanticBlockCapturedEntities(value: unknown): WorkspaceSemanticBlockCapturedEntity[] { if (!Array.isArray(value)) { return []; @@ -9370,6 +9571,8 @@ function sanitizeSemanticBlockCapturedEntities(value: unknown): WorkspaceSemanti { id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : selector, kind: typeof record.kind === "string" && record.kind.trim() ? record.kind.trim() : "element", + pageId: typeof record.pageId === "string" && record.pageId.trim() ? record.pageId.trim() : undefined, + scopeId: typeof record.scopeId === "string" && record.scopeId.trim() ? record.scopeId.trim() : undefined, selector, selectorIndex: typeof record.selectorIndex === "number" && Number.isFinite(record.selectorIndex) ? Math.max(0, record.selectorIndex) : 0, text: typeof record.text === "string" && record.text.trim() ? record.text.trim().slice(0, 500) : undefined @@ -9462,6 +9665,8 @@ function getAnchorsFromCapturedEntities(entities: WorkspaceSemanticBlockCaptured text: entity.text }, id: `captured:${entity.selector}:${entity.selectorIndex}`, + pageId: entity.pageId, + scopeId: entity.scopeId, selector: entity.selector, selectorIndex: entity.selectorIndex, source: "captured_dom", @@ -9517,6 +9722,7 @@ function sanitizeSemanticBlockGeometry(value: unknown): WorkspaceSemanticBlockGe documentWidth: typeof record.documentWidth === "number" && Number.isFinite(record.documentWidth) ? record.documentWidth : undefined, height: Math.max(2, height), left: Math.max(0, left), + mode: record.mode === "manual" ? "manual" : record.mode === "anchor" ? "anchor" : undefined, offsets, top: Math.max(0, top), width: Math.max(2, width) @@ -9544,6 +9750,7 @@ function getMergedSemanticBlockGeometry( documentWidth: Math.max(current.documentWidth ?? 0, next.documentWidth ?? 0) || (current.documentWidth ?? next.documentWidth), height: Math.max(2, bottom - top), left, + mode: current.mode === "manual" || next.mode === "manual" ? "manual" : current.mode ?? next.mode, top, width: Math.max(2, right - left) }; @@ -9564,21 +9771,17 @@ function collapseCarouselVisualSemanticBlockOverrides(overrides: WorkspaceSemant const visualMatch = /^(.*):visual-\d+$/i.exec(override.id); const titleMatch = /^(.*?)(?:\s+|[-_#])\d+$/i.exec(override.title); const baseTitle = (titleMatch?.[1] ?? override.title).trim(); - const hasGeometry = Boolean(override.geometry); - const baseId = - visualMatch?.[1] ?? - (titleMatch && hasGeometry && CAROUSEL_LIKE_SEMANTIC_PATTERN.test(baseTitle.toLocaleLowerCase("ru-RU")) - ? `manual-carousel:${normalizeSemanticBlockIdFragment(baseTitle).toLocaleLowerCase("ru-RU")}` - : ""); + const baseId = visualMatch?.[1] ?? ""; const searchableValue = `${baseId} ${baseTitle} ${override.title}`.toLocaleLowerCase("ru-RU"); + // A stable :visual-N id may represent several projections of one entity. + // Geometry/title similarity alone must never create semantic membership. if (!baseId || !CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) { result.push(override); return; } - const band = Math.round((override.geometry?.top ?? 0) / Math.max(220, override.geometry?.height ?? 220)); - const mergeKey = baseId.startsWith("manual-carousel:") ? `${baseId}:band-${band}` : baseId; + const mergeKey = baseId; const current = merged.get(mergeKey); if (!current) { @@ -9596,11 +9799,36 @@ function collapseCarouselVisualSemanticBlockOverrides(overrides: WorkspaceSemant current.anchors = mergeSemanticBlockAnchors(current.anchors, override.anchors); current.capturedEntities = sanitizeSemanticBlockCapturedEntities([...(current.capturedEntities ?? []), ...(override.capturedEntities ?? [])]); current.geometry = getMergedSemanticBlockGeometry(current.geometry, override.geometry); + current.identity = { + ...(override.identity ?? {}), + ...(current.identity ?? {}) + }; }); return result; } +function mergeSemanticBlockOverrideMembership( + current: WorkspaceSemanticBlockOverride | WorkspaceSemanticBlockDraftBox, + next: WorkspaceSemanticBlockOverride | WorkspaceSemanticBlockDraftBox +): WorkspaceSemanticBlockOverride { + return { + anchors: mergeSemanticBlockAnchors(current.anchors, next.anchors), + capturedEntities: sanitizeSemanticBlockCapturedEntities([ + ...(current.capturedEntities ?? []), + ...(next.capturedEntities ?? []) + ]), + geometry: getMergedSemanticBlockGeometry(current.geometry, next.geometry), + id: current.id, + identity: { + ...(next.identity ?? {}), + ...(current.identity ?? {}) + }, + targetIds: Array.from(new Set([...current.targetIds, ...next.targetIds])), + title: current.title || next.title + }; +} + function sanitizeStoredWorkspaceSemanticBlockOverrides(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; @@ -9628,9 +9856,10 @@ function sanitizeStoredWorkspaceSemanticBlockOverrides(value: unknown): Record 0 || capturedEntities.length > 0 || anchors.length > 0) - ? [{ anchors, capturedEntities, geometry, id, targetIds, title }] + ? [{ anchors, capturedEntities, geometry, id, identity, targetIds, title }] : []; }); @@ -9676,6 +9905,7 @@ function sanitizeSemanticBlockDraftBoxes(value: unknown): WorkspaceSemanticBlock ...getAnchorsFromCapturedEntities(capturedEntities) ]); const geometry = sanitizeSemanticBlockGeometry(record.geometry); + const identity = sanitizeSemanticBlockIdentity(record.identity); if (!id) { return []; @@ -9688,6 +9918,7 @@ function sanitizeSemanticBlockDraftBoxes(value: unknown): WorkspaceSemanticBlock geometry, height: typeof record.height === "number" ? record.height : undefined, id, + identity, left: typeof record.left === "number" ? record.left : undefined, targetIds, title, @@ -9721,6 +9952,7 @@ function commitSemanticBlockDraftBoxes(boxes: WorkspaceSemanticBlockDraftBox[] | capturedEntities, geometry, id: box.id, + identity: sanitizeSemanticBlockIdentity(box.identity), targetIds, title: box.title } @@ -9740,7 +9972,25 @@ function getSemanticBlockOverridesFromModels( return []; } - const capturedEntities = sanitizeSemanticBlockCapturedEntities(block.capturedEntities); + const capturedEntities = sanitizeSemanticBlockCapturedEntities([ + ...block.capturedEntities, + ...block.sourceBindings.flatMap((binding) => { + if (!binding || typeof binding !== "object" || Array.isArray(binding)) return []; + const record = binding as Record; + const selector = typeof record.selector === "string" ? record.selector : ""; + if (!selector || (record.contextOnly !== true && record.source !== "captured_dom")) return []; + + return [{ + id: typeof record.id === "string" ? record.id : `binding:${selector}:${Number(record.selectorIndex) || 0}`, + kind: typeof record.kind === "string" ? record.kind : "text", + pageId: typeof record.sourcePageId === "string" ? record.sourcePageId : undefined, + scopeId: typeof record.sourceScopeId === "string" ? record.sourceScopeId : undefined, + selector, + selectorIndex: typeof record.selectorIndex === "number" ? record.selectorIndex : 0, + text: typeof record.textSnapshot === "string" ? record.textSnapshot : undefined + }]; + }) + ]); const anchors = sanitizeSemanticBlockAnchors([ ...block.anchors, ...getAnchorsFromCapturedEntities(capturedEntities) @@ -9752,13 +10002,14 @@ function getSemanticBlockOverridesFromModels( .filter((targetId): targetId is string => Boolean(targetId)); const targetIds = Array.from(new Set([...block.targetIds.filter(Boolean), ...anchorTargetIds])); - return blockKey && targetIds.length > 0 + return blockKey && (targetIds.length > 0 || capturedEntities.length > 0 || anchors.length > 0 || block.sourceBindings.length > 0) ? [ { anchors, capturedEntities, geometry, id: blockKey, + identity: sanitizeSemanticBlockIdentity(block.identity), targetIds, title: block.title || "Semantic block" } @@ -9769,48 +10020,133 @@ function getSemanticBlockOverridesFromModels( return collapseCarouselVisualSemanticBlockOverrides(overrides); } -function buildSemanticBlockSourceBindings(override: WorkspaceSemanticBlockOverride, targets: WorkspaceTarget[]) { - const targetsById = new Map(targets.map((target) => [target.id, target])); - const anchorTargetIds = sanitizeSemanticBlockAnchors(override.anchors) +type SemanticBlockBindingTarget = WorkspaceTarget & { + semanticSourcePageId?: string; + semanticSourceScopeId?: string; + semanticSourceWorkspaceKey?: string; +}; + +function getSemanticBlockResolvedTargets( + override: WorkspaceSemanticBlockOverride, + targets: SemanticBlockBindingTarget[] +) { + const targetsById = targets.reduce>((index, target) => { + const candidates = index.get(target.id) ?? []; + candidates.push(target); + index.set(target.id, candidates); + return index; + }, new Map()); + const anchors = sanitizeSemanticBlockAnchors(override.anchors); + const anchorTargetIds = anchors .map((anchor) => anchor.targetId) .filter((targetId): targetId is string => Boolean(targetId)); const targetIds = Array.from(new Set([...override.targetIds, ...anchorTargetIds])); return targetIds.flatMap((targetId) => { - const target = targetsById.get(targetId); + const candidates = targetsById.get(targetId) ?? []; - if (!target) { + if (candidates.length === 0) { return []; } - return [ - { - contentField: target.contentField ?? null, - fallbackSelector: target.fallbackSelector, - kind: target.kind, - markerSelectors: target.markerSelectors, - permission: target.permission, - sectionId: target.sectionId, - selector: target.selector, - semanticGroup: target.semanticGroup - ? { - fieldCount: target.semanticGroup.fieldCount, - fieldOrder: target.semanticGroup.fieldOrder, - fieldRole: target.semanticGroup.fieldRole, - id: target.semanticGroup.id, - source: target.semanticGroup.source, - title: target.semanticGroup.title - } - : null, - source: target.source, - subtitle: target.subtitle, - targetId, - title: target.title + const identityAnchors = anchors.filter( + (anchor) => anchor.targetId === targetId && Boolean(anchor.scopeId || anchor.pageId) + ); + const anchoredCandidates = new Set(); + + identityAnchors.forEach((anchor) => { + const matches = candidates.filter( + (candidate) => + (!anchor.scopeId || candidate.semanticSourceScopeId === anchor.scopeId) && + (!anchor.pageId || candidate.semanticSourcePageId === anchor.pageId) + ); + + if (matches.length === 1) { + anchoredCandidates.add(matches[0]!); } - ]; + }); + + if (anchoredCandidates.size > 0) { + return Array.from(anchoredCandidates); + } + + // A stale explicit identity must not fall through to another scope. Plain + // legacy target ids remain usable only when they have one possible owner. + return identityAnchors.length === 0 && candidates.length === 1 ? candidates : []; }); } +function buildSemanticBlockSourceBindings(override: WorkspaceSemanticBlockOverride, targets: SemanticBlockBindingTarget[]) { + const boundBindings = getSemanticBlockResolvedTargets(override, targets).map((target) => ({ + contentField: target.contentField ?? null, + fallbackSelector: target.fallbackSelector, + kind: target.kind, + markerSelectors: target.markerSelectors, + permission: target.permission, + sectionId: target.sectionId, + selector: target.selector, + sourcePageId: target.semanticSourcePageId ?? null, + sourceScopeId: target.semanticSourceScopeId ?? null, + sourceWorkspaceKey: target.semanticSourceWorkspaceKey ?? null, + semanticGroup: target.semanticGroup + ? { + fieldCount: target.semanticGroup.fieldCount, + fieldOrder: target.semanticGroup.fieldOrder, + fieldRole: target.semanticGroup.fieldRole, + id: target.semanticGroup.id, + source: target.semanticGroup.source, + title: target.semanticGroup.title + } + : null, + source: target.source, + subtitle: target.subtitle, + targetId: target.id, + title: target.title + })); + const boundSelectors = new Set( + boundBindings.flatMap((binding) => [binding.selector, ...binding.markerSelectors]).filter(Boolean) + ); + const boundTargetIds = new Set(boundBindings.map((binding) => binding.targetId)); + const referencedBindings = sanitizeSemanticBlockAnchors(override.anchors) + .filter((anchor) => Boolean(anchor.targetId && !boundTargetIds.has(anchor.targetId))) + .map((anchor) => ({ + contextOnly: true, + kind: anchor.entityType, + permission: "read_only" as const, + sectionId: null, + selector: anchor.selector ?? null, + selectorIndex: anchor.selectorIndex ?? 0, + source: "workspace_target_reference" as const, + sourcePageId: anchor.pageId ?? null, + sourceScopeId: anchor.scopeId ?? null, + targetId: anchor.targetId ?? null, + textSnapshot: anchor.text ?? anchor.fingerprint?.text ?? "", + title: anchor.text?.slice(0, 120) || anchor.targetId || "Workspace target member" + })); + const reservedSelectors = new Set([ + ...boundSelectors, + ...referencedBindings.map((binding) => binding.selector).filter((value): value is string => Boolean(value)) + ]); + const capturedBindings = sanitizeSemanticBlockCapturedEntities(override.capturedEntities) + .filter((entity) => !reservedSelectors.has(entity.selector)) + .map((entity) => ({ + contextOnly: true, + kind: entity.kind, + permission: "read_only" as const, + sectionId: null, + selector: entity.selector, + selectorIndex: entity.selectorIndex, + source: "captured_dom" as const, + sourcePageId: entity.pageId ?? null, + sourceScopeId: entity.scopeId ?? null, + targetId: null, + textSnapshot: entity.text ?? "", + title: entity.text?.slice(0, 120) || "DOM text member" + })); + + return [...boundBindings, ...referencedBindings, ...capturedBindings]; +} + function buildSemanticBlockIdentity(override: WorkspaceSemanticBlockOverride, sourceBindings: unknown[]) { const bindingRecords = sourceBindings.filter( (binding): binding is Record => Boolean(binding) && typeof binding === "object" && !Array.isArray(binding) @@ -9829,11 +10165,27 @@ function buildSemanticBlockIdentity(override: WorkspaceSemanticBlockOverride, so const anchorKeys = sanitizeSemanticBlockAnchors(override.anchors).map((anchor) => [anchor.source, anchor.targetId ?? "", anchor.selector ?? "", anchor.selectorIndex ?? 0, anchor.scopeId ?? "", anchor.pageId ?? ""].join("::") ); + const sectionRefs = bindingRecords + .map((binding) => binding.sectionId) + .filter((value): value is string => typeof value === "string" && value.length > 0) + .map((sourceSectionId) => ({ entityId: "seo.section", sourceSectionId })); + const memberPageIds = Array.from(new Set([ + ...sanitizeSemanticBlockAnchors(override.anchors).map((anchor) => anchor.pageId), + ...sanitizeSemanticBlockCapturedEntities(override.capturedEntities).map((entity) => entity.pageId), + ...bindingRecords.map((binding) => binding.sourcePageId) + ].filter((value): value is string => typeof value === "string" && value.length > 0))); return { + ...(sanitizeSemanticBlockIdentity(override.identity) ?? {}), anchorKeys, blockKey: override.id, contentFieldPaths: Array.from(new Set(contentFieldPaths)), + editorModel: SEMANTIC_BLOCK_EDITOR_MODEL, + membershipAuthority: "explicit_members", + ontologyEntityId: "seo.semantic_block", + ontologyRelationId: "seo.semantic_block.groups_section", + memberPageIds, + sectionRefs, selectors: Array.from(new Set(selectors)), targetIds: Array.from(new Set(override.targetIds)), title: override.title @@ -10106,6 +10458,10 @@ function isSafeVisualComposerFallbackSection(section: WorkspaceSection) { return false; } + if (["
", "