Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 62 additions & 9 deletions src/actions/sync.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,13 +55,65 @@ export async function syncStyles() {
}
}

function pageFileName(page) {
const base =
page.name ||
(page.path && page.path !== '/'
? page.path.replace(/^\//, '').replace(/\//g, '-')
: 'home');
return `${base.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`;
function pagePathBase(page) {
return page.path && page.path !== '/'
? page.path.replace(/^\//, '').replace(/\//g, '-')
: 'home';
}

/**
* Compute a unique local file name for every page in a campaign.
*
* `page.name` is only unique for template pages — every custom
* (page-builder) page shares the name "legacy", so naming files by
* `page.name` alone makes all custom pages overwrite each other into a
* single legacy.json. Whenever a name is shared by more than one page,
* fall back to the page's path instead.
*
* @param {Array<object>} pages Pages belonging to one campaign
* @returns {string[]} File name for each page, in the same order
*/
export function pageFileNames(pages) {
const nameCounts = new Map();
for (const page of pages) {
if (page.name) {
nameCounts.set(page.name, (nameCounts.get(page.name) || 0) + 1);
}
}

const sanitize = (base) => `${base.replace(/[^a-zA-Z0-9._-]/g, '_')}.json`;

// First pass: pages with a unique name keep their name-based file.
// Reserving these up front keeps template filenames stable regardless
// of API order (a custom page at e.g. /profile must never take
// profile.json from the profile template page).
const used = new Set();
const fileNames = pages.map((page) => {
if (page.name && nameCounts.get(page.name) === 1) {
const fileName = sanitize(page.name);
used.add(fileName);
return fileName;
}
return null;
});

// Second pass: pages with a shared or missing name fall back to their
// path, with a short uuid suffix on any residual collision.
return fileNames.map((fileName, index) => {
if (fileName) {
return fileName;
}
const page = pages[index];
let candidate = sanitize(pagePathBase(page));
if (used.has(candidate) && page.uuid) {
candidate = candidate.replace(
/\.json$/,
`-${page.uuid.slice(0, 8)}.json`
);
}
used.add(candidate);
return candidate;
});
}

export async function syncPages() {
Expand All@@ -87,7 +139,8 @@ export async function syncPages() {
path: `/campaigns/${uuid}/pages?private=1&includeBody=1&limit=999`,
});

for (const page of pages.data) {
const fileNames = pageFileNames(pages.data);
for (const [index, page] of pages.data.entries()) {
const out = {
uuid: page.uuid,
path: page.path,
Expand All@@ -107,7 +160,7 @@ export async function syncPages() {
};

fs.writeFileSync(
path.join(pagesDir, pageFileName(page)),
path.join(pagesDir, fileNames[index]),
JSON.stringify(out, null, 4)
);
}
Expand Down
87 changes: 87 additions & 0 deletions tests/sync.test.js
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
import { describe, expect, test } from 'vitest';

import { pageFileNames } from '../src/actions/sync.js';

describe('pageFileNames', () => {
test('names template pages by their unique name', () => {
const pages = [
{ uuid: 'u1', name: 'home', path: '/' },
{ uuid: 'u2', name: 'dashboard', path: '/dashboard' },
{ uuid: 'u3', name: 'profile', path: '/:id' },
];
expect(pageFileNames(pages)).toEqual([
'home.json',
'dashboard.json',
'profile.json',
]);
});

test('falls back to path when multiple pages share a name', () => {
// Custom (page-builder) pages all share the name "legacy" — naming
// files by name alone collapses them into a single legacy.json.
const pages = [
{ uuid: 'u1', name: 'legacy', path: '/cause-areas' },
{ uuid: 'u2', name: 'legacy', path: '/terms' },
{ uuid: 'u3', name: 'legacy', path: '/about/team' },
{ uuid: 'u4', name: 'home', path: '/' },
];
expect(pageFileNames(pages)).toEqual([
'cause-areas.json',
'terms.json',
'about-team.json',
'home.json',
]);
});

test('keeps name-based file for a name used by only one page', () => {
const pages = [
{ uuid: 'u1', name: 'legacy', path: '/only-custom-page' },
{ uuid: 'u2', name: 'home', path: '/' },
];
expect(pageFileNames(pages)).toEqual(['legacy.json', 'home.json']);
});

test('uses path for pages without a name', () => {
const pages = [
{ uuid: 'u1', name: null, path: '/register' },
{ uuid: 'u2', name: null, path: '/signup' },
];
expect(pageFileNames(pages)).toEqual(['register.json', 'signup.json']);
});

test('sanitizes special characters in paths', () => {
const pages = [
{ uuid: 'u1', name: null, path: '/reset/:passwordResetToken?' },
];
expect(pageFileNames(pages)).toEqual([
'reset-_passwordResetToken_.json',
]);
});

test('template filenames are reserved regardless of API order', () => {
// A custom page at the literal path /profile must not take
// profile.json from the profile template page (path /:id), even
// when the API returns the custom page first.
const pages = [
{ uuid: 'cccccccc-3333', name: 'legacy', path: '/profile' },
{ uuid: 'dddddddd-4444', name: 'legacy', path: '/terms' },
{ uuid: 'eeeeeeee-5555', name: 'profile', path: '/:id' },
];
expect(pageFileNames(pages)).toEqual([
'profile-cccccccc.json',
'terms.json',
'profile.json',
]);
});

test('disambiguates residual file name collisions with a uuid suffix', () => {
const pages = [
{ uuid: 'aaaaaaaa-1111', name: 'legacy', path: '/promo!' },
{ uuid: 'bbbbbbbb-2222', name: 'legacy', path: '/promo?' },
];
expect(pageFileNames(pages)).toEqual([
'promo_.json',
'promo_-bbbbbbbb.json',
]);
});
});