diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index 5af9d2e4..e3b49efa 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -3,6 +3,7 @@ import { APIMATIC_CONFIG_FILE_NAME } from '../../types/apimatic-config/document. import { DirectoryPath } from '../../types/file/directoryPath.js'; import { listedInProse } from '../../utils/string-utils.js'; import { ContentNotices, NoTabsListed } from '../../types/portal/content-notices.js'; +import { LeftOutHtml } from '../../types/portal/left-out-html.js'; import { API_REFERENCE_TOKEN, NAVIGATION_FILE_NAME, TabEntry } from '../../types/portal/portal-navigation.js'; import { ContentProblem, @@ -430,6 +431,7 @@ export function reportIgnoredNavigationFiles(files: FilePath[], sourceDirectory: } export function reportContentNotices(notices: ContentNotices, sourceDirectory: DirectoryPath): void { + reportLeftOutHtml(notices.leftOutHtml, sourceDirectory); reportHiddenPages(notices.hiddenPages, sourceDirectory); reportIgnoredNavigationFiles(notices.ignoredNavigationFiles, sourceDirectory); reportSharedTabNames(notices.sharedTabNames, sourceDirectory); @@ -513,6 +515,28 @@ export function reportSharedTabNames(shared: SharedTabName[], sourceDirectory: D ); } +export function reportLeftOutHtml(pages: LeftOutHtml[], sourceDirectory: DirectoryPath): void { + if (pages.length === 0) { + return; + } + const one = pages.length === 1; + const lines = pages.flatMap((html) => { + const page = relative(html.page, sourceDirectory); + const shown = html.shown().map(({ line, text }) => ` • ${page}, line ${line}: ${f.var(text)}`); + const unshown = html.unshown(); + return unshown === 0 ? shown : [...shown, ` and ${unshown} more on this page`]; + }); + const holding = one + ? `A ${f.var('.md')} page in ${f.path(sourceDirectory)} has` + : `Some ${f.var('.md')} pages in ${f.path(sourceDirectory)} have`; + log.warn(`${holding} HTML or components, which the portal leaves out:`); + log.message(lines.join('\n')); + log.message( + `Rename ${one ? 'the page' : 'each page'} to ${f.var('.mdx')} to keep them. MDX needs empty tags closed, ` + + `such as ${f.var('
')}, and comments written as ${f.var('{/* … */}')}.` + ); +} + export function reportHiddenPages(files: FilePath[], sourceDirectory: DirectoryPath): void { if (files.length === 0) { return; diff --git a/src/types/portal/content-notices.ts b/src/types/portal/content-notices.ts index 39f09bff..4ebaf2f2 100644 --- a/src/types/portal/content-notices.ts +++ b/src/types/portal/content-notices.ts @@ -1,4 +1,5 @@ import { FilePath } from '../file/filePath.js'; +import { LeftOutHtml } from './left-out-html.js'; import { TabEntry } from './portal-navigation.js'; import { SharedTabName } from './portal-tabs.js'; @@ -11,6 +12,8 @@ export interface NoTabsListed { /** What a build accepts in `content/`, but the user should hear of. */ export interface ContentNotices { + /** Warned of rather than refused: the rest of the page builds, and one stray `
` should not stop a build. */ + leftOutHtml: LeftOutHtml[]; /** * Pages below `content/api//` for a specification ``. The section's generated * metadata lists only the reference pages, so these never appear in the sidebar. diff --git a/src/types/portal/content-tree.ts b/src/types/portal/content-tree.ts index 655c5d95..f1ea5122 100644 --- a/src/types/portal/content-tree.ts +++ b/src/types/portal/content-tree.ts @@ -6,6 +6,7 @@ import { FileName } from '../file/fileName.js'; import { FilePath } from '../file/filePath.js'; import { ContentNotices } from './content-notices.js'; import { GENERATED_SECTIONS, GeneratedPages } from './generated-pages.js'; +import { LeftOutHtml } from './left-out-html.js'; import { ParsedPage } from './page.js'; import { API_REFERENCE_NAME, @@ -190,6 +191,9 @@ export class ContentTree { const rootNavigation = navigation.root.navigation; const unplaced = ContentTree.unplaced(rootNavigation?.settings, generatedPages); return ok({ + leftOutHtml: read.pages.flatMap(({ file, parsed }) => + parsed === undefined || parsed.leftOutHtml.length === 0 ? [] : [new LeftOutHtml(file, parsed.leftOutHtml)] + ), hiddenPages: ContentTree.hiddenPages(pages, specs), ignoredNavigationFiles: navigation.ignoredFiles, sharedTabNames: sharedTabNames(tabs), diff --git a/src/types/portal/left-out-html.ts b/src/types/portal/left-out-html.ts new file mode 100644 index 00000000..6a77cee8 --- /dev/null +++ b/src/types/portal/left-out-html.ts @@ -0,0 +1,37 @@ +import { FilePath } from '../file/filePath.js'; +import { HtmlPiece } from './page.js'; + +// Renaming the page keeps all of it, so a few pieces say enough. +const SHOWN = 3; +// Enough to find a piece by, where its line can run to a whole paragraph. +const SHOWN_LENGTH = 60; + +/** The HTML a `.md` page holds, which the build leaves out: a `.md` page is Markdown without HTML or components. */ +export class LeftOutHtml { + constructor(public readonly page: FilePath, private readonly pieces: HtmlPiece[]) {} + + public shown(): HtmlPiece[] { + return this.distinct() + .slice(0, SHOWN) + .map(({ line, text }) => ({ + line, + text: text.length > SHOWN_LENGTH ? `${text.slice(0, SHOWN_LENGTH - 1)}…` : text + })); + } + + public unshown(): number { + return Math.max(this.distinct().length - SHOWN, 0); + } + + // By what is written, not where, or every edit above a piece would give the notice again. + public addsNothingTo(earlier: LeftOutHtml): boolean { + return ( + this.page.isEqual(earlier.page) && + this.pieces.every(({ text }) => earlier.pieces.some((piece) => piece.text === text)) + ); + } + + private distinct(): HtmlPiece[] { + return this.pieces.filter((piece, index) => this.pieces.findIndex(({ text }) => text === piece.text) === index); + } +} diff --git a/src/types/portal/page.ts b/src/types/portal/page.ts index bb0bb21c..a59adb0a 100644 --- a/src/types/portal/page.ts +++ b/src/types/portal/page.ts @@ -22,10 +22,18 @@ export interface PageImage { path: string; } -/** A page as the build parses it: its front matter held to the build's schema, and the images the build imports. */ +/** A piece of HTML in a `.md` page, by the line it starts on and that line as written. */ +export interface HtmlPiece { + line: number; + text: string; +} + +/** A page as the build parses it. */ export interface ParsedPage { frontMatter: Result; images: PageImage[]; + /** None in an `.mdx` page, which renders its HTML. */ + leftOutHtml: HtmlPiece[]; } type SchemaIssue = NonNullable['error']>['issues'][number]; @@ -38,6 +46,7 @@ interface MarkdownNode { identifier?: string; name?: string | null; attributes?: { type: string; name?: string; value?: unknown }[]; + value?: string; position?: { start: { line: number } }; children?: MarkdownNode[]; } @@ -45,6 +54,10 @@ interface MarkdownNode { // A scheme, a drive letter or a network path: none of them names a file the build imports. const NOT_A_FILE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i; +// As CommonMark reads one, where `` and `` are whole comments. +const COMMENT = /|$))/g; +const CLOSING_TAG = /<\/[a-z][a-z\d-]*\s*>/gi; + /** Parsed once, as the build parses it, `.mdx` as MDX; a body that does not parse is the build's to report. */ export async function parsePage(contents: string, fileName: FileName, label: string): Promise { let parsed: { matter: string; data: unknown }; @@ -52,13 +65,21 @@ export async function parsePage(contents: string, fileName: FileName, label: str parsed = frontmatter(contents); } catch (error) { const reason = errorMessage(error).split('\n')[0]; - return { frontMatter: err([`${label}: its front matter is not valid YAML. ${reason}`]), images: [] }; + return { + frontMatter: err([`${label}: its front matter is not valid YAML. ${reason}`]), + images: [], + leftOutHtml: [] + }; } // Blanked rather than cut, so every line keeps its number. const body = '\n'.repeat(parsed.matter.split('\n').length - 1) + contents.slice(parsed.matter.length); + const isMdx = fileName.hasExactExtension('.mdx'); + const tree = await parsedBody(body, isMdx); + const nodes = tree === null ? [] : nodesOf(tree); return { frontMatter: await checkedFrontMatter(parsed, label), - images: await imagesIn(body, fileName.hasExactExtension('.mdx')) + images: imagesIn(nodes), + leftOutHtml: isMdx ? [] : htmlIn(nodes) }; } @@ -106,39 +127,43 @@ function titleError(title: unknown, label: string): string { return `${label}: 'title' must be text. Put it in quotes if it looks like a number or true or false.`; } -async function imagesIn(body: string, isMdx: boolean): Promise { +async function parsedBody(body: string, isMdx: boolean): Promise { // On first use, as the schema is: most commands never read a page. const { remark } = await import('remark'); const processor = isMdx ? remark().use((await import('remark-mdx')).default) : remark(); - let tree: MarkdownNode; try { - tree = processor.parse(body) as MarkdownNode; + return processor.parse(body) as MarkdownNode; } catch { - return []; + return null; } +} + +function nodesOf(node: MarkdownNode): MarkdownNode[] { + return [node, ...(node.children ?? []).flatMap(nodesOf)]; +} +function imagesIn(nodes: MarkdownNode[]): PageImage[] { // The template writes `![a][ref]` as `![a](url)` before the build imports it (remark-image-references.ts). const definitions = new Map(); - const define = (node: MarkdownNode) => { + for (const node of nodes) { // The first definition of a label wins, as CommonMark reads them. if (node.type === 'definition' && node.identifier !== undefined && !definitions.has(node.identifier)) { definitions.set(node.identifier, node.url ?? ''); } - node.children?.forEach(define); - }; - define(tree); - - const images: PageImage[] = []; - const visit = (node: MarkdownNode) => { + } + return nodes.flatMap((node) => { const url = addressOf(node, definitions); const image = url !== undefined ? located(url) : null; - if (image !== null) { - images.push({ ...image, line: node.position?.start.line ?? 0 }); - } - node.children?.forEach(visit); - }; - visit(tree); - return images; + return image === null ? [] : [{ ...image, line: node.position?.start.line ?? 0 }]; + }); +} + +function htmlIn(nodes: MarkdownNode[]): HtmlPiece[] { + // Comments and closing tags leave out nothing a reader would see. + const shows = (html: string) => html.replace(COMMENT, '').replace(CLOSING_TAG, '').trim() !== ''; + return nodes.flatMap(({ type, value = '', position }) => + type === 'html' && shows(value) ? [{ line: position?.start.line ?? 0, text: value.split('\n')[0].trim() }] : [] + ); } function addressOf(node: MarkdownNode, definitions: Map): string | undefined { diff --git a/src/types/portal/preview-content.ts b/src/types/portal/preview-content.ts index a1a5ab28..58fba69d 100644 --- a/src/types/portal/preview-content.ts +++ b/src/types/portal/preview-content.ts @@ -33,6 +33,9 @@ export class PreviewContent { private static since(current: ContentNotices, previous: ContentNotices): ContentNotices { return { + leftOutHtml: whenAnyNew(current.leftOutHtml, previous.leftOutHtml, (html, earlier) => + html.addsNothingTo(earlier) + ), hiddenPages: whenAnyNew(current.hiddenPages, previous.hiddenPages, (left, right) => left.isEqual(right)), ignoredNavigationFiles: whenAnyNew( current.ignoredNavigationFiles, diff --git a/test/infrastructure/portal-project-service.test.ts b/test/infrastructure/portal-project-service.test.ts index 5724fe4c..e51f0aa2 100644 --- a/test/infrastructure/portal-project-service.test.ts +++ b/test/infrastructure/portal-project-service.test.ts @@ -68,6 +68,7 @@ describe('PortalProjectService', () => { staticDirectory: null, shadowedFiles: [], contentNotices: { + leftOutHtml: [], hiddenPages: [], ignoredNavigationFiles: [], sharedTabNames: [], diff --git a/test/prompts/portal/serve.test.ts b/test/prompts/portal/serve.test.ts index 80fc1f29..332c6d2a 100644 --- a/test/prompts/portal/serve.test.ts +++ b/test/prompts/portal/serve.test.ts @@ -200,6 +200,7 @@ describe('PortalServePrompts', () => { describe('a save the preview accepts', () => { const NO_NOTICES: ContentNotices = { + leftOutHtml: [], hiddenPages: [], ignoredNavigationFiles: [], sharedTabNames: [], diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index dfdd1c74..9a2d3ae9 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -4,6 +4,7 @@ import sinon from 'sinon'; import { log } from '@clack/prompts'; import { PortalServePrompts } from '../../../src/prompts/portal/serve.js'; import { + reportLeftOutHtml, reportLeftOutSpecs, reportNoTabsListed, reportShadowedFiles, @@ -17,8 +18,20 @@ import { DirectoryPath } from '../../../src/types/file/directoryPath.js'; import { FileName } from '../../../src/types/file/fileName.js'; import { FilePath } from '../../../src/types/file/filePath.js'; import { PLUGIN_SECTION, SDK_SECTION } from '../../../src/types/portal/generated-pages.js'; +import { LeftOutHtml } from '../../../src/types/portal/left-out-html.js'; import { OAUTH_CALLBACK } from '../../../src/types/portal/content-tree.js'; +/** What `log.warn` and `log.message` print from here on, without colours, until `sinon.restore`. */ +function recordedLines(): string[] { + const lines: string[] = []; + const record = (text?: string | string[]) => { + lines.push(stripVTControlCharacters(String(text))); + }; + sinon.stub(log, 'warn').callsFake(record); + sinon.stub(log, 'message').callsFake(record); + return lines; +} + describe('reportSourceProblem', () => { const source = new DirectoryPath('project').join('src'); let error: sinon.SinonStub; @@ -592,12 +605,7 @@ describe('reportSharedTabNames', () => { const printed = () => lines.join('\n'); beforeEach(() => { - lines = []; - const record = (text?: string | string[]) => { - lines.push(stripVTControlCharacters(String(text))); - }; - sinon.stub(log, 'warn').callsFake(record); - sinon.stub(log, 'message').callsFake(record); + lines = recordedLines(); }); afterEach(() => { @@ -853,3 +861,65 @@ describe('reportLeftOutSpecs', () => { ]); }); }); + +describe('reportLeftOutHtml', () => { + const source = new DirectoryPath('project').join('src'); + const index = new FilePath(source.join('content'), new FileName('index.md')); + const intro = new FilePath(source.join('content', 'guides'), new FileName('intro.md')); + let lines: string[]; + + beforeEach(() => { + lines = recordedLines(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('says nothing when no .md page has HTML', () => { + reportLeftOutHtml([], source); + + expect(lines).to.deep.equal([]); + }); + + it('names the HTML of a page with its lines, and how to keep it', () => { + reportLeftOutHtml( + [ + new LeftOutHtml(index, [ + { line: 5, text: '
' }, + { line: 9, text: '' } + ]) + ], + source + ); + + expect(lines.join('\n').split('\n')).to.deep.equal([ + `A '.md' page in '${source.toString()}' has HTML or components, which the portal leaves out:`, + " • 'content/index.md', line 5: '
'", + " • 'content/index.md', line 9: ''", + "Rename the page to '.mdx' to keep them. MDX needs empty tags closed, such as '
', and comments written " + + "as '{/* … */}'." + ]); + }); + + it('counts what a page does not show', () => { + const pieces = ['
', '
', '', '', ''].map((text, index) => ({ line: index + 1, text })); + + reportLeftOutHtml([new LeftOutHtml(index, pieces)], source); + + expect(lines[1].split('\n').at(-1)).to.equal(' and 2 more on this page'); + }); + + it('speaks of each page when there are several', () => { + reportLeftOutHtml( + [new LeftOutHtml(index, [{ line: 5, text: '
' }]), new LeftOutHtml(intro, [{ line: 3, text: '' }])], + source + ); + + expect(lines[0]).to.equal( + `Some '.md' pages in '${source.toString()}' have HTML or components, which the portal leaves out:` + ); + expect(lines[1]).to.contain("'content/guides/intro.md', line 3: ''"); + expect(lines[2]).to.match(/^Rename each page to '\.mdx'/); + }); +}); diff --git a/test/types/portal/content-tree.test.ts b/test/types/portal/content-tree.test.ts index f25ecc67..1c3fd13b 100644 --- a/test/types/portal/content-tree.test.ts +++ b/test/types/portal/content-tree.test.ts @@ -5,6 +5,7 @@ import { FileName } from '../../../src/types/file/fileName'; import { FilePath } from '../../../src/types/file/filePath'; import { ContentFile, ContentTree, ReadPage } from '../../../src/types/portal/content-tree'; import { GeneratedPages } from '../../../src/types/portal/generated-pages'; +import { LeftOutHtml } from '../../../src/types/portal/left-out-html'; import { parsePage } from '../../../src/types/portal/page'; import { PortalLanguages } from '../../../src/types/portal/portal-languages'; @@ -99,6 +100,21 @@ describe('ContentTree', () => { expect(problems[0]).to.deep.equal({ kind: 'missingImages', images: [image] }); }); + it('names each .md page with HTML, and its HTML', async () => { + const tagged = await parsed( + [inContent('index.md'), '---\ntitle: Home\n---\n\nLine one
line two.\n'], + [inContent('guides', 'intro.mdx'), '---\ntitle: Intro\n---\n\nLine one
line two.\n'] + ); + const navigationFiles = handed( + [inContent('nav.json'), JSON.stringify({ tabs: ['guides'], pages: ['index'] })], + [inContent('guides', 'nav.json'), JSON.stringify({ title: 'Guides' })] + ); + + const notices = tree.check({ pages: tagged, navigationFiles, missingImages: [] }, [], GENERATED)._unsafeUnwrap(); + + expect(notices.leftOutHtml).to.deep.equal([new LeftOutHtml(inContent('index.md'), [{ line: 5, text: '
' }])]); + }); + it('names the tabs from what it is handed', () => { const navigationFiles = handed( [inContent('nav.json'), JSON.stringify({ title: 'Guides', tabs: ['guides'], pages: ['index'] })], diff --git a/test/types/portal/left-out-html.test.ts b/test/types/portal/left-out-html.test.ts new file mode 100644 index 00000000..a7669bad --- /dev/null +++ b/test/types/portal/left-out-html.test.ts @@ -0,0 +1,45 @@ +import { expect } from 'chai'; +import { DirectoryPath } from '../../../src/types/file/directoryPath'; +import { FileName } from '../../../src/types/file/fileName'; +import { FilePath } from '../../../src/types/file/filePath'; +import { LeftOutHtml } from '../../../src/types/portal/left-out-html'; + +describe('LeftOutHtml', () => { + const content = new DirectoryPath('src', 'content'); + const index = new FilePath(content, new FileName('index.md')); + const intro = new FilePath(content.join('guides'), new FileName('intro.md')); + const piece = (line: number, text: string) => ({ line, text }); + + it('shows each piece once, where the page first has it, up to three, and counts the rest', () => { + const html = new LeftOutHtml(index, [ + piece(5, '
'), + piece(9, '
'), + piece(12, '
'), + piece(20, ''), + piece(24, '
'), + piece(30, ''), + piece(33, '') + ]); + + expect(html.shown()).to.deep.equal([piece(5, '
'), piece(12, '
'), piece(20, '')]); + expect(html.unshown()).to.equal(2); + }); + + it('cuts a long piece short', () => { + const html = new LeftOutHtml(index, [piece(5, `

