@designcrowd/library.brand-page 6.0.11 → 6.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/{CreatedOnBrandCrowd-de-de-DopPc6g2.js → CreatedOnBrandCrowd-de-de-CBjFFeBP.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-CSHmaAWm.js → CreatedOnBrandCrowd-en-us-NuNXF81K.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-C4vM0p2e.js → CreatedOnBrandCrowd-es-es-BJG8nfug.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-ArpBbnxm.js → CreatedOnBrandCrowd-fr-ca-uX-nL53e.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-ArpBbnxm.js → CreatedOnBrandCrowd-fr-fr-uX-nL53e.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-CGAQLXiX.js → CreatedOnBrandCrowd-pt-br-DTrgFGQV.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-CGAQLXiX.js → CreatedOnBrandCrowd-pt-pt-DTrgFGQV.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-3WVSgB6t.js → CreatedOnDcom-de-de-DSfpxioT.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-Dx-RHXrH.js → CreatedOnDcom-en-us-C7G4gKem.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-CfnEYFMA.js → CreatedOnDcom-es-es-DVckCaji.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-BSHZTiUO.js → CreatedOnDcom-fr-ca-C5GDrm_C.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-DtHssrQb.js → CreatedOnDcom-fr-fr-v5eMm-j2.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-CdX1aRI8.js → CreatedOnDcom-pt-br-Dq3-fPUc.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-jy044FNL.js → CreatedOnDcom-pt-pt-DFsncYax.js} +1 -1
- package/lib/src/brand-page/components/image-gallery/ImageGallery.stories.js +3 -0
- package/lib/src/brand-page/components/image-gallery/masonry/MasonryGallery.mixin.d.ts +1 -1
- package/lib/src/brand-page/components/images-section/ImagesSection.stories.js +2 -1
- package/lib/src/brand-page/components/social-links/SocialLinks.stories.js +3 -0
- package/lib/src/brand-page/components/testimonial-section/__fixtures__/data.js +3 -0
- package/lib/src/brand-page/components/text/Text.stories.js +2 -0
- package/lib/src/brand-page/models/index.d.ts +13 -0
- package/lib/src/brand-page/models/index.js +4 -0
- package/lib/src/brand-page/utils/migration/migration-01.js +2 -0
- package/lib/src/brand-page/utils/migration/migration-18.d.ts +14 -0
- package/lib/src/brand-page/utils/migration/migration-18.js +17 -0
- package/lib/src/brand-page/utils/migration.util.js +6 -0
- package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.stories.js +1 -0
- package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.js +1 -0
- package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +5 -0
- package/lib/src/brand-page-maker/constants/config-settings-constant.js +4 -0
- package/lib/src/brand-page-maker/models/content-settings.js +4 -0
- package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +18 -0
- package/lib/src/index.mjs +84 -83
- package/lib/src/{lib-Bx7xK4dn.js → lib-BG-pyXi9.js} +19585 -19807
- package/lib/src/metadata-builder/ai-metadata-executor.js +24 -1
- package/lib/src/metadata-builder/builders/footer-builder.js +32 -41
- package/lib/src/metadata-builder/builders/item-id-utils.d.ts +54 -0
- package/lib/src/metadata-builder/builders/item-id-utils.js +96 -0
- package/lib/src/metadata-builder/builders/move-page-builder.d.ts +32 -0
- package/lib/src/metadata-builder/builders/move-page-builder.js +137 -0
- package/lib/src/metadata-builder/builders/nav-utils.d.ts +26 -0
- package/lib/src/metadata-builder/builders/nav-utils.js +95 -0
- package/lib/src/metadata-builder/builders/section-builders/image-gallery-builder.js +57 -68
- package/lib/src/metadata-builder/builders/section-builders/images-section-builder.js +58 -93
- package/lib/src/metadata-builder/builders/section-builders/move-section-builder.d.ts +13 -1
- package/lib/src/metadata-builder/builders/section-builders/move-section-builder.js +40 -30
- package/lib/src/metadata-builder/builders/section-builders/shapes-section-builder.js +66 -99
- package/lib/src/metadata-builder/builders/section-builders/social-links-builder.js +57 -17
- package/lib/src/metadata-builder/builders/section-builders/testimonial-section-builder.js +51 -85
- package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +50 -74
- package/lib/src/metadata-builder/builders/section-field-maps.js +17 -0
- package/lib/src/metadata-builder/builders/set-page-visibility-builder.d.ts +23 -0
- package/lib/src/metadata-builder/builders/set-page-visibility-builder.js +83 -0
- package/lib/src/metadata-builder/metadata-executor.js +4 -0
- package/lib/src/metadata-builder/models.d.ts +2 -0
- package/lib/src/metadata-builder/models.js +2 -0
- package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +31 -7
- package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +2 -0
- package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +84 -48
- package/lib/src/shared/content-settings/components/links-field/LinksField.mixin.js +2 -0
- package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +2 -0
- package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +2 -0
- package/lib/src/tracker.d.ts +2 -0
- package/lib/src/tracker.js +12 -0
- package/lib/src/tracker.mjs +12 -0
- package/lib/src/useEventTracker-DmQ6AkNn.js +356 -0
- package/lib/src/utils/minify-brand-page-metadata.d.ts +2 -0
- package/lib/src/utils/minify-brand-page-metadata.js +8 -1
- package/package.json +16 -1
- package/src/brand-page/components/image-gallery/ImageGallery.stories.ts +3 -0
- package/src/brand-page/components/image-gallery/ImageGallery.test.ts +1 -0
- package/src/brand-page/components/image-gallery/masonry/MasonryGallery.mixin.ts +2 -1
- package/src/brand-page/components/images-section/ImagesSection.stories.ts +2 -1
- package/src/brand-page/components/social-links/SocialLinks.stories.ts +3 -0
- package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +5 -0
- package/src/brand-page/components/testimonial-section/__fixtures__/data.ts +3 -0
- package/src/brand-page/components/text/Text.stories.ts +2 -0
- package/src/brand-page/models/index.ts +17 -0
- package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +23 -3
- package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +6 -0
- package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +12 -0
- package/src/brand-page/utils/migration/migration-01.ts +2 -0
- package/src/brand-page/utils/migration/migration-18.test.ts +131 -0
- package/src/brand-page/utils/migration/migration-18.ts +22 -0
- package/src/brand-page/utils/migration.util.ts +7 -0
- package/src/brand-page/utils/personalisation.util.test.ts +4 -0
- package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.stories.ts +1 -0
- package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +1 -0
- package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +6 -0
- package/src/brand-page-maker/constants/config-settings-constant.ts +4 -0
- package/src/brand-page-maker/models/content-settings.ts +4 -0
- package/src/metadata-builder/ai-metadata-executor.ts +31 -1
- package/src/metadata-builder/builders/footer-builder.test.ts +335 -28
- package/src/metadata-builder/builders/footer-builder.ts +40 -57
- package/src/metadata-builder/builders/item-id-utils.test.ts +184 -0
- package/src/metadata-builder/builders/item-id-utils.ts +117 -0
- package/src/metadata-builder/builders/move-page-builder.ts +227 -0
- package/src/metadata-builder/builders/nav-utils.ts +141 -0
- package/src/metadata-builder/builders/section-builders/image-gallery-builder.ts +62 -88
- package/src/metadata-builder/builders/section-builders/images-section-builder.ts +66 -129
- package/src/metadata-builder/builders/section-builders/move-section-builder.ts +58 -35
- package/src/metadata-builder/builders/section-builders/shapes-section-builder.ts +105 -160
- package/src/metadata-builder/builders/section-builders/social-links-builder.ts +65 -20
- package/src/metadata-builder/builders/section-builders/testimonial-section-builder.ts +83 -146
- package/src/metadata-builder/builders/section-builders/text-section-builder.ts +71 -122
- package/src/metadata-builder/builders/section-field-maps.ts +18 -0
- package/src/metadata-builder/builders/set-page-visibility-builder.ts +122 -0
- package/src/metadata-builder/metadata-executor.ts +4 -0
- package/src/metadata-builder/models.ts +2 -0
- package/src/metadata-builder/parsers/ai-toolcall-parser.ts +32 -7
- package/src/metadata-builder/parsers/section-tool-arguments.ts +84 -48
- package/src/shared/content-settings/components/links-field/LinksField.mixin.ts +2 -0
- package/src/tracker.ts +28 -0
- package/src/utils/minify-brand-page-metadata.ts +14 -2
- package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +0 -13
- package/lib/src/metadata-builder/builders/reorder-pages-builder.js +0 -49
- package/src/metadata-builder/builders/reorder-pages-builder.ts +0 -80
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
END_ITEM_ANCHOR,
|
|
4
|
+
ensureBrandPageItemIds,
|
|
5
|
+
ensureItemIds,
|
|
6
|
+
generateItemId,
|
|
7
|
+
removeItemsByIds,
|
|
8
|
+
repositionItemByAnchor,
|
|
9
|
+
} from './item-id-utils';
|
|
10
|
+
import type { BrandPageModel } from '../../brand-page/models';
|
|
11
|
+
|
|
12
|
+
type Item = { id?: string; label: string };
|
|
13
|
+
|
|
14
|
+
const UUID_RE =
|
|
15
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
const items = (...labels: string[]): Item[] =>
|
|
18
|
+
labels.map((label) => ({ id: label, label }));
|
|
19
|
+
|
|
20
|
+
const labels = (list: Item[]): string[] => list.map((i) => i.label);
|
|
21
|
+
|
|
22
|
+
describe('generateItemId', () => {
|
|
23
|
+
it('returns unique uuid-shaped ids', () => {
|
|
24
|
+
const a = generateItemId();
|
|
25
|
+
const b = generateItemId();
|
|
26
|
+
|
|
27
|
+
expect(a).not.toBe(b);
|
|
28
|
+
expect(a).toMatch(
|
|
29
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('ensureItemIds', () => {
|
|
35
|
+
it('backfills only the items missing an id, preserving existing ones', () => {
|
|
36
|
+
const list: Item[] = [
|
|
37
|
+
{ id: 'keep', label: 'a' },
|
|
38
|
+
{ label: 'b' },
|
|
39
|
+
{ id: '', label: 'c' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
ensureItemIds(list);
|
|
43
|
+
|
|
44
|
+
expect(list[0].id).toBe('keep');
|
|
45
|
+
expect(list[1].id).toBeTruthy();
|
|
46
|
+
expect(list[2].id).toBeTruthy();
|
|
47
|
+
expect(new Set(list.map((i) => i.id)).size).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('is a no-op for undefined', () => {
|
|
51
|
+
expect(() => ensureItemIds(undefined)).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('removeItemsByIds', () => {
|
|
56
|
+
it('removes by id regardless of order (no index shifting)', () => {
|
|
57
|
+
const result = removeItemsByIds(items('a', 'b', 'c', 'd'), ['c', 'a']);
|
|
58
|
+
|
|
59
|
+
expect(labels(result)).toEqual(['b', 'd']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns the list unchanged when no ids given', () => {
|
|
63
|
+
const list = items('a', 'b');
|
|
64
|
+
|
|
65
|
+
expect(removeItemsByIds(list, undefined)).toBe(list);
|
|
66
|
+
expect(removeItemsByIds(list, [])).toBe(list);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('returns [] for undefined items', () => {
|
|
70
|
+
expect(removeItemsByIds(undefined, ['x'])).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('repositionItemByAnchor', () => {
|
|
75
|
+
it('appends a new item when no anchor is given', () => {
|
|
76
|
+
const list = items('a', 'b');
|
|
77
|
+
|
|
78
|
+
repositionItemByAnchor(list, { id: 'new', label: 'new' }, undefined);
|
|
79
|
+
|
|
80
|
+
expect(labels(list)).toEqual(['a', 'b', 'new']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('leaves an existing item in place when no anchor is given', () => {
|
|
84
|
+
const list = items('a', 'b', 'c');
|
|
85
|
+
|
|
86
|
+
repositionItemByAnchor(list, list[1], undefined);
|
|
87
|
+
|
|
88
|
+
expect(labels(list)).toEqual(['a', 'b', 'c']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('inserts a new item before the anchor id', () => {
|
|
92
|
+
const list = items('a', 'b', 'c');
|
|
93
|
+
|
|
94
|
+
repositionItemByAnchor(list, { id: 'new', label: 'new' }, 'b');
|
|
95
|
+
|
|
96
|
+
expect(labels(list)).toEqual(['a', 'new', 'b', 'c']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('moves an existing item before the anchor id', () => {
|
|
100
|
+
const list = items('a', 'b', 'c', 'd');
|
|
101
|
+
|
|
102
|
+
// move 'd' before 'b'
|
|
103
|
+
repositionItemByAnchor(list, list[3], 'b');
|
|
104
|
+
|
|
105
|
+
expect(labels(list)).toEqual(['a', 'd', 'b', 'c']);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('moves an existing item to the end with the __end__ sentinel', () => {
|
|
109
|
+
const list = items('a', 'b', 'c');
|
|
110
|
+
|
|
111
|
+
repositionItemByAnchor(list, list[0], END_ITEM_ANCHOR);
|
|
112
|
+
|
|
113
|
+
expect(labels(list)).toEqual(['b', 'c', 'a']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls through to the end when the anchor id is not found', () => {
|
|
117
|
+
const list = items('a', 'b');
|
|
118
|
+
|
|
119
|
+
repositionItemByAnchor(list, { id: 'new', label: 'new' }, 'missing');
|
|
120
|
+
|
|
121
|
+
expect(labels(list)).toEqual(['a', 'b', 'new']);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('ensureBrandPageItemIds', () => {
|
|
126
|
+
const makeModel = (): BrandPageModel =>
|
|
127
|
+
({
|
|
128
|
+
pages: [
|
|
129
|
+
{
|
|
130
|
+
contents: [
|
|
131
|
+
{
|
|
132
|
+
type: 'image-gallery',
|
|
133
|
+
images: [{ imageUrl: 'a' }, { imageUrl: 'b' }],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: 'testimonial-section',
|
|
137
|
+
items: [{ id: 'keep-me', name: 'X' }, { name: 'Y' }],
|
|
138
|
+
},
|
|
139
|
+
{ type: 'text', textItems: [{ text: 'hi' }] },
|
|
140
|
+
{ type: 'social-links', links: [{ url: 'u' }] },
|
|
141
|
+
{ type: 'divider-section' }, // no item arrays — must not throw
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
pageFooterDesktop: { socialLinks: [{ url: 'f' }] },
|
|
146
|
+
pageFooterMobile: { socialLinks: [{ url: 'm' }] },
|
|
147
|
+
}) as unknown as BrandPageModel;
|
|
148
|
+
|
|
149
|
+
it('stamps a uuid id onto every id-less nested item across sections + footer', () => {
|
|
150
|
+
const model = makeModel();
|
|
151
|
+
|
|
152
|
+
ensureBrandPageItemIds(model);
|
|
153
|
+
|
|
154
|
+
const c = model.pages[0].contents as any[];
|
|
155
|
+
|
|
156
|
+
expect(c[0].images[0].id).toMatch(UUID_RE);
|
|
157
|
+
expect(c[0].images[1].id).toMatch(UUID_RE);
|
|
158
|
+
expect(c[1].items[1].id).toMatch(UUID_RE);
|
|
159
|
+
expect(c[2].textItems[0].id).toMatch(UUID_RE);
|
|
160
|
+
expect(c[3].links[0].id).toMatch(UUID_RE);
|
|
161
|
+
expect((model.pageFooterDesktop as any).socialLinks[0].id).toMatch(UUID_RE);
|
|
162
|
+
expect((model.pageFooterMobile as any).socialLinks[0].id).toMatch(UUID_RE);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('preserves existing ids (idempotent)', () => {
|
|
166
|
+
const model = makeModel();
|
|
167
|
+
|
|
168
|
+
ensureBrandPageItemIds(model);
|
|
169
|
+
const first = (model.pages[0].contents as any[])[1].items[0].id;
|
|
170
|
+
|
|
171
|
+
ensureBrandPageItemIds(model);
|
|
172
|
+
const second = (model.pages[0].contents as any[])[1].items[0].id;
|
|
173
|
+
|
|
174
|
+
expect(first).toBe('keep-me');
|
|
175
|
+
expect(second).toBe('keep-me');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does not throw on an empty / partial model', () => {
|
|
179
|
+
expect(() => ensureBrandPageItemIds({} as BrandPageModel)).not.toThrow();
|
|
180
|
+
expect(() =>
|
|
181
|
+
ensureBrandPageItemIds({ pages: [] } as unknown as BrandPageModel),
|
|
182
|
+
).not.toThrow();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import type { BrandPageModel } from '../../brand-page/models';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sentinel anchor meaning "the end of the list", mirroring the section-level
|
|
6
|
+
* `move_before_content_id: "__end__"` convention used in base-section-builder.
|
|
7
|
+
*/
|
|
8
|
+
export const END_ITEM_ANCHOR = '__end__';
|
|
9
|
+
|
|
10
|
+
/** Generate a stable item id. Mirrors section-level `uuid.v4()` id generation. */
|
|
11
|
+
export const generateItemId = (): string => uuidv4();
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Walk an entire BrandPageModel and backfill a stable uuid `id` onto every
|
|
15
|
+
* nested array item that lacks one (gallery images, images/shapes/testimonial
|
|
16
|
+
* items, text items, social links, and footer social links).
|
|
17
|
+
*
|
|
18
|
+
* BP-5603: this must run BEFORE the model is minified for the AI, so the same
|
|
19
|
+
* id the AI is shown (and copies into `item_id`) is the one the executor later
|
|
20
|
+
* matches. The maker holds a single live model instance for both the minify
|
|
21
|
+
* and the apply step, so mutating it here makes the ids round-trip; they then
|
|
22
|
+
* persist on the next save. Idempotent — existing ids are preserved.
|
|
23
|
+
*/
|
|
24
|
+
export const ensureBrandPageItemIds = (model: BrandPageModel): void => {
|
|
25
|
+
const ensureContentItemIds = (content: Record<string, unknown>): void => {
|
|
26
|
+
// Every array field that the minifier surfaces as a list of items.
|
|
27
|
+
for (const field of ['images', 'items', 'textItems', 'links'] as const) {
|
|
28
|
+
const arr = content[field];
|
|
29
|
+
|
|
30
|
+
if (Array.isArray(arr)) ensureItemIds(arr as { id?: string }[]);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const page of model.pages ?? []) {
|
|
35
|
+
for (const content of page.contents ?? []) {
|
|
36
|
+
ensureContentItemIds(content as unknown as Record<string, unknown>);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
ensureItemIds(model.pageFooterDesktop?.socialLinks);
|
|
41
|
+
ensureItemIds(model.pageFooterMobile?.socialLinks);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Backfill ids onto any items missing one. Per BP-5603 this runs in the
|
|
46
|
+
* metadata builder when a section is processed, so the persisted metadata
|
|
47
|
+
* returned to the client gains stable ids that subsequent AI tool calls can
|
|
48
|
+
* reference. Mutates in place.
|
|
49
|
+
*/
|
|
50
|
+
export const ensureItemIds = <T extends { id?: string }>(
|
|
51
|
+
items: T[] | undefined,
|
|
52
|
+
): void => {
|
|
53
|
+
if (!items) return;
|
|
54
|
+
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (!item.id) item.id = generateItemId();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Remove items whose id appears in `ids`. Order-independent (no descending
|
|
62
|
+
* index sort needed) — the whole point of the id-based migration. Items
|
|
63
|
+
* without an id are never removed by id.
|
|
64
|
+
*/
|
|
65
|
+
export const removeItemsByIds = <T extends { id?: string }>(
|
|
66
|
+
items: T[] | undefined,
|
|
67
|
+
ids: string[] | undefined,
|
|
68
|
+
): T[] => {
|
|
69
|
+
if (!items?.length) return items ?? [];
|
|
70
|
+
|
|
71
|
+
if (!ids?.length) return items;
|
|
72
|
+
|
|
73
|
+
const toRemove = new Set(ids);
|
|
74
|
+
|
|
75
|
+
return items.filter((item) => !item.id || !toRemove.has(item.id));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Position `item` within `items` relative to an anchor id — the item-level
|
|
80
|
+
* mirror of base-section-builder's `moveContent`:
|
|
81
|
+
*
|
|
82
|
+
* - `moveBeforeItemId === undefined` → leave an existing item where it is;
|
|
83
|
+
* append a new (not-yet-present) item to the end.
|
|
84
|
+
* - `moveBeforeItemId === "__end__"` → move/insert at the very end.
|
|
85
|
+
* - anchor id found → insert immediately before it.
|
|
86
|
+
* - anchor id not found → fall through to the end (same
|
|
87
|
+
* forgiving behaviour as `moveContent`).
|
|
88
|
+
*
|
|
89
|
+
* Mutates `items` in place. `item` may be an existing element of `items`
|
|
90
|
+
* (a move) or a brand-new object (an insert).
|
|
91
|
+
*/
|
|
92
|
+
export const repositionItemByAnchor = <T extends { id?: string }>(
|
|
93
|
+
items: T[],
|
|
94
|
+
item: T,
|
|
95
|
+
moveBeforeItemId: string | undefined,
|
|
96
|
+
): void => {
|
|
97
|
+
const currentIndex = items.indexOf(item);
|
|
98
|
+
|
|
99
|
+
if (moveBeforeItemId === undefined) {
|
|
100
|
+
if (currentIndex === -1) items.push(item);
|
|
101
|
+
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (currentIndex !== -1) items.splice(currentIndex, 1);
|
|
106
|
+
|
|
107
|
+
const anchorIndex =
|
|
108
|
+
moveBeforeItemId === END_ITEM_ANCHOR
|
|
109
|
+
? -1
|
|
110
|
+
: items.findIndex((i) => i.id === moveBeforeItemId);
|
|
111
|
+
|
|
112
|
+
if (anchorIndex !== -1) {
|
|
113
|
+
items.splice(anchorIndex, 0, item);
|
|
114
|
+
} else {
|
|
115
|
+
items.push(item);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { BrandPageModel, NavOrder } from '../../brand-page/models';
|
|
2
|
+
import { getPageSlugFromPageTitle } from '../../brand-page-maker/utils/slug.util';
|
|
3
|
+
import {
|
|
4
|
+
BaseMetadataBuilder,
|
|
5
|
+
type BuilderResult,
|
|
6
|
+
type MetadataBuilderRequest,
|
|
7
|
+
} from '../models';
|
|
8
|
+
import type { Logger } from '../logger';
|
|
9
|
+
import {
|
|
10
|
+
detachOrder,
|
|
11
|
+
findOrderAnywhere,
|
|
12
|
+
findParentRouteId,
|
|
13
|
+
getSiblingSlugsAtDepth,
|
|
14
|
+
syncPagesWithNavOrders,
|
|
15
|
+
} from './nav-utils';
|
|
16
|
+
|
|
17
|
+
export interface MovePagePayload {
|
|
18
|
+
/** The page to move (resolved from page_id). */
|
|
19
|
+
pageId: string;
|
|
20
|
+
/**
|
|
21
|
+
* Destination parent: a page_id to nest under, or null/undefined for top-level.
|
|
22
|
+
* Analogous to move_section's target_page_id.
|
|
23
|
+
*/
|
|
24
|
+
parentPageId?: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Position anchor at the destination level: the page_id of the sibling to
|
|
27
|
+
* insert before, or "__end__" to append. Omitted/null → append at end.
|
|
28
|
+
* Analogous to move_section's move_before_content_id.
|
|
29
|
+
*/
|
|
30
|
+
moveBeforePageId?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class MovePageBuilder extends BaseMetadataBuilder<MovePagePayload> {
|
|
34
|
+
constructor(
|
|
35
|
+
request: MetadataBuilderRequest<MovePagePayload>,
|
|
36
|
+
logger?: Logger,
|
|
37
|
+
) {
|
|
38
|
+
super(request, logger);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async validateAsync(): Promise<{ errors: string[] }> {
|
|
42
|
+
const errors: string[] = [];
|
|
43
|
+
const payload = this.request.payload;
|
|
44
|
+
|
|
45
|
+
if (!payload?.pageId) {
|
|
46
|
+
errors.push('pageId is required for move_page');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { errors };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async buildAsync(model: BrandPageModel): Promise<BuilderResult> {
|
|
53
|
+
this.log('Build called');
|
|
54
|
+
|
|
55
|
+
const validation = await this.validateAsync();
|
|
56
|
+
|
|
57
|
+
if (validation.errors.length) {
|
|
58
|
+
return { request: this.request, model, errors: validation.errors };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const { pageId, parentPageId, moveBeforePageId } = this.request.payload!;
|
|
62
|
+
|
|
63
|
+
// Build lookup: pageId → NavRoute.
|
|
64
|
+
const routeByPageId = new Map(model.nav.routes.map((r) => [r.pageId, r]));
|
|
65
|
+
|
|
66
|
+
// --- Find the target route ---
|
|
67
|
+
const targetRoute = routeByPageId.get(pageId);
|
|
68
|
+
|
|
69
|
+
if (!targetRoute) {
|
|
70
|
+
return {
|
|
71
|
+
request: this.request,
|
|
72
|
+
model,
|
|
73
|
+
errors: [`Page with ID "${pageId}" not found`],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const targetRouteId = targetRoute.id;
|
|
78
|
+
|
|
79
|
+
// Find the NavOrder for the target (must exist in the tree).
|
|
80
|
+
const targetOrder = findOrderAnywhere(model.nav.orders, targetRouteId);
|
|
81
|
+
|
|
82
|
+
if (!targetOrder) {
|
|
83
|
+
return {
|
|
84
|
+
request: this.request,
|
|
85
|
+
model,
|
|
86
|
+
errors: [`NavOrder for page "${pageId}" not found in nav.orders`],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- GUARD: parent cannot equal target ---
|
|
91
|
+
if (parentPageId && parentPageId === pageId) {
|
|
92
|
+
return {
|
|
93
|
+
request: this.request,
|
|
94
|
+
model,
|
|
95
|
+
errors: [`A page cannot be nested under itself`],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- GUARD: home page (orders[0]) cannot be nested ---
|
|
100
|
+
const homeRouteId = model.nav.orders[0]?.routeId;
|
|
101
|
+
|
|
102
|
+
if (parentPageId && homeRouteId === targetRouteId) {
|
|
103
|
+
return {
|
|
104
|
+
request: this.request,
|
|
105
|
+
model,
|
|
106
|
+
errors: [`Home page "${pageId}" cannot be nested under another page`],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Resolve destination level ---
|
|
111
|
+
let destinationOrders: NavOrder[];
|
|
112
|
+
|
|
113
|
+
if (parentPageId) {
|
|
114
|
+
const parentRoute = routeByPageId.get(parentPageId);
|
|
115
|
+
|
|
116
|
+
if (!parentRoute) {
|
|
117
|
+
return {
|
|
118
|
+
request: this.request,
|
|
119
|
+
model,
|
|
120
|
+
errors: [`Parent page with ID "${parentPageId}" not found`],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// GUARD: parent must be a root-level page (depth-1 cap).
|
|
125
|
+
const isParentAtRoot = model.nav.orders.some(
|
|
126
|
+
(o) => o.routeId === parentRoute.id,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
if (!isParentAtRoot) {
|
|
130
|
+
return {
|
|
131
|
+
request: this.request,
|
|
132
|
+
model,
|
|
133
|
+
errors: [
|
|
134
|
+
`Parent page "${parentPageId}" is itself a subpage — nesting beyond depth 1 is not supported`,
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const parentOrder = findOrderAnywhere(model.nav.orders, parentRoute.id);
|
|
140
|
+
|
|
141
|
+
// GUARD: target page must not have its own subRoutes when being nested.
|
|
142
|
+
if (targetOrder.subRoutes.length > 0) {
|
|
143
|
+
return {
|
|
144
|
+
request: this.request,
|
|
145
|
+
model,
|
|
146
|
+
errors: [
|
|
147
|
+
`Page "${pageId}" has subpages and cannot itself be nested under another page`,
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
destinationOrders = parentOrder!.subRoutes;
|
|
153
|
+
} else {
|
|
154
|
+
// Moving to top level (un-nest or reorder at root).
|
|
155
|
+
destinationOrders = model.nav.orders;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Record the old parent before any mutation for slug recompute.
|
|
159
|
+
const oldParentRouteId = findParentRouteId(model.nav.orders, targetRouteId);
|
|
160
|
+
|
|
161
|
+
// Detach the target from its current position.
|
|
162
|
+
detachOrder(model.nav.orders, targetRouteId);
|
|
163
|
+
|
|
164
|
+
// --- Resolve insertion index ---
|
|
165
|
+
const insertIndex = this.resolveIndex(
|
|
166
|
+
destinationOrders,
|
|
167
|
+
moveBeforePageId,
|
|
168
|
+
routeByPageId,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
destinationOrders.splice(insertIndex, 0, targetOrder);
|
|
172
|
+
|
|
173
|
+
// --- Slug recompute only when parent changed ---
|
|
174
|
+
const newParentRouteId =
|
|
175
|
+
findParentRouteId(model.nav.orders, targetRouteId) ?? undefined;
|
|
176
|
+
|
|
177
|
+
// After detach+re-insert, undefined means root.
|
|
178
|
+
const effectiveOldParent = oldParentRouteId ?? undefined;
|
|
179
|
+
|
|
180
|
+
if (newParentRouteId !== effectiveOldParent) {
|
|
181
|
+
const siblingSlugs = getSiblingSlugsAtDepth(
|
|
182
|
+
model,
|
|
183
|
+
newParentRouteId,
|
|
184
|
+
targetRouteId,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
targetRoute.pageSlug = getPageSlugFromPageTitle(
|
|
188
|
+
targetRoute.displayName,
|
|
189
|
+
siblingSlugs,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
syncPagesWithNavOrders(model);
|
|
194
|
+
|
|
195
|
+
this.log({
|
|
196
|
+
message: `Moved page "${pageId}" — parent: ${parentPageId ?? 'root'}, before: ${moveBeforePageId ?? 'end'}`,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
return { request: this.request, model };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the insertion index within destinationOrders.
|
|
204
|
+
*
|
|
205
|
+
* moveBeforePageId anchors by pageId of a sibling at that level.
|
|
206
|
+
* "__end__" or omitted/null → append. Unknown anchor → append.
|
|
207
|
+
*/
|
|
208
|
+
private resolveIndex(
|
|
209
|
+
destinationOrders: NavOrder[],
|
|
210
|
+
moveBeforePageId: string | null | undefined,
|
|
211
|
+
routeByPageId: Map<string, { id: string }>,
|
|
212
|
+
): number {
|
|
213
|
+
if (!moveBeforePageId || moveBeforePageId === '__end__') {
|
|
214
|
+
return destinationOrders.length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const anchorRoute = routeByPageId.get(moveBeforePageId);
|
|
218
|
+
|
|
219
|
+
if (!anchorRoute) return destinationOrders.length;
|
|
220
|
+
|
|
221
|
+
const idx = destinationOrders.findIndex(
|
|
222
|
+
(o) => o.routeId === anchorRoute.id,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
return idx !== -1 ? idx : destinationOrders.length;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { BrandPageModel, NavOrder } from '../../brand-page/models';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DFS walk of nav.orders (recurse subRoutes), returning every routeId in visit order.
|
|
5
|
+
*/
|
|
6
|
+
const dfsRouteIds = (orders: NavOrder[]): string[] => {
|
|
7
|
+
const ids: string[] = [];
|
|
8
|
+
|
|
9
|
+
for (const order of orders) {
|
|
10
|
+
ids.push(order.routeId);
|
|
11
|
+
ids.push(...dfsRouteIds(order.subRoutes));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return ids;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resync model.pages[] to match the DFS walk of model.nav.orders.
|
|
19
|
+
* Pages not referenced in nav are appended at the end (defensive).
|
|
20
|
+
* Mutates and returns model.pages in place.
|
|
21
|
+
*/
|
|
22
|
+
export const syncPagesWithNavOrders = (
|
|
23
|
+
model: BrandPageModel,
|
|
24
|
+
): BrandPageModel['pages'] => {
|
|
25
|
+
const routeIds = dfsRouteIds(model.nav.orders);
|
|
26
|
+
|
|
27
|
+
// Build a map from pageId to its nav position so we can sort pages by encounter order.
|
|
28
|
+
const routeById = new Map(model.nav.routes.map((r) => [r.id, r]));
|
|
29
|
+
|
|
30
|
+
// Map from pageId → DFS index (using route→pageId lookup).
|
|
31
|
+
const routeIdToDfsIndex = new Map(routeIds.map((rid, i) => [rid, i]));
|
|
32
|
+
|
|
33
|
+
// Convert nav routeId order to pageId order.
|
|
34
|
+
const pageIdOrder = new Map<string, number>();
|
|
35
|
+
|
|
36
|
+
for (const [routeId, idx] of routeIdToDfsIndex) {
|
|
37
|
+
const route = routeById.get(routeId);
|
|
38
|
+
|
|
39
|
+
if (route) {
|
|
40
|
+
pageIdOrder.set(route.pageId, idx);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const navMax = routeIds.length;
|
|
45
|
+
|
|
46
|
+
model.pages = [...model.pages].sort((a, b) => {
|
|
47
|
+
const ia = pageIdOrder.has(a.id) ? pageIdOrder.get(a.id)! : navMax;
|
|
48
|
+
const ib = pageIdOrder.has(b.id) ? pageIdOrder.get(b.id)! : navMax;
|
|
49
|
+
|
|
50
|
+
return ia - ib;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return model.pages;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Find a NavOrder by routeId anywhere in the tree (root + subRoutes, recursive).
|
|
58
|
+
*/
|
|
59
|
+
export const findOrderAnywhere = (
|
|
60
|
+
orders: NavOrder[],
|
|
61
|
+
routeId: string,
|
|
62
|
+
): NavOrder | undefined => {
|
|
63
|
+
for (const order of orders) {
|
|
64
|
+
if (order.routeId === routeId) return order;
|
|
65
|
+
|
|
66
|
+
const found = findOrderAnywhere(order.subRoutes, routeId);
|
|
67
|
+
|
|
68
|
+
if (found) return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return undefined;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find the routeId of the parent that contains the target as a direct child in subRoutes.
|
|
76
|
+
* Returns undefined when the target is a root order.
|
|
77
|
+
*/
|
|
78
|
+
export const findParentRouteId = (
|
|
79
|
+
orders: NavOrder[],
|
|
80
|
+
targetRouteId: string,
|
|
81
|
+
_parentId?: string,
|
|
82
|
+
): string | undefined => {
|
|
83
|
+
for (const order of orders) {
|
|
84
|
+
if (order.subRoutes.some((sr) => sr.routeId === targetRouteId)) {
|
|
85
|
+
return order.routeId;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const found = findParentRouteId(
|
|
89
|
+
order.subRoutes,
|
|
90
|
+
targetRouteId,
|
|
91
|
+
order.routeId,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if (found !== undefined) return found;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return undefined;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Splice the NavOrder with routeId from wherever it lives (root or any subRoutes), in place.
|
|
102
|
+
*/
|
|
103
|
+
export const detachOrder = (orders: NavOrder[], routeId: string): void => {
|
|
104
|
+
const idx = orders.findIndex((o) => o.routeId === routeId);
|
|
105
|
+
|
|
106
|
+
if (idx !== -1) {
|
|
107
|
+
orders.splice(idx, 1);
|
|
108
|
+
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const order of orders) {
|
|
113
|
+
detachOrder(order.subRoutes, routeId);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Return the slugs of all routes that are direct children at the same depth as parentRouteId.
|
|
119
|
+
* - parentRouteId === undefined → root orders.
|
|
120
|
+
* - excludeRouteId is omitted from the result (pass the route being renamed/moved).
|
|
121
|
+
*/
|
|
122
|
+
export const getSiblingSlugsAtDepth = (
|
|
123
|
+
model: BrandPageModel,
|
|
124
|
+
parentRouteId: string | undefined,
|
|
125
|
+
excludeRouteId: string,
|
|
126
|
+
): string[] => {
|
|
127
|
+
const siblingOrders =
|
|
128
|
+
parentRouteId === undefined
|
|
129
|
+
? model.nav.orders
|
|
130
|
+
: (findOrderAnywhere(model.nav.orders, parentRouteId)?.subRoutes ?? []);
|
|
131
|
+
|
|
132
|
+
const siblingRouteIds = new Set(
|
|
133
|
+
siblingOrders
|
|
134
|
+
.filter((o) => o.routeId !== excludeRouteId)
|
|
135
|
+
.map((o) => o.routeId),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return model.nav.routes
|
|
139
|
+
.filter((r) => siblingRouteIds.has(r.id))
|
|
140
|
+
.map((r) => r.pageSlug);
|
|
141
|
+
};
|