Generalize semantic block editing across page workspaces
This commit is contained in:
parent
5be9002f6e
commit
089bc2406f
File diff suppressed because it is too large
Load Diff
|
|
@ -3754,6 +3754,11 @@ export type SemanticBlockModel = {
|
|||
};
|
||||
|
||||
export type SemanticBlockSaveInput = {
|
||||
replaceWorkspaces?: Array<{
|
||||
pageId: string;
|
||||
scopeId: string;
|
||||
workspaceKey: string;
|
||||
}>;
|
||||
scanVersionId?: string | null;
|
||||
workspaceKey: string;
|
||||
scopeId: string;
|
||||
|
|
|
|||
|
|
@ -17439,20 +17439,22 @@ body:has(.seo-launcher-shell) {
|
|||
}
|
||||
|
||||
.seo-launcher-shell .workspace.stage-7 .workspace-preview-frame.mobile-preview {
|
||||
display: block !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: minmax(0, 390px) !important;
|
||||
justify-content: center !important;
|
||||
overflow: auto !important;
|
||||
padding: 0 !important;
|
||||
background: #ffffff !important;
|
||||
padding: 0.85rem !important;
|
||||
background: #f2f3f2 !important;
|
||||
}
|
||||
|
||||
.seo-launcher-shell .workspace.stage-7 .workspace-preview-frame.mobile-preview iframe {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
min-width: 320px !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid rgba(24, 32, 29, 0.12) !important;
|
||||
border-radius: 20px !important;
|
||||
box-shadow: 0 16px 42px rgba(24, 32, 29, 0.12) !important;
|
||||
}
|
||||
|
||||
.visual-composer-shell {
|
||||
|
|
@ -17583,22 +17585,22 @@ body:has(.seo-launcher-shell) {
|
|||
|
||||
@keyframes semantic-generation-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(232, 45, 134, 0.36);
|
||||
box-shadow: 0 0 0 0 rgba(90, 127, 43, 0.34);
|
||||
}
|
||||
|
||||
70% {
|
||||
box-shadow: 0 0 0 9px rgba(232, 45, 134, 0);
|
||||
box-shadow: 0 0 0 9px rgba(90, 127, 43, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(232, 45, 134, 0);
|
||||
box-shadow: 0 0 0 0 rgba(90, 127, 43, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.tiny-action.needs-regeneration {
|
||||
color: #ffffff;
|
||||
background: #e82d86;
|
||||
border-color: #d51f76;
|
||||
background: #5a7f2b;
|
||||
border-color: #4d7023;
|
||||
animation: semantic-generation-pulse 1.35s ease-out infinite;
|
||||
}
|
||||
|
||||
|
|
@ -17645,6 +17647,91 @@ body:has(.seo-launcher-shell) {
|
|||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.workspace-drawer-page-browser {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-scope-search {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(24, 32, 29, 0.1);
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.workspace-scope-search:focus-within {
|
||||
color: var(--ink);
|
||||
border-color: rgba(24, 32, 29, 0.24);
|
||||
box-shadow: 0 0 0 3px rgba(24, 32, 29, 0.06);
|
||||
}
|
||||
|
||||
.workspace-scope-search input {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.workspace-scope-search span,
|
||||
.workspace-scope-group-head span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.workspace-drawer-page-groups {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 2px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.workspace-scope-group {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-scope-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.workspace-scope-group-head strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 790;
|
||||
}
|
||||
|
||||
.workspace-drawer-page-browser .workspace-drawer-page-list {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.semantic-block-edit-controls .icon-action {
|
||||
display: inline-grid;
|
||||
width: 38px;
|
||||
|
|
@ -17670,14 +17757,29 @@ body:has(.seo-launcher-shell) {
|
|||
}
|
||||
|
||||
.semantic-block-edit-controls .semantic-block-toggle {
|
||||
color: #e82d86;
|
||||
border-color: #e82d86;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 32px;
|
||||
align-items: center;
|
||||
gap: 0.34rem;
|
||||
padding: 0 0.62rem;
|
||||
color: #5a7f2b;
|
||||
border-color: #5a7f2b;
|
||||
}
|
||||
|
||||
.semantic-block-edit-controls .semantic-block-toggle span {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.stage-7 .semantic-block-edit-controls .semantic-block-toggle {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.semantic-block-edit-controls .semantic-block-toggle.active {
|
||||
color: #ffffff;
|
||||
background: #e82d86;
|
||||
border-color: #e82d86;
|
||||
background: #5a7f2b;
|
||||
border-color: #5a7f2b;
|
||||
}
|
||||
|
||||
.semantic-block-edit-controls .semantic-block-apply {
|
||||
|
|
@ -17686,8 +17788,8 @@ body:has(.seo-launcher-shell) {
|
|||
}
|
||||
|
||||
.semantic-block-edit-controls .semantic-block-add {
|
||||
color: #e82d86;
|
||||
border-color: #e82d86;
|
||||
color: #5a7f2b;
|
||||
border-color: #5a7f2b;
|
||||
}
|
||||
|
||||
.semantic-block-edit-controls .semantic-block-delete {
|
||||
|
|
@ -17700,6 +17802,289 @@ body:has(.seo-launcher-shell) {
|
|||
border-color: #5a7f2b;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-drawer {
|
||||
flex-basis: min(420px, calc(100% - 24px));
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-drawer {
|
||||
flex-basis: min(430px, calc(100% - 24px));
|
||||
max-width: 430px;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.72rem;
|
||||
padding: 0.78rem;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-title {
|
||||
display: grid;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-title span,
|
||||
.workspace-semantic-group-members > span,
|
||||
.workspace-semantic-group-functions > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-title input {
|
||||
min-height: 38px;
|
||||
padding: 0 0.66rem;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
border: 1px solid rgba(24, 32, 29, 0.14);
|
||||
border-radius: 10px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-guide {
|
||||
margin: 0;
|
||||
padding: 0.72rem;
|
||||
color: #40532a;
|
||||
background: rgba(90, 127, 43, 0.09);
|
||||
border: 1px solid rgba(90, 127, 43, 0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-stats div {
|
||||
min-width: 0;
|
||||
padding: 0.58rem;
|
||||
background: #f7f8f7;
|
||||
border: 1px solid rgba(24, 32, 29, 0.09);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-stats dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-stats dd {
|
||||
margin: 0.16rem 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 1rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-members,
|
||||
.workspace-semantic-group-functions {
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-members > div {
|
||||
display: grid;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-members button {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
padding: 0.56rem 0.62rem;
|
||||
text-align: left;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(24, 32, 29, 0.1);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-members button small {
|
||||
color: var(--muted);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-members button strong {
|
||||
overflow: hidden;
|
||||
color: var(--ink);
|
||||
font-size: 0.76rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-semantic-group-functions > small {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.workspace-text-group-action {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.72rem;
|
||||
padding: 0.78rem;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-status span {
|
||||
padding: 0.28rem 0.5rem;
|
||||
color: #52605a;
|
||||
background: #f1f3f2;
|
||||
border: 1px solid rgba(24, 32, 29, 0.1);
|
||||
border-radius: 999px;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-status span.ok {
|
||||
color: #355718;
|
||||
background: #eef5e8;
|
||||
border-color: rgba(90, 127, 43, 0.24);
|
||||
}
|
||||
|
||||
.workspace-text-inspector-status span.warning {
|
||||
color: #805c13;
|
||||
background: #fff8e8;
|
||||
border-color: rgba(166, 119, 26, 0.22);
|
||||
}
|
||||
|
||||
.workspace-text-inspector-copy,
|
||||
.workspace-text-inspector-keywords,
|
||||
.workspace-text-inspector-unbound,
|
||||
.workspace-text-inspector-technical {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
padding: 0.7rem;
|
||||
background: #f7f8f7;
|
||||
border: 1px solid rgba(24, 32, 29, 0.09);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-copy > span,
|
||||
.workspace-text-inspector-keywords > span,
|
||||
.workspace-text-inspector-editor > span {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-copy p,
|
||||
.workspace-text-inspector-unbound p {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.42rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-meta div {
|
||||
min-width: 0;
|
||||
padding: 0.58rem;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(24, 32, 29, 0.09);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-meta dt {
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-meta dd {
|
||||
overflow: hidden;
|
||||
margin: 0.18rem 0 0;
|
||||
color: var(--ink);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 760;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-technical summary {
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-technical code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #52605a;
|
||||
font-size: 0.66rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-technical small {
|
||||
color: var(--muted);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-keywords > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-keywords small {
|
||||
padding: 0.3rem 0.48rem;
|
||||
color: #31495a;
|
||||
background: #edf3f6;
|
||||
border-radius: 999px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-editor {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-editor textarea {
|
||||
min-height: 128px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-actions {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-actions .tiny-action {
|
||||
flex: 1 1 calc(50% - 0.3rem);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workspace-text-inspector-unbound strong {
|
||||
color: #805c13;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.visual-page-settings {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -43,7 +43,10 @@ export type SemanticBlockAnchorLike = {
|
|||
};
|
||||
|
||||
export function normalizeSemanticBlockIdFragment(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9а-яА-ЯёЁ._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function isCarouselLikeSemanticValue(values: Array<string | null | undefined>) {
|
||||
|
|
@ -58,7 +61,6 @@ export function getCarouselManualBoxBase(box: Pick<SemanticBlockBoxLike, "id" |
|
|||
const visualIdMatch = /^(.*):visual-\d+$/i.exec(box.id || "");
|
||||
const numberedTitleMatch = /^(.*?)(?:\s+|[-_#])\d+$/i.exec(box.title || "");
|
||||
const baseTitle = (numberedTitleMatch?.[1] || box.title || "").trim();
|
||||
const hasGeometry = Boolean("geometry" in box && box.geometry);
|
||||
const searchableValue = [
|
||||
visualIdMatch?.[1],
|
||||
baseTitle,
|
||||
|
|
@ -69,16 +71,14 @@ export function getCarouselManualBoxBase(box: Pick<SemanticBlockBoxLike, "id" |
|
|||
.join(" ")
|
||||
.toLocaleLowerCase("ru-RU");
|
||||
|
||||
if (!CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!visualIdMatch && numberedTitleMatch && !hasGeometry) {
|
||||
// Geometry and numbered labels are presentation hints only. A stable
|
||||
// :visual-N identity is the sole authority for collapsing projections.
|
||||
if (!visualIdMatch || !CAROUSEL_LIKE_SEMANTIC_PATTERN.test(searchableValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: visualIdMatch?.[1] || `manual-carousel:${normalizeSemanticBlockIdFragment(baseTitle).toLocaleLowerCase("ru-RU")}`,
|
||||
id: visualIdMatch[1],
|
||||
title: baseTitle || box.title || "Слайдер"
|
||||
};
|
||||
}
|
||||
|
|
@ -136,8 +136,7 @@ export function collapseCarouselManualSemanticBlockBoxes<T extends SemanticBlock
|
|||
continue;
|
||||
}
|
||||
|
||||
const band = Math.round(box.top / Math.max(220, box.height || 220));
|
||||
const key = base.id.startsWith("manual-carousel:") ? `${base.id}:band-${band}` : base.id;
|
||||
const key = base.id;
|
||||
const existing = mergedByKey.get(key);
|
||||
|
||||
if (!existing) {
|
||||
|
|
|
|||
|
|
@ -299,6 +299,11 @@ const updateContentFieldDraftSchema = z.object({
|
|||
const semanticBlockSourceSchema = z.enum(["auto", "manual", "source_object", "visual_section", "workspace_section"]);
|
||||
const semanticBlockJsonRecordSchema = z.record(z.string(), z.unknown());
|
||||
const semanticBlockSaveSchema = z.object({
|
||||
replaceWorkspaces: z.array(z.object({
|
||||
pageId: projectIdSchema,
|
||||
scopeId: z.string().trim().min(1).max(240),
|
||||
workspaceKey: z.string().trim().min(1).max(240)
|
||||
})).max(120).optional(),
|
||||
scanVersionId: projectIdSchema.nullable().optional(),
|
||||
workspaceKey: z.string().trim().min(1, "Нужен workspaceKey semantic block.").max(240),
|
||||
scopeId: z.string().trim().min(1, "Нужен scopeId semantic block.").max(240),
|
||||
|
|
|
|||
|
|
@ -349,6 +349,25 @@ function getSemanticBlockSourceBindings(block: SemanticBlockModel) {
|
|||
return block.sourceBindings.map(asRecord).filter((binding): binding is Record<string, unknown> => Boolean(binding));
|
||||
}
|
||||
|
||||
function getSemanticBlockReferencedPageIds(block: SemanticBlockModel) {
|
||||
const pageIds = new Set<string>([block.pageId]);
|
||||
const identityPageIds = Array.isArray(block.identity.memberPageIds) ? block.identity.memberPageIds : [];
|
||||
|
||||
for (const value of identityPageIds) {
|
||||
if (typeof value === "string" && value) pageIds.add(value);
|
||||
}
|
||||
|
||||
for (const anchor of block.anchors.map(asRecord).filter((value): value is Record<string, unknown> => Boolean(value))) {
|
||||
if (typeof anchor.pageId === "string" && anchor.pageId) pageIds.add(anchor.pageId);
|
||||
}
|
||||
|
||||
for (const binding of getSemanticBlockSourceBindings(block)) {
|
||||
if (typeof binding.sourcePageId === "string" && binding.sourcePageId) pageIds.add(binding.sourcePageId);
|
||||
}
|
||||
|
||||
return Array.from(pageIds);
|
||||
}
|
||||
|
||||
function mapRewriteDiffSemanticBlock(block: SemanticBlockModel): RewriteDiffSemanticBlock {
|
||||
return {
|
||||
anchors: block.anchors,
|
||||
|
|
@ -722,7 +741,9 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
|
|||
]);
|
||||
const snapshotMap = await getWorkspaceSnapshotMap(draftMap);
|
||||
const semanticBlocksByPageId = semanticBlocks.reduce<Map<string, SemanticBlockModel[]>>((accumulator, block) => {
|
||||
accumulator.set(block.pageId, [...(accumulator.get(block.pageId) ?? []), block]);
|
||||
getSemanticBlockReferencedPageIds(block).forEach((pageId) => {
|
||||
accumulator.set(pageId, [...(accumulator.get(pageId) ?? []), block]);
|
||||
});
|
||||
return accumulator;
|
||||
}, new Map());
|
||||
const targets = rewritePlan.existingPageTargets
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@ import {
|
|||
collapseCarouselManualSemanticBlockBoxes,
|
||||
getCarouselManualBoxBase,
|
||||
isCarouselLikeSemanticValue,
|
||||
normalizeSemanticBlockIdFragment,
|
||||
type SemanticBlockBoxLike
|
||||
} from "../preview/semanticBlockRules.js";
|
||||
import {
|
||||
annotatePreviewTargets,
|
||||
getPreviewAtomicTextUnits,
|
||||
getPreviewTargetSeeds
|
||||
} from "../workspace/previewTargets.js";
|
||||
|
||||
function box(input: Partial<SemanticBlockBoxLike> & Pick<SemanticBlockBoxLike, "id" | "title">): SemanticBlockBoxLike {
|
||||
return {
|
||||
|
|
@ -33,6 +39,79 @@ assert.equal(
|
|||
"Plain card lists must not become one carousel entity only because they are indexed."
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
normalizeSemanticBlockIdFragment("قسم / 产品 / блок"),
|
||||
"قسم-产品-блок",
|
||||
"Semantic ids must retain Unicode letters instead of collapsing non-Latin projects."
|
||||
);
|
||||
|
||||
const genericCmsFixture = `<!doctype html>
|
||||
<html><body>
|
||||
<header><p>Shared product chrome</p></header>
|
||||
<main>
|
||||
<h1>Independent product guide</h1>
|
||||
<p>First editable paragraph from an ordinary CMS theme.</p>
|
||||
<h2>Configuration</h2>
|
||||
<p>Second editable paragraph with <strong>nested inline markup</strong>.</p>
|
||||
</main>
|
||||
<aside><p>Related navigation chrome</p></aside>
|
||||
</body></html>`;
|
||||
const genericCmsSeeds = getPreviewTargetSeeds(genericCmsFixture);
|
||||
const genericTextSeeds = genericCmsSeeds.filter((seed) => seed.kind === "text_unit");
|
||||
const genericHeadingSeeds = genericCmsSeeds.filter((seed) => seed.kind === "heading_group");
|
||||
const annotatedGenericCms = annotatePreviewTargets(genericCmsFixture, genericCmsSeeds);
|
||||
|
||||
assert.equal(genericHeadingSeeds.length, 2, "Generic heading units must be discovered without project vocabulary.");
|
||||
assert.equal(genericTextSeeds.length, 4, "CMS paragraphs in content and chrome must receive stable occurrence locators.");
|
||||
assert.ok(
|
||||
genericHeadingSeeds.every((seed) => annotatedGenericCms.includes(`data-seo-target-id="${seed.id}"`)) &&
|
||||
genericTextSeeds.every((seed) => annotatedGenericCms.includes(`data-seo-target-id="${seed.id}"`)),
|
||||
"Preview annotation must materialize every generic heading/text-unit locator."
|
||||
);
|
||||
|
||||
const nestedListFixture = `<body>
|
||||
<ul>
|
||||
<li>Outer item text<ul><li>Inner item text</li></ul></li>
|
||||
<li>Sibling item text</li>
|
||||
</ul>
|
||||
</body>`;
|
||||
const nestedListSeeds = getPreviewTargetSeeds(nestedListFixture).filter((seed) => seed.kind === "text_unit");
|
||||
const annotatedNestedList = annotatePreviewTargets(nestedListFixture, nestedListSeeds);
|
||||
|
||||
assert.deepEqual(
|
||||
getPreviewAtomicTextUnits(nestedListFixture).map((unit) => unit.targetIndex),
|
||||
[1, 2, 3],
|
||||
"Nested list items must retain distinct opening-occurrence identities."
|
||||
);
|
||||
assert.ok(
|
||||
nestedListSeeds.every((seed) => annotatedNestedList.includes(`data-seo-target-id="${seed.id}"`)),
|
||||
"Nested outer, inner, and sibling text units must all be annotated."
|
||||
);
|
||||
|
||||
const rawScriptFixture = `<body>
|
||||
<script>const template = \`<p>Template-only paragraph</p>\`;</script>
|
||||
<p>Actual editable paragraph</p>
|
||||
</body>`;
|
||||
const rawScriptSeeds = getPreviewTargetSeeds(rawScriptFixture).filter((seed) => seed.kind === "text_unit");
|
||||
const annotatedRawScript = annotatePreviewTargets(rawScriptFixture, rawScriptSeeds);
|
||||
|
||||
assert.equal(rawScriptSeeds.length, 1, "Markup-looking script text must not consume atomic target indices.");
|
||||
assert.ok(
|
||||
annotatedRawScript.includes("`<p>Template-only paragraph</p>`") &&
|
||||
annotatedRawScript.includes('<p data-seo-target-id="text-unit-1">Actual editable paragraph</p>'),
|
||||
"Preview annotation must preserve raw script bytes and annotate only rendered body text."
|
||||
);
|
||||
|
||||
const reservedAttributeFixture = `<body><p data-seo-target-id="customer-value">Actual editable paragraph</p></body>`;
|
||||
const reservedAttributeSeeds = getPreviewTargetSeeds(reservedAttributeFixture);
|
||||
const annotatedReservedAttribute = annotatePreviewTargets(reservedAttributeFixture, reservedAttributeSeeds);
|
||||
|
||||
assert.ok(
|
||||
annotatedReservedAttribute.includes('data-seo-target-id="text-unit-1"') &&
|
||||
!annotatedReservedAttribute.includes('data-seo-target-id="customer-value"'),
|
||||
"A pre-existing reserved annotation must be rebound to the selector emitted by the workspace seed."
|
||||
);
|
||||
|
||||
assert.deepEqual(getCarouselManualBoxBase(box({ id: "semantic-block:gallery:visual-2", title: "Gallery 2" })), {
|
||||
id: "semantic-block:gallery",
|
||||
title: "Gallery"
|
||||
|
|
@ -80,9 +159,12 @@ const collapsedTitleOnlyBoxes = collapseCarouselManualSemanticBlockBoxes([
|
|||
box({ geometry: { height: 240, left: 600, top: 0, width: 320 }, id: "manual-3", title: "Слайдер 3", left: 600, targetIds: ["slide-3"] })
|
||||
]);
|
||||
|
||||
assert.equal(collapsedTitleOnlyBoxes.length, 1, "Legacy title-only slider boxes must collapse after reload.");
|
||||
assert.equal(collapsedTitleOnlyBoxes[0]?.id, "manual-carousel:слайдер");
|
||||
assert.equal(collapsedTitleOnlyBoxes[0]?.title, "Слайдер");
|
||||
assert.equal(
|
||||
collapsedTitleOnlyBoxes.length,
|
||||
3,
|
||||
"Numbered titles and nearby geometry must not invent semantic membership after reload."
|
||||
);
|
||||
assert.deepEqual(collapsedTitleOnlyBoxes.map((item) => item.id), ["manual-1", "manual-2", "manual-3"]);
|
||||
|
||||
const separateRows = collapseCarouselManualSemanticBlockBoxes([
|
||||
box({ geometry: { height: 240, left: 0, top: 0, width: 320 }, id: "row-a", title: "Слайдер 1", top: 0, targetIds: ["a"] }),
|
||||
|
|
@ -107,10 +189,15 @@ assert.equal(plainBlocks.length, 2, "Plain numbered semantic blocks must stay se
|
|||
|
||||
const previewBridgeSource = await readFile(new URL("../preview/routes.ts", import.meta.url), "utf8");
|
||||
const semanticBlockRepositorySource = await readFile(new URL("../workspace/semanticBlocks.ts", import.meta.url), "utf8");
|
||||
const pageWorkspaceSource = await readFile(new URL("../workspace/pageWorkspace.ts", import.meta.url), "utf8");
|
||||
const semanticBlockMigrationSource = await readFile(new URL("../../migrations/010_semantic_blocks.sql", import.meta.url), "utf8");
|
||||
const rewriteDiffSource = await readFile(new URL("../rewrite/rewriteDiff.ts", import.meta.url), "utf8");
|
||||
const visualComposerSource = await readFile(new URL("../rewrite/visualComposerVariants.ts", import.meta.url), "utf8");
|
||||
const appSource = await readFile(new URL("../../../app/src/App.tsx", import.meta.url), "utf8");
|
||||
const previewBridgeBodySource = previewBridgeSource.slice(
|
||||
previewBridgeSource.indexOf("function buildPreviewBridgeScript()"),
|
||||
previewBridgeSource.indexOf("function buildPreviewFocusScript(")
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
!/const\s+SEMANTIC_BLOCK_EDITOR_ENABLED\s*=\s*false/.test(appSource),
|
||||
|
|
@ -128,6 +215,171 @@ assert.ok(
|
|||
previewBridgeSource.includes("function isSafePreviewPatchElement"),
|
||||
"Preview bridge must keep a safe-patch gate before mutating iframe DOM."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("function getSemanticBlockMemberEntries") &&
|
||||
previewBridgeSource.includes("function createAdaptiveSemanticBlockVisuals"),
|
||||
"Stage 07 must render one semantic group as adaptive member outlines instead of one persisted viewport rectangle."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("row and geometry are diagnostics and never define membership"),
|
||||
"Responsive geometry must remain a diagnostic cache, never a semantic merge key."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("toggleTextUnitInSelectedSemanticBlock") &&
|
||||
previewBridgeSource.includes("removeTextUnitFromSemanticBlock") &&
|
||||
previewBridgeSource.includes("Commit explicit membership only"),
|
||||
"Semantic membership must change only through explicit text-unit interaction, never rectangle intersection."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("Automatic layout suggestions stay suggestions") &&
|
||||
!/function renderSemanticBlockBoxes[\s\S]{0,500}seedSemanticBlockEditBoxes\(/.test(previewBridgeSource),
|
||||
"Opening the editor must not promote automatic suggestions into manual groups."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("One ID · several adaptive outlines") ||
|
||||
(previewBridgeSource.includes("seo-mode-semantic-member") && appSource.includes("Один ID · несколько адаптивных контуров")),
|
||||
"Disjoint semantic members must stay one group without a giant union frame."
|
||||
);
|
||||
assert.ok(
|
||||
!previewBridgeSource.includes("state.semanticBlockEditMode && state.hasManualSemanticBlockSet) {\n return;"),
|
||||
"Semantic block editing must continue measuring during viewport and layout changes."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("ResizeObserver(scheduleDynamicLayoutMeasure)") &&
|
||||
previewBridgeSource.includes('document.fonts.addEventListener?.("loadingdone"') &&
|
||||
previewBridgeSource.includes('window.visualViewport?.addEventListener("resize"'),
|
||||
"Preview projection must observe responsive layout, late fonts, and visual viewport changes."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes('type: "seo-mode:text-unit-select"') &&
|
||||
previewBridgeSource.includes("function getInspectableTextElementAtPoint") &&
|
||||
previewBridgeSource.includes("function getTextNodeAtPoint") &&
|
||||
previewBridgeSource.includes("function handleTextInspectorPointerDown") &&
|
||||
previewBridgeSource.includes("function getPresentedTextVisualElement") &&
|
||||
!previewBridgeSource.includes('document.addEventListener("click", handleTextInspectorClick') &&
|
||||
appSource.includes("Текстовый фрагмент") &&
|
||||
appSource.includes("workspaceTextInspectorSelections"),
|
||||
"Stage 07 must select split-animation and ordinary text exactly once without changing page layout."
|
||||
);
|
||||
assert.equal(
|
||||
previewBridgeBodySource.match(/(^|[^\\])\\[bBdDsSwW]/g),
|
||||
null,
|
||||
"Regex character-class escapes inside the injected preview script must be doubled; otherwise they become literal letters."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("markerVisible: activeStageIndex !== 6") &&
|
||||
appSource.includes("!isVisualComposerMode ? <div className=\"workspace-preview-markers\">"),
|
||||
"Legacy numbered rewrite markers must stay out of the stage 07 visual editor."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("const planReady = strategyReady || Boolean(input.rewritePlan)") &&
|
||||
appSource.includes("unlocked: planReady") &&
|
||||
appSource.includes("fetchLatestRewritePlan(projectId)") &&
|
||||
appSource.includes("загружаю доступ к этапам..."),
|
||||
"Persisted rewrite plans must unlock only stage 07 during deterministic project hydration without waiting for strategy recomputation."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("function isTargetTextConsistent") &&
|
||||
previewBridgeSource.includes("if (targetElement === element) return { target, score: 0 }") &&
|
||||
previewBridgeSource.includes("if (element.contains(targetElement)) return { target, score: 1 }") &&
|
||||
previewBridgeSource.includes("selectorIndex: getElementSelectorIndex(element, selector)"),
|
||||
"Text inspector source writes must preserve text-consistent logical-unit binding and duplicate-selector identity."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("workspaceTextInspectorModes[projectId]") &&
|
||||
appSource.includes("workspaceTextInspectorSelections[project.id]"),
|
||||
"Inspector mode and selection must remain page-session stable while the active semantic scope changes."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("function getSemanticBlockOwnerWorkspaceKey") &&
|
||||
appSource.includes("getWorkspaceRenderedPageKey(item, scopeItems, scanSummary) === renderedPageKey") &&
|
||||
appSource.includes("mergeSemanticBlockOverrideMembership") &&
|
||||
appSource.includes("selectedSemanticBlockIds[activeSemanticBlockEditorSessionKey]"),
|
||||
"One rendered page must have one canonical group projection across source workspaces and scope changes."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes('return `page:${scopeItem.pageId}`') &&
|
||||
appSource.includes("function getWorkspaceSectionHostPage") &&
|
||||
appSource.includes("logicalHostPages.length === 1") &&
|
||||
appSource.includes("section.selected || Boolean(getWorkspaceSectionHostPage(scan, section))") &&
|
||||
appSource.includes("getWorkspaceSectionHostPage(scan, scopeItem)") &&
|
||||
appSource.includes('return `scope:${scopeItem.scopeId}`') &&
|
||||
!/function normalizeWorkspacePreviewSourceKey[\s\S]{0,220}toLocaleLowerCase/.test(appSource),
|
||||
"Rendered-page identity must isolate shared-index SPA routes, reuse one unambiguous section host, and preserve case-sensitive source paths."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("function getSemanticBlockResolvedTargets") &&
|
||||
appSource.includes("identityAnchors.length === 0 && candidates.length === 1") &&
|
||||
appSource.includes("targetsBySourceIdentity") &&
|
||||
appSource.includes('const sourceIdentity = `${sourceWorkspaceKey}\\u0000${target.id}`'),
|
||||
"Repeated atomic target ids must retain workspace ownership and bind only through explicit scope/page identity or a unique legacy fallback."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("projectWorkspaceGenerationsRef") &&
|
||||
appSource.includes("function invalidateProjectWorkspaceCaches") &&
|
||||
appSource.includes("invalidateProjectWorkspaceCaches(project.id)") &&
|
||||
appSource.includes("getProjectWorkspaceGeneration(project.id) !== workspaceGeneration") &&
|
||||
appSource.includes("prefetchingWorkspaceKeysRef.current.delete(prefetchKey)") &&
|
||||
appSource.includes("if (cachedWorkspace)") &&
|
||||
appSource.includes("Reopening an already hydrated scope is navigation, not a reload"),
|
||||
"A successful rescan must invalidate Stage 3/7 caches, reject stale in-flight results, and preserve local text on cache-first navigation."
|
||||
);
|
||||
assert.ok(
|
||||
!previewBridgeSource.includes(".c-viz") &&
|
||||
!previewBridgeSource.includes("getScopedAnchorFallbackElement") &&
|
||||
!appSource.includes("getWorkspaceScopedAnchorFallbackSelector"),
|
||||
"Preview scope resolution must not depend on NODE.DC classes or synthesized section-anchor ids."
|
||||
);
|
||||
assert.ok(
|
||||
pageWorkspaceSource.includes('seed.kind === "text_unit"') &&
|
||||
pageWorkspaceSource.includes('"atomic_dom_text_units"') &&
|
||||
pageWorkspaceSource.includes("function getPrimaryContentRange") &&
|
||||
pageWorkspaceSource.includes("getPreviewAtomicTextUnits(html)") &&
|
||||
pageWorkspaceSource.includes("Atomic units are identities, not text values") &&
|
||||
!appSource.includes("rewriteTargets.length === 0 && contentFieldTargets.length === 0"),
|
||||
"Oversized primary content must split into generic atomic text units without hiding remaining text when one plan slot exists."
|
||||
);
|
||||
assert.ok(
|
||||
pageWorkspaceSource.includes("item.working_text == null || item.working_text === snapshot.originalText") &&
|
||||
pageWorkspaceSource.includes("!draftIsUnchanged && (!belongsToCurrentScan || !isCurrentSnapshot)") &&
|
||||
pageWorkspaceSource.includes("anchor-aware conflict/rebase flow"),
|
||||
"Changed and intentionally empty drafts must never be positionally rebased onto an atomic snapshot topology."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("sources.length === 1") &&
|
||||
previewBridgeSource.includes("presentations.length === 1") &&
|
||||
previewBridgeSource.includes("!semanticSourceElement && isSafePreviewPatchElement(patchElement)") &&
|
||||
previewBridgeSource.includes("function hasPresentedTextCompanion") &&
|
||||
previewBridgeSource.includes("Number(anchor.selectorIndex || 0) === selectorIndex"),
|
||||
"Split-text adapters must reject ambiguous responsive/accessibility clones and retain selector occurrence identity."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("readElementText(element) === record.lastAppliedText") &&
|
||||
previewBridgeSource.includes("if (applied) restoreAppliedSection(sectionId, applied)") &&
|
||||
previewBridgeSource.includes("owners?.size !== 1") &&
|
||||
previewBridgeSource.includes("target.preferPageStart") &&
|
||||
!/function getTargetElement\(target, options\) \{[\s\S]{0,260}appliedSectionTexts\.get/.test(previewBridgeSource),
|
||||
"Preview draft patches must restore replaced elements, reject competing bindings, and never overwrite runtime-owned text."
|
||||
);
|
||||
assert.ok(
|
||||
semanticBlockRepositorySource.includes("replaceWorkspaces") &&
|
||||
semanticBlockRepositorySource.includes("for (const entry of replaceWorkspaces)") &&
|
||||
appSource.includes("replaceWorkspaces: canonicalWorkspaceKeys.flatMap"),
|
||||
"Canonical page saves must archive legacy workspace copies in the same backend transaction."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("bridgeSessionToken") &&
|
||||
previewBridgeSource.includes("isTrustedParentMessage") &&
|
||||
previewBridgeSource.includes("bridgeScriptElement?.remove()") &&
|
||||
previewBridgeSource.includes("${headTag}${baseTag}${stabilityStyle}${bridgeScript}") &&
|
||||
appSource.includes("previewBridgeTokensRef"),
|
||||
"Imported page scripts must not be able to forge authenticated Stage 07 commits."
|
||||
);
|
||||
assert.ok(
|
||||
previewBridgeSource.includes("const capturedEntities = textUnit.targetId") &&
|
||||
previewBridgeSource.includes("state.semanticBlockEditMode || state.textInspectorMode ? 72 : 180"),
|
||||
"One bound text unit must render once, and adaptive outlines must follow nested scrolling."
|
||||
);
|
||||
assert.ok(
|
||||
/const\s+blockedTags\s*=\s*new\s+Set\([\s\S]*"section"[\s\S]*"article"[\s\S]*"form"/.test(previewBridgeSource),
|
||||
"Preview bridge must block text writes into layout containers."
|
||||
|
|
@ -147,21 +399,21 @@ assert.ok(
|
|||
assert.ok(
|
||||
semanticBlockRepositorySource.includes("hasSemanticBlockRuntimeBinding") &&
|
||||
semanticBlockRepositorySource.includes("sourceBindings.length > 0"),
|
||||
"Semantic block repository must not persist captured-only visual boxes without text target/source bindings."
|
||||
"Semantic block repository must retain context-only DOM members through typed source bindings."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("hydratePersistedWorkspaceSemanticBlocks") && appSource.includes("savePageSemanticBlocks"),
|
||||
"Stage 07 semantic block overrides must reload from and save to backend."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("skippedSemanticBlockCount") &&
|
||||
appSource.includes("sourceBindings.length === 0") &&
|
||||
appSource.includes("без текстовой привязки"),
|
||||
"Stage 07 must skip semantic block saves that have no runtime text source binding."
|
||||
appSource.includes('source: "captured_dom" as const') &&
|
||||
appSource.includes("contextOnly: true") &&
|
||||
appSource.includes('ontologyEntityId: "seo.semantic_block"'),
|
||||
"Unbound text must remain groupable and persist as context-only membership with ontology identity."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("anchorTargetIds") && /return blockKey && targetIds\.length > 0/.test(appSource),
|
||||
"Persisted semantic block hydration must require target-bound anchors or targetIds."
|
||||
appSource.includes("capturedEntities.length > 0 || anchors.length > 0 || block.sourceBindings.length > 0"),
|
||||
"Persisted semantic block hydration must retain captured/context-only members without a writable target."
|
||||
);
|
||||
assert.ok(
|
||||
rewriteDiffSource.includes("listSemanticBlocksForPages") &&
|
||||
|
|
@ -170,7 +422,8 @@ assert.ok(
|
|||
rewriteDiffSource.includes("buildSemanticRewriteGroups") &&
|
||||
rewriteDiffSource.includes("fallbackReason: slot.semanticBlock ? null : \"no_semantic_block\"") &&
|
||||
rewriteDiffSource.includes("buildSemanticGroupSourceGraph") &&
|
||||
rewriteDiffSource.includes("contentFieldCount"),
|
||||
rewriteDiffSource.includes("contentFieldCount") &&
|
||||
rewriteDiffSource.includes("getSemanticBlockReferencedPageIds"),
|
||||
"Rewrite diff must attach persisted semantic blocks and build semantic rewrite groups with source graph."
|
||||
);
|
||||
assert.ok(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import fs from "node:fs/promises";
|
|||
import path from "node:path";
|
||||
import { pool } from "../db/client.js";
|
||||
import { storageProvider } from "../storage/index.js";
|
||||
import { annotatePreviewTargets, getPreviewTargetSelector, getPreviewTargetSeeds } from "./previewTargets.js";
|
||||
import {
|
||||
annotatePreviewTargets,
|
||||
getPreviewAtomicTextUnits,
|
||||
getPreviewHeadingUnits,
|
||||
getPreviewTargetSelector,
|
||||
getPreviewTargetSeeds
|
||||
} from "./previewTargets.js";
|
||||
|
||||
type PageWorkspaceSourceRow = {
|
||||
project_id: string;
|
||||
|
|
@ -131,9 +137,9 @@ export class PageWorkspaceError extends Error {
|
|||
|
||||
const WORKSPACE_FIELD = "page_text";
|
||||
const MAX_BLOCK_TEXT_LENGTH = 6_000;
|
||||
const MAX_SECTIONS = 80;
|
||||
const MAX_SECTIONS = 240;
|
||||
const MAX_MEDIA_SECTIONS = 80;
|
||||
const WORKSPACE_GROUPING_VERSION_EVIDENCE = "workspace_grouping_v3";
|
||||
const WORKSPACE_GROUPING_VERSION_EVIDENCE = "workspace_grouping_v7_safe_atomic_text";
|
||||
const MAX_SOURCE_ORDER_CLUSTER_SIZE = 12;
|
||||
const MAX_CLUSTER_SECTION_CHARS = 900;
|
||||
|
||||
|
|
@ -404,6 +410,108 @@ function extractHeadingSections(html: string) {
|
|||
return dedupeSections(sections);
|
||||
}
|
||||
|
||||
function getPrimaryContentRange(bodyHtml: string) {
|
||||
for (const tagName of ["main", "article"]) {
|
||||
const match = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "i").exec(bodyHtml);
|
||||
|
||||
if (!match || match.index == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const openingTagEnd = match[0].indexOf(">") + 1;
|
||||
const closingTagStart = match[0].toLocaleLowerCase().lastIndexOf(`</${tagName}>`);
|
||||
|
||||
return {
|
||||
end: match.index + (closingTagStart >= 0 ? closingTagStart : match[0].length),
|
||||
start: match.index + Math.max(0, openingTagEnd)
|
||||
};
|
||||
}
|
||||
|
||||
return { end: bodyHtml.length, start: 0 };
|
||||
}
|
||||
|
||||
function getAtomicTextUnitTitle(tagName: string, text: string, index: number) {
|
||||
const normalizedText = normalizeWhitespace(text);
|
||||
|
||||
if (/^h[1-6]$/.test(tagName)) {
|
||||
return `${tagName.toUpperCase()}: ${normalizedText}`;
|
||||
}
|
||||
|
||||
const summary = normalizedText.length > 92 ? `${normalizedText.slice(0, 89)}...` : normalizedText;
|
||||
return summary || `${tagName.toUpperCase()} ${index}`;
|
||||
}
|
||||
|
||||
function extractAtomicTextSections(html: string) {
|
||||
const bodyHtml = getBodyHtml(html);
|
||||
const primaryRange = getPrimaryContentRange(bodyHtml);
|
||||
const seeds = getPreviewTargetSeeds(html);
|
||||
const headingSeeds = seeds.filter((seed) => seed.kind === "heading_group");
|
||||
const textUnitSeeds = seeds.filter((seed) => seed.kind === "text_unit");
|
||||
const entries: Array<{ section: PageWorkspaceSection; sourceIndex: number }> = [];
|
||||
|
||||
for (const headingUnit of getPreviewHeadingUnits(html)) {
|
||||
const sourceIndex = headingUnit.sourceIndex;
|
||||
|
||||
if (sourceIndex < primaryRange.start || sourceIndex >= primaryRange.end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = htmlToReadableText(headingUnit.contentHtml);
|
||||
const seed = headingSeeds.find((candidate) => candidate.targetIndex === headingUnit.targetIndex);
|
||||
|
||||
if (!seed || text.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
section: makeSection({
|
||||
id: seed.id,
|
||||
kind: "heading_group",
|
||||
title: getAtomicTextUnitTitle(headingUnit.tagName, text, headingUnit.targetIndex),
|
||||
selector: seed.selector,
|
||||
originalText: text,
|
||||
sourceHint: seed.sourceHint
|
||||
}),
|
||||
sourceIndex
|
||||
});
|
||||
}
|
||||
|
||||
for (const textUnit of getPreviewAtomicTextUnits(html)) {
|
||||
const sourceIndex = textUnit.sourceIndex;
|
||||
|
||||
if (sourceIndex < primaryRange.start || sourceIndex >= primaryRange.end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = htmlToReadableText(textUnit.contentHtml);
|
||||
const seed = textUnitSeeds.find((candidate) => candidate.targetIndex === textUnit.targetIndex);
|
||||
|
||||
if (!seed || text.length < 8) {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
section: makeSection({
|
||||
id: seed.id,
|
||||
kind: "content_block",
|
||||
title: getAtomicTextUnitTitle(textUnit.tagName, text, textUnit.targetIndex),
|
||||
selector: seed.selector,
|
||||
originalText: text,
|
||||
sourceHint: seed.sourceHint
|
||||
}),
|
||||
sourceIndex
|
||||
});
|
||||
}
|
||||
|
||||
// Atomic units are identities, not text values. Repeated CTA/list copy is
|
||||
// still a distinct DOM/source unit and must not disappear through text
|
||||
// deduplication. Apply the page cap only after restoring DOM source order.
|
||||
return entries
|
||||
.sort((left, right) => left.sourceIndex - right.sourceIndex)
|
||||
.slice(0, MAX_SECTIONS)
|
||||
.map((entry) => entry.section);
|
||||
}
|
||||
|
||||
function extractMediaSections(html: string) {
|
||||
const bodyHtml = getBodyHtml(html);
|
||||
const mediaPattern = /<(img|video)\b([^>]*)>/gi;
|
||||
|
|
@ -475,7 +583,13 @@ function isSourceOrderClusterCandidate(section: PageWorkspaceSection) {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (section.sourceHint === "<main>" || section.sourceHint === "<header>" || section.sourceHint === "<footer>" || section.sourceHint === "<nav>") {
|
||||
if (
|
||||
section.sourceHint === "<main>" ||
|
||||
section.sourceHint === "<header>" ||
|
||||
section.sourceHint === "<footer>" ||
|
||||
section.sourceHint === "<nav>" ||
|
||||
section.sourceHint === "<aside>"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -518,6 +632,62 @@ function getDefaultSemanticGroup(section: PageWorkspaceSection): PageWorkspaceSe
|
|||
};
|
||||
}
|
||||
|
||||
function attachAtomicHeadingRangeGroups(sections: PageWorkspaceSection[]) {
|
||||
const groups: PageWorkspaceSection[][] = [];
|
||||
let currentGroup: PageWorkspaceSection[] = [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section.kind === "heading_group" && currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
currentGroup = [];
|
||||
}
|
||||
|
||||
currentGroup.push(section);
|
||||
}
|
||||
|
||||
if (currentGroup.length > 0) {
|
||||
groups.push(currentGroup);
|
||||
}
|
||||
|
||||
const semanticGroups = new Map<string, PageWorkspaceSemanticGroup>();
|
||||
|
||||
groups.forEach((group) => {
|
||||
const heading = group[0]?.kind === "heading_group" ? group[0] : null;
|
||||
|
||||
if (!heading) {
|
||||
group.forEach((section) => semanticGroups.set(section.id, getDefaultSemanticGroup(section)));
|
||||
return;
|
||||
}
|
||||
|
||||
group.forEach((section, fieldOrder) => {
|
||||
semanticGroups.set(section.id, {
|
||||
confidence: 0.82,
|
||||
evidence: [
|
||||
WORKSPACE_GROUPING_VERSION_EVIDENCE,
|
||||
"heading_range",
|
||||
"atomic_dom_text_units",
|
||||
"source_order"
|
||||
],
|
||||
fieldCount: group.length,
|
||||
fieldOrder,
|
||||
fieldRole: getWorkspaceSectionRole({
|
||||
id: section.id,
|
||||
kind: section.kind,
|
||||
title: section.title
|
||||
}),
|
||||
id: `semantic-block:heading-range:${heading.id}`,
|
||||
source: "workspace_section",
|
||||
title: heading.title
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
semanticGroup: semanticGroups.get(section.id) ?? getDefaultSemanticGroup(section)
|
||||
}));
|
||||
}
|
||||
|
||||
function getSourceOrderClusterGroup(
|
||||
run: PageWorkspaceSection[],
|
||||
section: PageWorkspaceSection,
|
||||
|
|
@ -600,6 +770,21 @@ function attachSemanticGroups(sections: PageWorkspaceSection[]) {
|
|||
}));
|
||||
}
|
||||
|
||||
function getPageContentSections(html: string) {
|
||||
const semanticSections = extractSemanticSections(html);
|
||||
const hasSafeContentSection = semanticSections.some(isSourceOrderClusterCandidate);
|
||||
|
||||
if (!hasSafeContentSection) {
|
||||
const atomicTextSections = extractAtomicTextSections(html);
|
||||
|
||||
if (atomicTextSections.length > 0) {
|
||||
return attachAtomicHeadingRangeGroups(atomicTextSections);
|
||||
}
|
||||
}
|
||||
|
||||
return semanticSections.length >= 2 ? semanticSections : extractHeadingSections(html);
|
||||
}
|
||||
|
||||
function buildPreviewHtml(html: string) {
|
||||
const previewStyles = `
|
||||
<style>
|
||||
|
|
@ -660,16 +845,14 @@ function buildSections(page: PageWorkspaceSourceRow, html: string): PageWorkspac
|
|||
}));
|
||||
}
|
||||
|
||||
const semanticSections = extractSemanticSections(html);
|
||||
const contentSections = semanticSections.length >= 2 ? semanticSections : extractHeadingSections(html);
|
||||
const contentSections = getPageContentSections(html);
|
||||
const mediaSections = extractMediaSections(html);
|
||||
|
||||
return attachSemanticGroups([...sections, ...contentSections, ...mediaSections]);
|
||||
}
|
||||
|
||||
function buildIssueAlignedSections(page: PageWorkspaceSourceRow, html: string): PageWorkspaceSection[] {
|
||||
const semanticSections = extractSemanticSections(html);
|
||||
const contentSections = semanticSections.length >= 2 ? semanticSections : extractHeadingSections(html);
|
||||
const contentSections = getPageContentSections(html);
|
||||
const mediaSections = extractMediaSections(html);
|
||||
const pageAnchorSelector = contentSections[0]?.selector ?? mediaSections[0]?.selector ?? getPreviewTargetSelector("block-1");
|
||||
const h1Section = contentSections.find((section) => section.title.toLocaleLowerCase().startsWith("h1:"));
|
||||
|
|
@ -895,6 +1078,11 @@ function buildSnapshot(page: PageWorkspaceSourceRow, html: string): WorkspaceSna
|
|||
|
||||
async function getUpgradedSnapshot(page: PageWorkspaceSourceRow, item: DraftItemRow) {
|
||||
const snapshot = await readOriginalSnapshot(item.original_snapshot_key);
|
||||
const draftIsUnchanged = item.working_text == null || item.working_text === snapshot.originalText;
|
||||
const belongsToCurrentScan =
|
||||
snapshot.scanVersionId === page.scan_version_id &&
|
||||
snapshot.pageId === page.page_id &&
|
||||
snapshot.sourcePath === page.source_path;
|
||||
const mediaCount = Math.min(countMediaTags(snapshot.originalHtml ?? ""), MAX_MEDIA_SECTIONS);
|
||||
const mediaSectionCount = snapshot.sections.filter((section) => section.kind === "media").length;
|
||||
const isCurrentSnapshot =
|
||||
|
|
@ -906,16 +1094,26 @@ async function getUpgradedSnapshot(page: PageWorkspaceSourceRow, item: DraftItem
|
|||
snapshot.sections.some((section) => section.kind === "content_block" || section.kind === "heading_group") &&
|
||||
snapshot.sections.every((section) => Boolean(section.anchor?.entityType) && "fingerprint" in section.anchor) &&
|
||||
snapshot.sections.every((section) =>
|
||||
Boolean(section.semanticGroup?.evidence.includes(WORKSPACE_GROUPING_VERSION_EVIDENCE))
|
||||
Boolean(section.semanticGroup?.evidence?.includes(WORKSPACE_GROUPING_VERSION_EVIDENCE))
|
||||
) &&
|
||||
(!/<(img|video)\b/i.test(snapshot.originalHtml ?? "") || mediaSectionCount >= mediaCount) &&
|
||||
snapshot.sections.every((section) => section.kind === "meta" || section.selector?.includes("data-seo-target-id"));
|
||||
|
||||
if (isCurrentSnapshot) {
|
||||
if (isCurrentSnapshot && belongsToCurrentScan) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const html = snapshot.originalHtml || (await readPageHtml(page));
|
||||
// Never positionally map a changed draft onto a different scan or section
|
||||
// topology. Atomic upgrades can turn one legacy container into hundreds of
|
||||
// fields, so changed workspaces keep their immutable source snapshot until
|
||||
// a dedicated anchor-aware conflict/rebase flow is accepted.
|
||||
if (!draftIsUnchanged && (!belongsToCurrentScan || !isCurrentSnapshot)) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const html = belongsToCurrentScan
|
||||
? snapshot.originalHtml || (await readPageHtml(page))
|
||||
: await readPageHtml(page);
|
||||
const upgradedSnapshot = buildSnapshot(page, html);
|
||||
|
||||
if (item.original_snapshot_key) {
|
||||
|
|
@ -926,7 +1124,7 @@ async function getUpgradedSnapshot(page: PageWorkspaceSourceRow, item: DraftItem
|
|||
});
|
||||
}
|
||||
|
||||
if (!item.working_text || item.working_text === snapshot.originalText) {
|
||||
if (draftIsUnchanged) {
|
||||
await pool.query(
|
||||
`
|
||||
update draft_items
|
||||
|
|
|
|||
|
|
@ -1,11 +1,37 @@
|
|||
export type PreviewTargetSeed = {
|
||||
id: string;
|
||||
kind: "content_block" | "heading_group" | "media";
|
||||
kind: "content_block" | "heading_group" | "media" | "text_unit";
|
||||
selector: string;
|
||||
sourceHint: string;
|
||||
targetIndex: number;
|
||||
};
|
||||
|
||||
export type PreviewAtomicTextUnit = {
|
||||
contentHtml: string;
|
||||
documentSourceIndex: number;
|
||||
openingTagEndIndex: number;
|
||||
sourceIndex: number;
|
||||
tagName: string;
|
||||
targetIndex: number;
|
||||
};
|
||||
|
||||
type HtmlTagToken = {
|
||||
closing: boolean;
|
||||
end: number;
|
||||
selfClosing: boolean;
|
||||
start: number;
|
||||
tagName: string;
|
||||
};
|
||||
|
||||
type ProtectedHtml = {
|
||||
html: string;
|
||||
restore: (value: string) => string;
|
||||
};
|
||||
|
||||
const RAW_TEXT_BLOCK_PATTERN = /<(script|style|svg|noscript|template|textarea)\b[^>]*>[\s\S]*?<\/\1>/gi;
|
||||
const ATOMIC_TEXT_TAGS = new Set(["p", "li", "blockquote", "figcaption", "dt", "dd"]);
|
||||
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
||||
|
||||
function normalizeWhitespace(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
|
@ -15,13 +41,192 @@ function getBodyHtml(html: string) {
|
|||
return bodyMatch?.[1] ?? html;
|
||||
}
|
||||
|
||||
function maskRawTextBlocks(html: string) {
|
||||
return html.replace(RAW_TEXT_BLOCK_PATTERN, (block) => " ".repeat(block.length));
|
||||
}
|
||||
|
||||
function protectRawTextBlocks(html: string): ProtectedHtml {
|
||||
const blocks: string[] = [];
|
||||
const protectedHtml = html.replace(RAW_TEXT_BLOCK_PATTERN, (block) => {
|
||||
const index = blocks.push(block) - 1;
|
||||
return `\uE000SEO_MODE_RAW_${index}\uE001`;
|
||||
});
|
||||
|
||||
return {
|
||||
html: protectedHtml,
|
||||
restore: (value) => value.replace(/\uE000SEO_MODE_RAW_(\d+)\uE001/g, (_token, rawIndex: string) => {
|
||||
return blocks[Number(rawIndex)] ?? "";
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function scanHtmlTags(html: string) {
|
||||
const tokens: HtmlTagToken[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < html.length) {
|
||||
const start = html.indexOf("<", cursor);
|
||||
|
||||
if (start < 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (html.startsWith("<!--", start)) {
|
||||
const commentEnd = html.indexOf("-->", start + 4);
|
||||
cursor = commentEnd >= 0 ? commentEnd + 3 : html.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let quote = "";
|
||||
let end = start + 1;
|
||||
|
||||
for (; end < html.length; end += 1) {
|
||||
const character = html[end] ?? "";
|
||||
|
||||
if (quote) {
|
||||
if (character === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\"" || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ">") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (end >= html.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
const rawTag = html.slice(start, end + 1);
|
||||
const parsed = /^<\s*(\/?)\s*([A-Za-z][\w:-]*)\b/.exec(rawTag);
|
||||
|
||||
if (parsed) {
|
||||
tokens.push({
|
||||
closing: parsed[1] === "/",
|
||||
end: end + 1,
|
||||
selfClosing: /\/\s*>$/.test(rawTag),
|
||||
start,
|
||||
tagName: (parsed[2] ?? "").toLocaleLowerCase()
|
||||
});
|
||||
}
|
||||
|
||||
cursor = end + 1;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function getBodyContentRange(html: string) {
|
||||
const tokens = scanHtmlTags(html);
|
||||
const openingBody = tokens.find((token) => token.tagName === "body" && !token.closing);
|
||||
|
||||
if (!openingBody) {
|
||||
return { end: html.length, start: 0 };
|
||||
}
|
||||
|
||||
const closingBody = tokens.find(
|
||||
(token) => token.tagName === "body" && token.closing && token.start >= openingBody.end
|
||||
);
|
||||
|
||||
return {
|
||||
end: closingBody?.start ?? html.length,
|
||||
start: openingBody.end
|
||||
};
|
||||
}
|
||||
|
||||
function getMatchedTextUnits(html: string, allowedTags: Set<string>) {
|
||||
const maskedHtml = maskRawTextBlocks(html);
|
||||
const bodyRange = getBodyContentRange(maskedHtml);
|
||||
const maskedBodyHtml = maskedHtml.slice(bodyRange.start, bodyRange.end);
|
||||
const originalBodyHtml = html.slice(bodyRange.start, bodyRange.end);
|
||||
const openElements: Array<{
|
||||
openingEnd: number;
|
||||
openingStart: number;
|
||||
tagName: string;
|
||||
targetIndex: number;
|
||||
}> = [];
|
||||
const matches: PreviewAtomicTextUnit[] = [];
|
||||
let targetIndex = 0;
|
||||
|
||||
for (const token of scanHtmlTags(maskedBodyHtml)) {
|
||||
if (!allowedTags.has(token.tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!token.closing) {
|
||||
targetIndex += 1;
|
||||
|
||||
if (!token.selfClosing) {
|
||||
openElements.push({
|
||||
openingEnd: token.end,
|
||||
openingStart: token.start,
|
||||
tagName: token.tagName,
|
||||
targetIndex
|
||||
});
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let openingIndex = -1;
|
||||
|
||||
for (let index = openElements.length - 1; index >= 0; index -= 1) {
|
||||
if (openElements[index]?.tagName === token.tagName) {
|
||||
openingIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (openingIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const opening = openElements[openingIndex];
|
||||
openElements.splice(openingIndex, 1);
|
||||
|
||||
if (!opening) {
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
contentHtml: originalBodyHtml.slice(opening.openingEnd, token.start),
|
||||
documentSourceIndex: bodyRange.start + opening.openingStart,
|
||||
openingTagEndIndex: bodyRange.start + opening.openingEnd,
|
||||
sourceIndex: opening.openingStart,
|
||||
tagName: opening.tagName,
|
||||
targetIndex: opening.targetIndex
|
||||
});
|
||||
}
|
||||
|
||||
return matches.sort((left, right) => left.sourceIndex - right.sourceIndex);
|
||||
}
|
||||
|
||||
export function getPreviewAtomicTextUnits(html: string) {
|
||||
return getMatchedTextUnits(html, ATOMIC_TEXT_TAGS);
|
||||
}
|
||||
|
||||
export function getPreviewHeadingUnits(html: string) {
|
||||
return getMatchedTextUnits(html, HEADING_TAGS);
|
||||
}
|
||||
|
||||
function getAttribute(attributes: string, name: string) {
|
||||
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, "i");
|
||||
return pattern.exec(attributes)?.[1] ?? null;
|
||||
}
|
||||
|
||||
function htmlToPlainText(html: string) {
|
||||
return normalizeWhitespace(html.replace(/<script\b[\s\S]*?<\/script>/gi, " ").replace(/<style\b[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "));
|
||||
return normalizeWhitespace(
|
||||
html
|
||||
.replace(/\uE000SEO_MODE_RAW_\d+\uE001/g, " ")
|
||||
.replace(/<script\b[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
);
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string) {
|
||||
|
|
@ -29,8 +234,10 @@ function escapeAttribute(value: string) {
|
|||
}
|
||||
|
||||
function injectAttribute(openingTag: string, name: string, value: string) {
|
||||
if (new RegExp(`\\s${name}\\s*=`, "i").test(openingTag)) {
|
||||
return openingTag;
|
||||
const existingAttributePattern = new RegExp(`(\\s${name}\\s*=\\s*)(?:["'][^"']*["']|[^\\s>]+)`, "i");
|
||||
|
||||
if (existingAttributePattern.test(openingTag)) {
|
||||
return openingTag.replace(existingAttributePattern, `$1"${escapeAttribute(value)}"`);
|
||||
}
|
||||
|
||||
return openingTag.replace(/\/?\s*>$/, (ending) => ` ${name}="${escapeAttribute(value)}"${ending}`);
|
||||
|
|
@ -41,13 +248,14 @@ export function getPreviewTargetSelector(targetId: string) {
|
|||
}
|
||||
|
||||
export function getPreviewTargetSeeds(html: string) {
|
||||
const bodyHtml = getBodyHtml(html);
|
||||
const bodyHtml = getBodyHtml(maskRawTextBlocks(html));
|
||||
const seeds: PreviewTargetSeed[] = [];
|
||||
const blockPattern = /<(section|article|header|main|footer|aside|nav)\b([^>]*)>([\s\S]*?)<\/\1>/gi;
|
||||
let blockMatch: RegExpExecArray | null;
|
||||
let blockIndex = 1;
|
||||
let blockSeedCount = 0;
|
||||
|
||||
while ((blockMatch = blockPattern.exec(bodyHtml)) && seeds.length < 80) {
|
||||
while ((blockMatch = blockPattern.exec(bodyHtml))) {
|
||||
const tagName = blockMatch[1] ?? "section";
|
||||
const blockHtml = blockMatch[3] ?? "";
|
||||
const text = htmlToPlainText(blockHtml);
|
||||
|
|
@ -56,6 +264,10 @@ export function getPreviewTargetSeeds(html: string) {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (blockSeedCount >= 80) {
|
||||
break;
|
||||
}
|
||||
|
||||
const id = `block-${blockIndex}`;
|
||||
seeds.push({
|
||||
id,
|
||||
|
|
@ -65,37 +277,68 @@ export function getPreviewTargetSeeds(html: string) {
|
|||
targetIndex: blockIndex
|
||||
});
|
||||
blockIndex += 1;
|
||||
blockSeedCount += 1;
|
||||
}
|
||||
|
||||
const headingPattern = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi;
|
||||
let headingMatch: RegExpExecArray | null;
|
||||
let headingIndex = 1;
|
||||
const headingUnits = getPreviewHeadingUnits(html);
|
||||
let headingSeedCount = 0;
|
||||
|
||||
while ((headingMatch = headingPattern.exec(bodyHtml)) && seeds.length < 120) {
|
||||
const text = htmlToPlainText(headingMatch[2] ?? "");
|
||||
for (const headingUnit of headingUnits) {
|
||||
const text = htmlToPlainText(headingUnit.contentHtml);
|
||||
|
||||
if (text.length < 2) {
|
||||
headingIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const id = `heading-group-${headingIndex}`;
|
||||
if (headingSeedCount >= 160) {
|
||||
break;
|
||||
}
|
||||
|
||||
const id = `heading-group-${headingUnit.targetIndex}`;
|
||||
seeds.push({
|
||||
id,
|
||||
kind: "heading_group",
|
||||
selector: getPreviewTargetSelector(id),
|
||||
sourceHint: `H${headingMatch[1] ?? ""}`,
|
||||
targetIndex: headingIndex
|
||||
sourceHint: headingUnit.tagName.toLocaleUpperCase(),
|
||||
targetIndex: headingUnit.targetIndex
|
||||
});
|
||||
headingIndex += 1;
|
||||
headingSeedCount += 1;
|
||||
}
|
||||
|
||||
const textUnits = getPreviewAtomicTextUnits(html);
|
||||
let textUnitSeedCount = 0;
|
||||
|
||||
for (const textUnit of textUnits) {
|
||||
const text = htmlToPlainText(textUnit.contentHtml);
|
||||
|
||||
if (text.length >= 8) {
|
||||
if (textUnitSeedCount >= 400) {
|
||||
break;
|
||||
}
|
||||
|
||||
const id = `text-unit-${textUnit.targetIndex}`;
|
||||
seeds.push({
|
||||
id,
|
||||
kind: "text_unit",
|
||||
selector: getPreviewTargetSelector(id),
|
||||
sourceHint: `<${textUnit.tagName}>`,
|
||||
targetIndex: textUnit.targetIndex
|
||||
});
|
||||
textUnitSeedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const mediaPattern = /<(img|video)\b([^>]*)>/gi;
|
||||
let mediaMatch: RegExpExecArray | null;
|
||||
let mediaIndex = 0;
|
||||
while ((mediaMatch = mediaPattern.exec(bodyHtml)) && seeds.length < 180) {
|
||||
let mediaSeedCount = 0;
|
||||
while ((mediaMatch = mediaPattern.exec(bodyHtml))) {
|
||||
mediaIndex += 1;
|
||||
|
||||
if (mediaSeedCount >= 160) {
|
||||
break;
|
||||
}
|
||||
|
||||
const tagName = mediaMatch[1] ?? "img";
|
||||
const attributes = mediaMatch[2] ?? "";
|
||||
const source = getAttribute(attributes, "src") ?? getAttribute(attributes, "poster");
|
||||
|
|
@ -107,20 +350,51 @@ export function getPreviewTargetSeeds(html: string) {
|
|||
sourceHint: source ?? `${tagName}:nth-of-type(${mediaIndex})`,
|
||||
targetIndex: mediaIndex
|
||||
});
|
||||
mediaSeedCount += 1;
|
||||
}
|
||||
|
||||
return seeds;
|
||||
}
|
||||
|
||||
export function annotatePreviewTargets(html: string, seeds: PreviewTargetSeed[]) {
|
||||
const protectedHtml = protectRawTextBlocks(html);
|
||||
const blockSeeds = seeds.filter((seed) => seed.kind === "content_block");
|
||||
const headingSeeds = seeds.filter((seed) => seed.kind === "heading_group");
|
||||
const textUnitSeeds = seeds.filter((seed) => seed.kind === "text_unit");
|
||||
const mediaSeeds = seeds.filter((seed) => seed.kind === "media");
|
||||
let blockIndex = 0;
|
||||
let headingIndex = 0;
|
||||
let mediaIndex = 0;
|
||||
|
||||
return html
|
||||
const textElementAnnotations = [
|
||||
...getPreviewHeadingUnits(protectedHtml.html).map((unit) => ({ kind: "heading_group" as const, unit })),
|
||||
...getPreviewAtomicTextUnits(protectedHtml.html).map((unit) => ({ kind: "text_unit" as const, unit }))
|
||||
]
|
||||
.flatMap(({ kind, unit }) => {
|
||||
const text = htmlToPlainText(unit.contentHtml);
|
||||
const eligible = kind === "heading_group" ? text.length >= 2 : text.length >= 8;
|
||||
const candidates = kind === "heading_group" ? headingSeeds : textUnitSeeds;
|
||||
const seed = eligible ? candidates.find((candidate) => candidate.targetIndex === unit.targetIndex) : null;
|
||||
|
||||
if (!seed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const openingTag = protectedHtml.html.slice(unit.documentSourceIndex, unit.openingTagEndIndex);
|
||||
return [{
|
||||
end: unit.openingTagEndIndex,
|
||||
replacement: injectAttribute(openingTag, "data-seo-target-id", seed.id),
|
||||
start: unit.documentSourceIndex
|
||||
}];
|
||||
})
|
||||
.sort((left, right) => right.start - left.start);
|
||||
|
||||
let annotatedHtml = protectedHtml.html;
|
||||
|
||||
for (const annotation of textElementAnnotations) {
|
||||
annotatedHtml = `${annotatedHtml.slice(0, annotation.start)}${annotation.replacement}${annotatedHtml.slice(annotation.end)}`;
|
||||
}
|
||||
|
||||
const annotatedStructuralHtml = annotatedHtml
|
||||
.replace(/<(section|article|header|main|footer|aside|nav)\b([^>]*)>([\s\S]*?)<\/\1>/gi, (blockHtml, tagName: string, attributes: string, content: string) => {
|
||||
const text = htmlToPlainText(content);
|
||||
|
||||
|
|
@ -138,27 +412,12 @@ export function annotatePreviewTargets(html: string, seeds: PreviewTargetSeed[])
|
|||
const openingTag = `<${tagName}${attributes}>`;
|
||||
return blockHtml.replace(openingTag, injectAttribute(openingTag, "data-seo-target-id", seed.id));
|
||||
})
|
||||
.replace(/<h([1-6])\b([^>]*)>([\s\S]*?)<\/h\1>/gi, (headingHtml, level: string, attributes: string, content: string) => {
|
||||
const text = htmlToPlainText(content);
|
||||
|
||||
if (text.length < 2) {
|
||||
return headingHtml;
|
||||
}
|
||||
|
||||
const seed = headingSeeds[headingIndex];
|
||||
headingIndex += 1;
|
||||
|
||||
if (!seed) {
|
||||
return headingHtml;
|
||||
}
|
||||
|
||||
const openingTag = `<h${level}${attributes}>`;
|
||||
return headingHtml.replace(openingTag, injectAttribute(openingTag, "data-seo-target-id", seed.id));
|
||||
})
|
||||
.replace(/<(img|video)\b([^>]*)>/gi, (openingTag) => {
|
||||
mediaIndex += 1;
|
||||
const seed = mediaSeeds.find((candidate) => candidate.targetIndex === mediaIndex);
|
||||
|
||||
return seed ? injectAttribute(openingTag, "data-seo-target-id", seed.id) : openingTag;
|
||||
});
|
||||
|
||||
return protectedHtml.restore(annotatedStructuralHtml);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ export type SemanticBlockModel = {
|
|||
};
|
||||
|
||||
export type SemanticBlockSaveInput = {
|
||||
replaceWorkspaces?: Array<{
|
||||
pageId: string;
|
||||
scopeId: string;
|
||||
workspaceKey: string;
|
||||
}>;
|
||||
scanVersionId?: string | null;
|
||||
workspaceKey: string;
|
||||
scopeId: string;
|
||||
|
|
@ -175,11 +180,18 @@ export async function listSemanticBlocksForPages(projectId: string, pageIds: str
|
|||
from semantic_blocks sb
|
||||
join pages p on p.id = sb.page_id and p.project_id = sb.project_id
|
||||
where sb.project_id = $1
|
||||
and sb.page_id = any($2::uuid[])
|
||||
and (
|
||||
sb.page_id = any($2::uuid[])
|
||||
or exists (
|
||||
select 1
|
||||
from jsonb_array_elements_text(coalesce(sb.identity -> 'memberPageIds', '[]'::jsonb)) as member_page_id(value)
|
||||
where member_page_id.value = any($3::text[])
|
||||
)
|
||||
)
|
||||
and sb.archived_at is null
|
||||
order by sb.page_id asc, sb.workspace_key asc, sb.scope_id asc, sb.updated_at desc, sb.block_key asc;
|
||||
`,
|
||||
[projectId, uniquePageIds]
|
||||
[projectId, uniquePageIds, uniquePageIds]
|
||||
);
|
||||
|
||||
return result.rows.map(mapSemanticBlockRow);
|
||||
|
|
@ -195,6 +207,23 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId:
|
|||
throw new SemanticBlockWorkspaceError("Нужны workspaceKey и scopeId для semantic blocks.");
|
||||
}
|
||||
|
||||
const replaceWorkspaces = Array.from(
|
||||
new Map(
|
||||
(input.replaceWorkspaces ?? [])
|
||||
.map((entry) => ({
|
||||
pageId: cleanText(entry.pageId, 80),
|
||||
scopeId: cleanText(entry.scopeId, 240),
|
||||
workspaceKey: cleanText(entry.workspaceKey, 240)
|
||||
}))
|
||||
.filter((entry) => entry.pageId && entry.scopeId && entry.workspaceKey)
|
||||
.map((entry) => [`${entry.pageId}::${entry.workspaceKey}::${entry.scopeId}`, entry] as const)
|
||||
).values()
|
||||
);
|
||||
|
||||
for (const entry of replaceWorkspaces) {
|
||||
await assertPageBelongsToProject(projectId, entry.pageId);
|
||||
}
|
||||
|
||||
const normalizedBlocks = input.blocks
|
||||
.map((block) => ({
|
||||
anchors: normalizeArray(block.anchors),
|
||||
|
|
@ -217,6 +246,22 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId:
|
|||
|
||||
await client.query("begin");
|
||||
try {
|
||||
if (replaceWorkspaces.length > 0) {
|
||||
for (const entry of replaceWorkspaces) {
|
||||
await client.query(
|
||||
`
|
||||
update semantic_blocks
|
||||
set archived_at = now(), updated_at = now()
|
||||
where project_id = $1
|
||||
and page_id = $2
|
||||
and workspace_key = $3
|
||||
and scope_id = $4
|
||||
and archived_at is null;
|
||||
`,
|
||||
[projectId, entry.pageId, entry.workspaceKey, entry.scopeId]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await client.query(
|
||||
`
|
||||
update semantic_blocks
|
||||
|
|
@ -230,6 +275,7 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId:
|
|||
`,
|
||||
[projectId, pageId, workspaceKey, scopeId, blockKeys]
|
||||
);
|
||||
}
|
||||
|
||||
for (const block of normalizedBlocks) {
|
||||
await client.query(
|
||||
|
|
|
|||
Loading…
Reference in New Issue