diff --git a/packages/ckeditor5-bbcode/__tests__/bbob/Paragraphs.test.ts b/packages/ckeditor5-bbcode/__tests__/bbob/Paragraphs.test.ts new file mode 100644 index 0000000000..95b20106d7 --- /dev/null +++ b/packages/ckeditor5-bbcode/__tests__/bbob/Paragraphs.test.ts @@ -0,0 +1,260 @@ +import { paragraphAwareContent, ParagraphAwareContentOptions } from "../../src/bbob/Paragraphs"; +import { toNode } from "../../src/bbob/TagNodes"; +import { TagNode } from "@bbob/plugin-helper/es"; + +type ContentFixture = NonNullable; + +const p = (content: ContentFixture): TagNode => toNode("p", {}, content); +/** + * Just any inline tag-node. + */ +const b = (content: ContentFixture): TagNode => toNode("b", {}, content); +/** + * Shortcut to add `quote` node. + */ +const q = (content: ContentFixture): TagNode => toNode("quote", {}, content); + +describe(`Paragraphs`, () => { + // ==========================================================================================[ paragraphAwareContent ] + describe(`paragraphAwareContent`, () => { + // -------------------------------------------------------------------------------------------------[ content=null ] + describe(`content=null`, () => { + it(`should return "null" with default options`, () => { + expect(paragraphAwareContent(null)).toBeNull(); + }); + + it(`should return "null" even with "requireParagraph=true"`, () => { + expect(paragraphAwareContent(null, { requireParagraph: true })).toBeNull(); + }); + }); + + // ---------------------------------------------------------------------------------------------------[ content=[] ] + describe(`content=[]`, () => { + it(`should return "[]" with default options`, () => { + expect(paragraphAwareContent([])).toMatchObject([]); + }); + + it(`should return "[]" wrapped in paragraph with "requireParagraph=true"`, () => { + const expected = [toNode("p", {}, [])]; + expect(paragraphAwareContent([], { requireParagraph: true })).toMatchObject(expected); + }); + }); + + // ---------------------------------------------------------------------------------------------[ content=string[] ] + describe(`content=string[]: Content only containing strings without EOL characters`, () => { + it(`should skip extra paragraph (requireParagraphs=default false)`, () => { + const input = ["lorem", "ipsum"]; + expect(paragraphAwareContent(input)).toMatchObject(input); + }); + + it(`should wrap content into paragraph (requireParagraphs=true)`, () => { + const input = ["lorem", "ipsum"]; + const expected = [toNode("p", {}, input)]; + expect(paragraphAwareContent(input, { requireParagraph: true })).toMatchObject(expected); + }); + }); + + // -----------------------------------------------------------------------------------[ content=(string|TagNode)[] ] + describe(`content=(string|TagNode)[]: Content only containing strings and tag-nodes without EOL characters`, () => { + it.each` + input | comment + ${[b(["lorem"]), "ipsum", "dolor"]} | ${"tag-node at start"} + ${["lorem", b(["ipsum"]), "dolor"]} | ${"tag-node in the middle"} + ${["lorem", "ipsum", b(["dolor"])]} | ${"tag-node at the end"} + `( + `[$#] should skip extra paragraph (requireParagraphs=default false, $comment): $input`, + ({ input }: { input: ContentFixture }) => { + expect(paragraphAwareContent(input)).toMatchObject(input); + }, + ); + + it.each` + input | comment + ${[b(["lorem"]), "ipsum", "dolor"]} | ${"tag-node at start"} + ${["lorem", b(["ipsum"]), "dolor"]} | ${"tag-node in the middle"} + ${["lorem", "ipsum", b(["dolor"])]} | ${"tag-node at the end"} + `( + `[$#] should wrap content into paragraph (requireParagraphs=default true, $comment): $input`, + ({ input }: { input: ContentFixture }) => { + const expected = [toNode("p", {}, input)]; + const actual = paragraphAwareContent(input, { requireParagraph: true }); + expect(actual).toMatchObject(expected); + }, + ); + }); + + // ------------------------------------------------------------------------------------------------[ content=EOL[] ] + describe(`content=EOL[]: Content consisting of EOLs only`, () => { + it.each` + input | expected | comment + ${["\n"]} | ${["\n"]} | ${"keep single newline as is"} + ${["\n", "\n"]} | ${["\n"]} | ${"squash newlines, keep at least one"} + ${["\n", "\n", "\n"]} | ${["\n"]} | ${"squash newlines, keep at least one"} + `( + "[$#] should transform from $input to $expected (all defaults): $comment", + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input); + expect(actual).toMatchObject(expected); + }, + ); + + it.each` + input | expected | comment + ${["\n"]} | ${[p([])]} | ${"Design scope: Trim irrelevant newline"} + ${["\n", "\n"]} | ${[p([])]} | ${"Design scope: Trim irrelevant newline"} + ${["\n", "\n", "\n"]} | ${[p([])]} | ${"Design scope: Trim irrelevant newline"} + `( + "[$#] should transform from $input to $expected (requireParagraph=true): $comment", + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const options: ParagraphAwareContentOptions = { requireParagraph: true }; + const actual = paragraphAwareContent(input, options); + expect(actual).toMatchObject(expected); + }, + ); + }); + + // ---------------------------------------------------------------------------------------[ content=(string|EOL)[] ] + describe(`content=(string|EOL)[]: Content only containing strings (including EOL characters)`, () => { + describe("Default Options", () => { + it.each` + input | expected | comment + ${["\n", "ipsum", "dolor"]} | ${["\n", "ipsum", "dolor"]} | ${"keep single EOL at start"} + ${["lorem", "\n", "dolor"]} | ${["lorem", "\n", "dolor"]} | ${"keep single EOL in the middle"} + ${["lorem", "ipsum", "\n"]} | ${["lorem", "ipsum", "\n"]} | ${"keep single EOL at end"} + `( + `[$#] should keep single newline characters ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input); + expect(actual).toMatchObject(expected); + }, + ); + + it.each` + input | expected | comment + ${["\n", "\n", "ipsum", "dolor"]} | ${["\n", "ipsum", "dolor"]} | ${"Design Scope: Squash newlines irrelevant to trigger as-paragraph-behavior."} + ${["lorem", "\n", "\n", "dolor"]} | ${[p(["lorem"]), p(["dolor"])]} | ${"respect EOL above threshold in the middle; ensure, that subsequent text-nodes must also be added to a paragraph"} + ${["lorem", "ipsum", "\n", "\n"]} | ${["lorem", "ipsum", "\n"]} | ${"Design Scope: Squash newlines at the end as irrelevant to trigger as-paragraph-behavior."} + `( + `[$#] should handle consecutive EOL at threshold ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input); + expect(actual).toMatchObject(expected); + }, + ); + }); + + describe("requireParagraph=true", () => { + const options: ParagraphAwareContentOptions = { requireParagraph: true }; + + it.each` + input | expected | comment + ${["\n", "ipsum", "dolor"]} | ${[p(["\n", "ipsum", "dolor"])]} | ${"keep single EOL at start"} + ${["lorem", "\n", "dolor"]} | ${[p(["lorem", "\n", "dolor"])]} | ${"keep single EOL in the middle"} + ${["lorem", "ipsum", "\n"]} | ${[p(["lorem", "ipsum"])]} | ${"trim EOL at end"} + `( + `[$#] should keep single newline characters ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input, options); + expect(actual).toMatchObject(expected); + }, + ); + + it.each` + input | expected | comment + ${["\n", "\n", "ipsum", "dolor"]} | ${[p(["\n", "ipsum", "dolor"])]} | ${"Design Scope: Squash newlines irrelevant to trigger as-paragraph-behavior."} + ${["lorem", "\n", "\n", "dolor"]} | ${[p(["lorem"]), p(["dolor"])]} | ${"respect EOL above threshold in the middle; ensure, that subsequent text-nodes must also be added to a paragraph"} + ${["lorem", "ipsum", "\n", "\n"]} | ${[p(["lorem", "ipsum"])]} | ${"trim EOL at end"} + `( + `[$#] should handle consecutive EOL at threshold ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input, options); + expect(actual).toMatchObject(expected); + }, + ); + }); + }); + + // -------------------------------------------------------------------------------[ content=(string|TagNode|EOL)[] ] + describe(`content=(string|TagNode|EOL)[]: Content containing anything (including EOL characters)`, () => { + describe("Default Options", () => { + it.each` + input | expected | comment + ${["\n", b(["ipsum"]), "dolor"]} | ${["\n", b(["ipsum"]), "dolor"]} | ${"keep single EOL at start"} + ${[b(["lorem"]), "\n", "dolor"]} | ${[b(["lorem"]), "\n", "dolor"]} | ${"keep single EOL in the middle"} + ${["lorem", b(["ipsum"]), "\n"]} | ${["lorem", b(["ipsum"]), "\n"]} | ${"keep single EOL at end"} + `( + `[$#] should keep single newline characters ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input); + expect(actual).toMatchObject(expected); + }, + ); + + it.each` + input | expected | comment + ${["\n", "\n", b(["ipsum"]), "dolor"]} | ${["\n", b(["ipsum"]), "dolor"]} | ${"Design Scope: Squash newlines irrelevant to trigger as-paragraph-behavior."} + ${[b(["lorem"]), "\n", "\n", "dolor"]} | ${[p([b(["lorem"])]), p(["dolor"])]} | ${"respect EOL above threshold in the middle; ensure, that subsequent text-nodes must also be added to a paragraph"} + ${["lorem", b(["ipsum"]), "\n", "\n"]} | ${["lorem", b(["ipsum"]), "\n"]} | ${"Design Scope: Squash newlines at the end as irrelevant to trigger as-paragraph-behavior."} + `( + `[$#] should handle consecutive EOL at threshold ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input); + console.debug("DEBUG", { + input: JSON.stringify(input), + actual: JSON.stringify(actual), + expected: JSON.stringify(expected), + }); + expect(actual).toMatchObject(expected); + }, + ); + }); + + describe("requireParagraph=true", () => { + const options: ParagraphAwareContentOptions = { requireParagraph: true }; + + it.each` + input | expected | comment + ${["\n", b(["ipsum"]), "dolor"]} | ${[p(["\n", b(["ipsum"]), "dolor"])]} | ${"keep single EOL at start"} + ${[b(["lorem"]), "\n", "dolor"]} | ${[p([b(["lorem"]), "\n", "dolor"])]} | ${"keep single EOL in the middle"} + ${["lorem", b(["ipsum"]), "\n"]} | ${[p(["lorem", b(["ipsum"])])]} | ${"trim EOL at end"} + `( + `[$#] should keep single newline characters ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input, options); + expect(actual).toMatchObject(expected); + }, + ); + + it.each` + input | expected | comment + ${["\n", "\n", b(["ipsum"]), "dolor"]} | ${[p(["\n", b(["ipsum"]), "dolor"])]} | ${"Design Scope: Squash newlines irrelevant to trigger as-paragraph-behavior."} + ${[b(["lorem"]), "\n", "\n", "dolor"]} | ${[p([b(["lorem"])]), p(["dolor"])]} | ${"respect EOL above threshold in the middle; ensure, that subsequent text-nodes must also be added to a paragraph"} + ${["lorem", b(["ipsum"]), "\n", "\n"]} | ${[p(["lorem", b(["ipsum"])])]} | ${"trim EOL at end"} + `( + `[$#] should handle consecutive EOL at threshold ($comment): $input`, + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const actual = paragraphAwareContent(input, options); + expect(actual).toMatchObject(expected); + }, + ); + }); + }); + + // -------------------------------------------------------------------------------------------[ Block Tag Handling ] + describe("Block Tag Handling", () => { + it.each` + input | expected | comment + ${[q(["lorem"])]} | ${[q(["lorem"])]} | ${"single quote block only"} + ${["lorem", q(["ipsum"]), "dolor"]} | ${[p(["lorem"]), q(["ipsum"]), p(["dolor"])]} | ${"add paragraphs only before and after"} + ${["lorem", "\n", "\n", "ipsum", "\n", "\n", q(["dolor"]), "sit", "\n", "\n", "amet"]} | ${[p(["lorem"]), p(["ipsum"]), q(["dolor"]), p(["sit"]), p(["amet"])]} | ${"quote embedded in paragraphs"} + `( + "[$#] should not wrap (default) block tags within paragraphs: $comment", + ({ input, expected }: { input: ContentFixture; expected: ContentFixture }) => { + const options: ParagraphAwareContentOptions = { requireParagraph: true }; + const actual = paragraphAwareContent(input, options); + expect(actual).toMatchObject(expected); + }, + ); + }); + }); +}); diff --git a/packages/ckeditor5-bbcode/src/bbob/Paragraphs.ts b/packages/ckeditor5-bbcode/src/bbob/Paragraphs.ts new file mode 100644 index 0000000000..deec6e7b2e --- /dev/null +++ b/packages/ckeditor5-bbcode/src/bbob/Paragraphs.ts @@ -0,0 +1,258 @@ +import { isEOL, isTagNode, TagNode } from "@bbob/plugin-helper/es"; +import { toNode } from "./TagNodes"; + +/** + * Options for `paragraphAwareContent`. + */ +export interface ParagraphAwareContentOptions { + /** + * If to require a paragraph. This would ensure that a paragraph even + * surrounds plain text-content. Otherwise, for only text-contents, no + * extra paragraph will be added. + * + * Defaults to `false`. + */ + requireParagraph?: boolean; + /** + * Threshold of consecutive newlines that are meant to represent a paragraph. + * Newlines that do not reach this limit will be taken as is to the + * generated content. For standard HTML elements, this means that these + * newlines will be regarded as normal blank characters. + * + * The threshold defaults to `2`. + */ + newlineThreshold?: number; + /** + * Tag names to consider as _block_, which is, that they will not be embedded + * within a `[p]` tag. + */ + blockTags?: TagNode["tag"][]; +} + +/** + * Default tags to consider _block-level_. As child-contents were not processed + * yet, these are raw BBCode elements and not intermediate elements such as + * `blockquote`, that will later be transformed directly to HTML `
`. + */ +const defaultBlockTags: NonNullable = ["quote", "table", "list"]; + +/** + * Processes the top level string nodes and possibly adds a paragraph, where + * required. + * + * Note that processing ignores any possible contained "block-level" elements + * that for valid HTML must not be contained within a paragraph. + * + * **BBob API Note:** The tokenizer will split all newline characters into an + * extra string-entry. This may be important to know to understand the + * implementation of this function. + * + * @param content - content to process + * @param options - options to respect + */ +export const paragraphAwareContent = ( + content: TagNode["content"], + options: ParagraphAwareContentOptions = {}, +): TagNode["content"] => { + const { + requireParagraph: fromConfigRequireParagraph = false, + newlineThreshold = 2, + blockTags = defaultBlockTags, + } = options; + + if (!content) { + // eslint-disable-next-line no-null/no-null + return null; + } + + if (content.length === 0) { + if (fromConfigRequireParagraph) { + return [toNode("p", {}, [])]; + } + return []; + } + + /** + * Flag, if we require adding a paragraph. As soon as we once decided to + * require a paragraph, all following sections will also require to be + * added as paragraph. + */ + let requireParagraph = fromConfigRequireParagraph; + + console.debug("paragraphAwareContent", { + content: JSON.stringify(content), + options: JSON.stringify(options), + }); + + // Intermediate buffer, that may need to go to a paragraph node. + const buffer: NonNullable = []; + // Collected EOLs + const trailingNewlineBuffer: "\n"[] = []; + // The result to return in the end. + const result: NonNullable = []; + + const dumpState = (id = "dumpState") => { + console.debug(id, { + buffer: JSON.stringify(buffer), + trailingNewlineBuffer: JSON.stringify(trailingNewlineBuffer), + result: JSON.stringify(result), + requireParagraph, + }); + }; + + // Clear Buffers. + const clearBuffers = (): void => { + buffer.length = 0; + trailingNewlineBuffer.length = 0; + }; + + /** + * Adds a paragraph and triggers that all subsequent contents also + * are required to be embedded into a paragraph. + * + * The passed content will be added as shallow copy, so that you may + * safely clear any forwarded buffer afterward. + * + * @param content - content to add + */ + const addCopyAsParagraph = (content: NonNullable): void => { + console.debug("addCopyAsParagraph", { + content: JSON.stringify(content), + }); + result.push(toNode("p", {}, [...content])); + requireParagraph = true; + dumpState("addCopyAsParagraph done."); + }; + + /** + * We may have collected yet unprocessed newlines that did not reach the + * given threshold. We must not ignore them completely, as they may + * represent a relevant separator. But it is safe to squash them to one + * single blank character. Just to keep the identity similar, we again use + * a newline character as this squashed result. + */ + const squashAndPushNewlinesToBuffer = (): void => { + console.debug("squashAndPushNewlinesToBuffer", { + trailingNewlineBuffer, + }); + if (trailingNewlineBuffer.length > 0) { + buffer.push("\n"); + // Clear buffer, we respected the newlines. + trailingNewlineBuffer.length = 0; + } + dumpState("squashAndPushNewlinesToBuffer done"); + }; + + /** + * Flush when we processed the last content entry. + */ + const flushFinally = (): void => { + console.debug(`flushFinally`, { + buffer, + trailingNewlineBuffer, + result, + }); + + if (requireParagraph) { + // Design Scope: Ignore any trailing newlines as irrelevant. + // buffer.length > 0: By default, add no empty paragraphs. + // fromConfigRequireParagraph: But, if originally we requested to always + // add a paragraph, do so. It may be a structural requirement. + // For this, we need to check the result-length, though, as otherwise + // we may add an empty paragraph to some already existing content. + if (buffer.length > 0 || (result.length === 0 && fromConfigRequireParagraph)) { + addCopyAsParagraph(buffer); + } + } else { + squashAndPushNewlinesToBuffer(); + result.push(...buffer); + } + + dumpState("flushFinally done"); + }; + + const flush = (): void => { + console.debug(`flush`, { + buffer, + trailingNewlineBuffer, + result, + }); + if (buffer.length > 0) { + // Any trailing EOLs may safely be ignored here. + addCopyAsParagraph(buffer); + } + // Design Scope: Not having an else-branch here also means that we + // ignore any leading newlines. + clearBuffers(); + dumpState("flush done"); + }; + + const flushOnParagraph = (): void => { + if (trailingNewlineBuffer.length >= newlineThreshold) { + console.debug(`flushOnParagraph: reached threshold`, { + trailingNewlineBuffer, + newlineThreshold, + }); + const onlyNewlinesAtStart = 0 === result.length + buffer.length; + if (onlyNewlinesAtStart) { + // This triggers a similar behavior to when we did not reach the + // threshold, i.e., to keep leading newlines, but squash them. + squashAndPushNewlinesToBuffer(); + } else { + // The previously stored entries represent a paragraph. + // Let's create a new paragraph node. + flush(); + } + } else { + console.debug(`flushOnParagraph: threshold not reached`, { + trailingNewlineBuffer, + newlineThreshold, + }); + squashAndPushNewlinesToBuffer(); + } + dumpState("flushOnParagraph done"); + }; + + const handleTagNode = (node: TagNode): void => { + const { tag } = node; + if (blockTags.includes(tag)) { + console.debug(`handleTagNode (as block tag): ${JSON.stringify(node)}`); + // If it is a block-tag, put previous contents in a paragraph. + flush(); + result.push(node); + } else { + console.debug(`handleTagNode (standard behavior): ${JSON.stringify(node)}`); + flushOnParagraph(); + buffer.push(node); + } + dumpState("handleTagNode done"); + }; + + const handleString = (value: string): void => { + if (isEOL(value)) { + console.debug(`handleString (as EOL): ${JSON.stringify(value)}`); + trailingNewlineBuffer.push(value); + } else { + console.debug(`handleString (as normal string): ${JSON.stringify(value)}`); + flushOnParagraph(); + buffer.push(value); + } + dumpState("handleString done"); + }; + + for (const contentItem of content) { + console.debug(`loop: ${JSON.stringify(contentItem)}`); + if (isTagNode(contentItem)) { + handleTagNode(contentItem); + } else { + handleString(contentItem); + } + dumpState(`loop done: ${JSON.stringify(contentItem)}`); + } + + flushFinally(); + + dumpState("paragraphAwareContent done"); + + return result; +}; diff --git a/packages/ckeditor5-bbcode/src/bbob/TagNodes.ts b/packages/ckeditor5-bbcode/src/bbob/TagNodes.ts new file mode 100644 index 0000000000..b0ae374676 --- /dev/null +++ b/packages/ckeditor5-bbcode/src/bbob/TagNodes.ts @@ -0,0 +1,9 @@ +import { TagNode } from "@bbob/plugin-helper/es"; +import { Attrs, Content, Tag } from "./types"; + +// eslint-disable-next-line no-null/no-null +export const toNode = (tag: Tag, attrs: Attrs = {}, content: Content = null): TagNode => ({ + tag, + attrs, + content, +}); diff --git a/packages/ckeditor5-bbcode/src/bbob/types.ts b/packages/ckeditor5-bbcode/src/bbob/types.ts new file mode 100644 index 0000000000..64a78a9f27 --- /dev/null +++ b/packages/ckeditor5-bbcode/src/bbob/types.ts @@ -0,0 +1,10 @@ +import { TagNode } from "@bbob/plugin-helper/es"; +import createPreset from "@bbob/preset/es"; + +export type DefaultTags = Parameters[0]; +export type TagMappingFn = DefaultTags[string]; +export type Core = Parameters[1]; +export type Options = Parameters[2]; +export type Tag = TagNode["tag"]; +export type Content = TagNode["content"]; +export type Attrs = TagNode["attrs"];