Merge branch 'develop' into feature/cw-7513
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
// Powers the "unpublished changes" preview: marks what changed between the live
|
||||
// article and the draft — word by word in the title, block by block in the body.
|
||||
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
// Matches the public renderer (CommonMark, no typographer). True when two
|
||||
// markdown strings render the same — so blank-line/spacing-only edits don't count,
|
||||
// but real changes (code indentation, smart quotes, width markers) do.
|
||||
const commonmark = MarkdownIt('commonmark');
|
||||
export const rendersIdentically = (a, b) =>
|
||||
commonmark.render(a ?? '') === commonmark.render(b ?? '');
|
||||
|
||||
const INS_CLASS = '!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5';
|
||||
const DEL_CLASS = '!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5';
|
||||
|
||||
// Detailed compare gets slow on huge texts; past this, show all old as removed
|
||||
// and all new as added.
|
||||
const MAX_DIFF_TOKENS = 2000;
|
||||
|
||||
const tokenizeWords = value => (value || '').match(/\S+/g) || [];
|
||||
|
||||
const escapeHtml = value =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
// Compares two lists in order and reports what's the same (`equal`), removed
|
||||
// (`del`) or added (`ins`), keeping as much unchanged as possible. `keyOf` says
|
||||
// how to compare items (title passes words, body passes blocks).
|
||||
const diffSequence = (a, b, keyOf = item => item) => {
|
||||
const n = a.length;
|
||||
const m = b.length;
|
||||
if (n > MAX_DIFF_TOKENS || m > MAX_DIFF_TOKENS) {
|
||||
return [
|
||||
...a.map(item => ({ type: 'del', item })),
|
||||
...b.map(item => ({ type: 'ins', item })),
|
||||
];
|
||||
}
|
||||
|
||||
const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i -= 1) {
|
||||
for (let j = m - 1; j >= 0; j -= 1) {
|
||||
dp[i][j] =
|
||||
keyOf(a[i]) === keyOf(b[j])
|
||||
? dp[i + 1][j + 1] + 1
|
||||
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
const ops = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (keyOf(a[i]) === keyOf(b[j])) {
|
||||
ops.push({ type: 'equal', item: a[i] });
|
||||
i += 1;
|
||||
j += 1;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
ops.push({ type: 'del', item: a[i] });
|
||||
i += 1;
|
||||
} else {
|
||||
ops.push({ type: 'ins', item: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
while (i < n) {
|
||||
ops.push({ type: 'del', item: a[i] });
|
||||
i += 1;
|
||||
}
|
||||
while (j < m) {
|
||||
ops.push({ type: 'ins', item: b[j] });
|
||||
j += 1;
|
||||
}
|
||||
return ops;
|
||||
};
|
||||
|
||||
const wrapDiff = {
|
||||
ins: text => `<ins class="${INS_CLASS}">${text}</ins>`,
|
||||
del: text => `<del class="${DEL_CLASS}">${text}</del>`,
|
||||
};
|
||||
|
||||
// Builds the highlighted title. Compares whole words (not single spaces) so
|
||||
// repeated words/spaces don't make the highlights jump around, then rejoins
|
||||
// with single spaces — a run of added/removed words shares one <ins>/<del> tag.
|
||||
export const renderInlineDiff = (oldValue, newValue) => {
|
||||
const ops = diffSequence(tokenizeWords(oldValue), tokenizeWords(newValue));
|
||||
|
||||
const segments = [];
|
||||
let run = [];
|
||||
let runType = null;
|
||||
const flushRun = () => {
|
||||
if (!run.length) return;
|
||||
const text = run.map(escapeHtml).join(' ');
|
||||
segments.push(wrapDiff[runType] ? wrapDiff[runType](text) : text);
|
||||
run = [];
|
||||
};
|
||||
|
||||
ops.forEach(({ type, item }) => {
|
||||
if (type !== runType) flushRun();
|
||||
runType = type;
|
||||
run.push(item);
|
||||
});
|
||||
flushRun();
|
||||
|
||||
return segments.join(' ');
|
||||
};
|
||||
|
||||
// A fenced code block opener: ``` or ~~~, indented up to 3 spaces (CommonMark).
|
||||
const FENCE_RE = /^ {0,3}(```|~~~)/;
|
||||
// A list item marker: -, *, + or "1." / "1)", indented up to 3 spaces.
|
||||
const LIST_ITEM_RE = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
||||
|
||||
// Split on blank lines so each paragraph, heading or list compares as one piece.
|
||||
// Blank lines inside a fenced code block, or between items of the same list, are
|
||||
// content — splitting there would tear a code block or list apart and render it
|
||||
// with broken structure (orphaned <li>/<p>), so we keep those together.
|
||||
const splitBlocks = text => {
|
||||
const lines = (text || '').split('\n');
|
||||
const blocks = [];
|
||||
let buffer = [];
|
||||
let fence = null;
|
||||
let inList = false;
|
||||
|
||||
const flush = () => {
|
||||
const block = buffer.join('\n');
|
||||
if (block.trim()) blocks.push(block);
|
||||
buffer = [];
|
||||
inList = false;
|
||||
};
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
const marker = line.match(FENCE_RE)?.[1];
|
||||
if (marker && !fence) fence = marker;
|
||||
else if (fence && line.trimStart().startsWith(fence)) fence = null;
|
||||
|
||||
if (fence) {
|
||||
buffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (LIST_ITEM_RE.test(line)) inList = true;
|
||||
|
||||
if (line.trim() !== '') {
|
||||
buffer.push(line);
|
||||
return;
|
||||
}
|
||||
|
||||
// Blank line: keep it when the current list continues on the next non-blank
|
||||
// line (another item or an indented continuation); otherwise end the block.
|
||||
const next = lines.slice(index + 1).find(other => other.trim() !== '');
|
||||
if (inList && next && (LIST_ITEM_RE.test(next) || /^\s/.test(next))) {
|
||||
buffer.push(line);
|
||||
} else {
|
||||
flush();
|
||||
}
|
||||
});
|
||||
|
||||
flush();
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const BLOCK_TYPE = { equal: 'equal', del: 'removed', ins: 'added' };
|
||||
|
||||
// Diffs the body block by block. Blocks match when they render to the same HTML
|
||||
// (the check staging uses), so only edits that change the page show as a diff.
|
||||
export const buildDiffBlocks = (oldText, newText) => {
|
||||
const toBlocks = text =>
|
||||
splitBlocks(text).map(md => ({ md, key: commonmark.render(md) }));
|
||||
const ops = diffSequence(toBlocks(oldText), toBlocks(newText), b => b.key);
|
||||
return ops.map(op => ({ type: BLOCK_TYPE[op.type], md: op.item.md }));
|
||||
};
|
||||
|
||||
export const hasPendingChanges = article =>
|
||||
article?.draftTitle != null || article?.draftContent != null;
|
||||
@@ -0,0 +1,19 @@
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
export const setDirectUploadAuthHeaders = xhr => {
|
||||
const {
|
||||
'access-token': accessToken,
|
||||
'token-type': tokenType,
|
||||
client,
|
||||
expiry,
|
||||
uid,
|
||||
} = Auth.getAuthData() || {};
|
||||
|
||||
if (!accessToken) return;
|
||||
|
||||
xhr.setRequestHeader('access-token', accessToken);
|
||||
xhr.setRequestHeader('token-type', tokenType);
|
||||
xhr.setRequestHeader('client', client);
|
||||
xhr.setRequestHeader('expiry', expiry);
|
||||
xhr.setRequestHeader('uid', uid);
|
||||
};
|
||||
@@ -166,6 +166,13 @@ export const LOCALE_MENU_ITEMS = {
|
||||
value: 'customize-content',
|
||||
icon: 'i-lucide-pencil',
|
||||
},
|
||||
selectPopularContent: {
|
||||
label:
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT',
|
||||
action: 'select-popular-content',
|
||||
value: 'select-popular-content',
|
||||
icon: 'i-lucide-sparkles',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
@@ -185,6 +192,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
]),
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
|
||||
];
|
||||
}
|
||||
@@ -193,6 +201,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.publishLocale,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
}
|
||||
@@ -201,6 +210,7 @@ export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.customizeContent,
|
||||
LOCALE_MENU_ITEMS.selectPopularContent,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
renderInlineDiff,
|
||||
buildDiffBlocks,
|
||||
hasPendingChanges,
|
||||
rendersIdentically,
|
||||
} from '../articleDiffHelper';
|
||||
|
||||
describe('articleDiffHelper', () => {
|
||||
describe('renderInlineDiff', () => {
|
||||
it('returns the text unchanged when there is no difference', () => {
|
||||
const result = renderInlineDiff('hello world', 'hello world');
|
||||
expect(result).toBe('hello world');
|
||||
expect(result).not.toContain('<ins');
|
||||
expect(result).not.toContain('<del');
|
||||
});
|
||||
|
||||
it('wraps inserted words in <ins>', () => {
|
||||
const result = renderInlineDiff('hello', 'hello there');
|
||||
expect(result).toContain('hello');
|
||||
expect(result).toContain('<ins');
|
||||
expect(result).toContain('there');
|
||||
});
|
||||
|
||||
it('wraps removed words in <del>', () => {
|
||||
const result = renderInlineDiff('hello there', 'hello');
|
||||
expect(result).toContain('<del');
|
||||
expect(result).toContain('there');
|
||||
});
|
||||
|
||||
it('keeps a single removal contiguous when a word repeats', () => {
|
||||
const result = renderInlineDiff(
|
||||
'How to use Agent bots?',
|
||||
'How How to Agent bots?'
|
||||
);
|
||||
expect(result).toBe(
|
||||
'How <ins class="!bg-n-teal-5 !text-n-teal-12 !no-underline rounded px-0.5">How</ins> to <del class="!bg-n-ruby-5 !text-n-ruby-12 !line-through rounded px-0.5">use</del> Agent bots?'
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes markup when diffing plain text', () => {
|
||||
const result = renderInlineDiff('a', 'a <b>');
|
||||
expect(result).toContain('<b>');
|
||||
expect(result).not.toContain('<b>');
|
||||
});
|
||||
|
||||
it('treats a cleared empty string as a full deletion', () => {
|
||||
const result = renderInlineDiff('gone', '');
|
||||
expect(result).toContain('<del');
|
||||
expect(result).toContain('gone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDiffBlocks', () => {
|
||||
it('passes an unchanged block through as equal', () => {
|
||||
const blocks = buildDiffBlocks('same para', 'same para');
|
||||
expect(blocks).toEqual([{ type: 'equal', md: 'same para' }]);
|
||||
});
|
||||
|
||||
it('marks an appended block as added', () => {
|
||||
const blocks = buildDiffBlocks('a', 'a\n\nb');
|
||||
expect(blocks).toContainEqual({ type: 'equal', md: 'a' });
|
||||
expect(blocks).toContainEqual({ type: 'added', md: 'b' });
|
||||
});
|
||||
|
||||
it('marks a deleted block as removed', () => {
|
||||
const blocks = buildDiffBlocks('a\n\nb', 'a');
|
||||
expect(blocks).toContainEqual({ type: 'removed', md: 'b' });
|
||||
});
|
||||
|
||||
it('emits the old block then the new block for a reworded section', () => {
|
||||
const blocks = buildDiffBlocks('hello world', 'hello there');
|
||||
expect(blocks).toEqual([
|
||||
{ type: 'removed', md: 'hello world' },
|
||||
{ type: 'added', md: 'hello there' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a fenced code block whole when it contains blank lines', () => {
|
||||
const code = '```\nline one\n\nline two\n```';
|
||||
const blocks = buildDiffBlocks(code, code);
|
||||
expect(blocks).toEqual([{ type: 'equal', md: code }]);
|
||||
});
|
||||
|
||||
it('diffs an edited code block as one whole removed + added block', () => {
|
||||
const live = '```\ncode line\n```';
|
||||
const draft = '```\ncode line\n\nsd\n```';
|
||||
const blocks = buildDiffBlocks(live, draft);
|
||||
expect(blocks).toContainEqual({ type: 'removed', md: live });
|
||||
expect(blocks).toContainEqual({ type: 'added', md: draft });
|
||||
});
|
||||
|
||||
it('surfaces whitespace edits that change the rendered output', () => {
|
||||
expect(
|
||||
buildDiffBlocks('```\nx\n```', '```\n x\n```').some(
|
||||
block => block.type !== 'equal'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
buildDiffBlocks('line one\nline two', 'line one \nline two').some(
|
||||
block => block.type !== 'equal'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('surfaces an indented code block turning into a paragraph', () => {
|
||||
const blocks = buildDiffBlocks(
|
||||
' curl example.com',
|
||||
'curl example.com'
|
||||
);
|
||||
expect(blocks).toContainEqual({
|
||||
type: 'removed',
|
||||
md: ' curl example.com',
|
||||
});
|
||||
expect(blocks).toContainEqual({ type: 'added', md: 'curl example.com' });
|
||||
});
|
||||
|
||||
it('keeps spacing the renderer ignores as equal', () => {
|
||||
const blocks = buildDiffBlocks('a\nb', 'a \nb');
|
||||
expect(blocks.every(block => block.type === 'equal')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a loose list with item descriptions as one block', () => {
|
||||
const list =
|
||||
'1. **One**\n\n First item.\n\n2. **Two**\n\n Second item.';
|
||||
const blocks = buildDiffBlocks(list, list);
|
||||
expect(blocks).toEqual([{ type: 'equal', md: list }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rendersIdentically', () => {
|
||||
it('ignores blank-line / empty-paragraph differences', () => {
|
||||
expect(rendersIdentically('a\n\nb', 'a\n\n\nb')).toBe(true);
|
||||
expect(rendersIdentically('hello', 'hello\n\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('counts code-block indentation changes', () => {
|
||||
expect(rendersIdentically('```\n x\n```', '```\nx\n```')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts smart vs straight quotes (no typographer)', () => {
|
||||
expect(rendersIdentically('"hi"', '“hi”')).toBe(false);
|
||||
});
|
||||
|
||||
it('counts real text changes', () => {
|
||||
expect(rendersIdentically('hello world', 'hello there')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats nullish input as empty', () => {
|
||||
expect(rendersIdentically(null, '')).toBe(true);
|
||||
expect(rendersIdentically(undefined, 'x')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPendingChanges', () => {
|
||||
it('is true when a draft title or content is staged', () => {
|
||||
expect(hasPendingChanges({ draftContent: 'edit' })).toBe(true);
|
||||
expect(hasPendingChanges({ draftTitle: 'edit' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a cleared empty-string draft as a pending change', () => {
|
||||
expect(hasPendingChanges({ draftTitle: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false with no draft columns', () => {
|
||||
expect(hasPendingChanges({ title: 'live' })).toBe(false);
|
||||
expect(hasPendingChanges({})).toBe(false);
|
||||
expect(hasPendingChanges(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { setDirectUploadAuthHeaders } from '../directUploadsHelper';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
|
||||
vi.mock('dashboard/api/auth', () => ({
|
||||
default: { getAuthData: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('setDirectUploadAuthHeaders', () => {
|
||||
const buildXhr = () => ({ setRequestHeader: vi.fn() });
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sets the five session auth headers from the auth cookie', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
'access-token': 'token-123',
|
||||
'token-type': 'Bearer',
|
||||
client: 'client-123',
|
||||
expiry: '9999',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledTimes(5);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'access-token',
|
||||
'token-123'
|
||||
);
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('token-type', 'Bearer');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('client', 'client-123');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('expiry', '9999');
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith(
|
||||
'uid',
|
||||
'agent@example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not set any header when there is no auth data', () => {
|
||||
Auth.getAuthData.mockReturnValue(false);
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not set any header when the access token is missing', () => {
|
||||
Auth.getAuthData.mockReturnValue({
|
||||
client: 'client-123',
|
||||
uid: 'agent@example.com',
|
||||
});
|
||||
const xhr = buildXhr();
|
||||
|
||||
setDirectUploadAuthHeaders(xhr);
|
||||
|
||||
expect(xhr.setRequestHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -74,26 +74,34 @@ describe('PortalHelper', () => {
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('disables other actions but keeps customize enabled for the default locale', () => {
|
||||
it('disables other actions but keeps content actions enabled for the default locale', () => {
|
||||
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
|
||||
const customize = items.find(item => item.action === 'customize-content');
|
||||
const enabledActions = ['customize-content', 'select-popular-content'];
|
||||
|
||||
expect(customize).toBeTruthy();
|
||||
expect(customize.disabled).toBeFalsy();
|
||||
enabledActions.forEach(action => {
|
||||
expect(
|
||||
items.find(item => item.action === action)?.disabled
|
||||
).toBeFalsy();
|
||||
});
|
||||
expect(
|
||||
items
|
||||
.filter(item => item.action !== 'customize-content')
|
||||
.filter(item => !enabledActions.includes(item.action))
|
||||
.every(item => item.disabled)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns publish, customize, and delete actions for draft locales', () => {
|
||||
it('returns publish, customize, popular content, and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'customize-content', 'delete']);
|
||||
).toEqual([
|
||||
'publish-locale',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns default, draft, customize, and delete actions for live locales', () => {
|
||||
@@ -106,6 +114,7 @@ describe('PortalHelper', () => {
|
||||
'change-default',
|
||||
'move-to-draft',
|
||||
'customize-content',
|
||||
'select-popular-content',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user