${'word '.repeat(20)}

`), piece(9, '
')]); + + expect(html.shown()).to.deep.equal([piece(5, `

${'word '.repeat(8)}w…`), piece(9, '
')]); + }); + + it('leaves nothing unshown on a page with three pieces or fewer', () => { + expect(new LeftOutHtml(index, [piece(5, '
'), piece(9, '
')]).unshown()).to.equal(0); + }); + + it('adds nothing to the same page holding the same pieces, wherever they now are', () => { + const before = new LeftOutHtml(index, [piece(5, '
'), piece(9, '

')]); + + expect(new LeftOutHtml(index, [piece(7, '
'), piece(12, '
')]).addsNothingTo(before)).to.be.true; + expect(new LeftOutHtml(index, [piece(7, '
'), piece(14, '')]).addsNothingTo(before)).to.be.false; + expect(new LeftOutHtml(intro, [piece(5, '
')]).addsNothingTo(before)).to.be.false; + }); +}); diff --git a/test/types/portal/page.test.ts b/test/types/portal/page.test.ts index d4fbf7a9..a975fae2 100644 --- a/test/types/portal/page.test.ts +++ b/test/types/portal/page.test.ts @@ -182,4 +182,49 @@ describe('parsePage', () => { expect(await images('\n\nInline here.\n')).to.deep.equal([]); }); }); + + // The build leaves out every piece of HTML in a `.md` page, and the reader loses what it says. + describe('the HTML of a .md page', () => { + const html = async (markdown: string, name?: string) => (await page(markdown, name)).leftOutHtml; + + it('names each piece of HTML by the line it starts on, as written', async () => { + const markdown = + '---\ntitle: Tags\n---\n\nLine one
line two.\n\n
\nMore\n\nHidden.\n\n' + + '
\n\n\nKeep it secret.\n\n'; + + expect(await html(markdown)).to.deep.equal([ + { line: 5, text: '
' }, + { line: 7, text: '
' }, + { line: 14, text: '' } + ]); + }); + + it('names a piece that leaves text out after a closing tag or a comment', async () => { + const markdown = + '\nThis line is left out too.\n\n And this one.\n\n After an empty comment\n'; + + expect(await html(markdown)).to.deep.equal([ + { line: 1, text: '' }, + { line: 4, text: ' And this one.' }, + { line: 6, text: ' After an empty comment' } + ]); + }); + + it('leaves out a piece of nothing but comments and closing tags', async () => { + const markdown = + '\n\nText here.\n\n
\n\nPress Ctrl.\n\n\n'; + + expect(await html(markdown)).to.deep.equal([{ line: 7, text: '' }]); + }); + + it('reads code, inline code and an autolink as the text they are', async () => { + const markdown = '```html\n
\n```\n\nWrite `
` for a break, or see .\n'; + + expect(await html(markdown)).to.deep.equal([]); + }); + + it('names none in an .mdx page, which renders its HTML', async () => { + expect(await html('Line one
line two.\n\n
\n\nText.\n\n
\n', 'page.mdx')).to.deep.equal([]); + }); + }); }); diff --git a/test/types/portal/preview-content.test.ts b/test/types/portal/preview-content.test.ts index 9031268c..645fb0ce 100644 --- a/test/types/portal/preview-content.test.ts +++ b/test/types/portal/preview-content.test.ts @@ -4,6 +4,7 @@ import { FileName } from '../../../src/types/file/fileName'; import { FilePath } from '../../../src/types/file/filePath'; import { ContentNotices } from '../../../src/types/portal/content-notices'; import { GeneratedSection, PLUGIN_SECTION, SDK_SECTION } from '../../../src/types/portal/generated-pages'; +import { LeftOutHtml } from '../../../src/types/portal/left-out-html'; import { TabEntry } from '../../../src/types/portal/portal-navigation'; import { PortalTab, SharedTabName } from '../../../src/types/portal/portal-tabs'; import { PreviewContent } from '../../../src/types/portal/preview-content'; @@ -14,6 +15,7 @@ describe('PreviewContent', () => { const folder = (name: string) => content.join(name); const NONE: ContentNotices = { + leftOutHtml: [], hiddenPages: [], ignoredNavigationFiles: [], sharedTabNames: [], @@ -114,6 +116,23 @@ describe('PreviewContent', () => { expect(noticesAfter(titled, titled).unseenHomeTitle).to.be.undefined; }); + it('gives the left-out HTML again when a page has a piece it did not have, not when one moves', () => { + const index = new FilePath(content, new FileName('index.md')); + const before = notices({ leftOutHtml: [new LeftOutHtml(index, [{ line: 5, text: '
' }])] }); + const moved = notices({ leftOutHtml: [new LeftOutHtml(index, [{ line: 9, text: '
' }])] }); + const added = notices({ + leftOutHtml: [ + new LeftOutHtml(index, [ + { line: 9, text: '
' }, + { line: 14, text: '
' } + ]) + ] + }); + + expect(noticesAfter(moved, before).leftOutHtml).to.deep.equal([]); + expect(noticesAfter(added, before).leftOutHtml).to.deep.equal(added.leftOutHtml); + }); + it('gives a name shared by another tab than before', () => { const sdks: PortalTab = { owner: { kind: 'generated', section: SDK_SECTION }, name: 'Guides', namedBy: null }; const plugin: PortalTab = { owner: { kind: 'generated', section: PLUGIN_SECTION }, name: 'Guides', namedBy: null };