Skip to content

Commit

Permalink
feat(serializers): Add get* functions for reusable singular instanc…
Browse files Browse the repository at this point in the history
…es (Doist#88)

* feat(serializers): Add `get*` functions for reusable singular instances

* Remove `id` parameter requirement from `get*` functions

Instead of an explicit `id` parameter to map a serializer to a given
schema, the ID is now computed based on marks and nodes names available
in the given schema.

The ID will end up being a **very long string*, but this should never be
a problem, because JavaScript object keys support very long strings:

- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length#description

* chore: Update JSDoc for the `get*` functions

* test: Add a couple of tests for each serializer new `get*` function

* docs: Add Storybook documentation page for serializers

* chore: Add missing JSDoc and tests for `computeSchemaId` function
  • Loading branch information
rfgamaral committed Mar 17, 2023
1 parent 9ff5bf4 commit b2c77c3
Show file tree
Hide file tree
Showing 12 changed files with 183 additions and 30 deletions.
9 changes: 4 additions & 5 deletions src/components/typist-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { ExtraEditorCommands } from '../extensions/core/extra-editor-commands/ex
import { ViewEventHandlers, ViewEventHandlersOptions } from '../extensions/core/view-event-handlers'
import { isMultilineDocument, isPlainTextDocument } from '../helpers/schema'
import { useEditor } from '../hooks/use-editor'
import { createHTMLSerializer } from '../serializers/html/html'
import { createMarkdownSerializer } from '../serializers/markdown/markdown'
import { getHTMLSerializerInstance } from '../serializers/html/html'
import { getMarkdownSerializerInstance } from '../serializers/markdown/markdown'

import { getAllNodesAttributesByType, resolveContentSelection } from './typist-editor.helper'

Expand Down Expand Up @@ -241,7 +241,6 @@ const TypistEditor = forwardRef<TypistEditorRef, TypistEditorProps>(function Typ
const allExtensions = useMemo(
function initializeExtensions() {
return [
// Custom core extensions
...(placeholder
? [
Placeholder.configure({
Expand Down Expand Up @@ -271,13 +270,13 @@ const TypistEditor = forwardRef<TypistEditorRef, TypistEditorProps>(function Typ

const htmlSerializer = useMemo(
function initializeHTMLSerializer() {
return createHTMLSerializer(schema)
return getHTMLSerializerInstance(schema)
},
[schema],
)
const markdownSerializer = useMemo(
function initializeMarkdownSerializer() {
return createMarkdownSerializer(schema)
return getMarkdownSerializerInstance(schema)
},
[schema],
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { RawCommands } from '@tiptap/core'
import { DOMParser } from 'prosemirror-model'

import { parseHtmlToElement } from '../../../../helpers/dom'
import { createHTMLSerializer } from '../../../../serializers/html/html'
import { getHTMLSerializerInstance } from '../../../../serializers/html/html'

import type { ParseOptions } from 'prosemirror-model'

Expand Down Expand Up @@ -38,7 +38,7 @@ function insertMarkdownContent(
// Check if the transaction should be dispatched
// ref: https://tiptap.dev/api/commands#dry-run-for-commands
if (dispatch) {
const htmlContent = createHTMLSerializer(editor.schema).serialize(markdown)
const htmlContent = getHTMLSerializerInstance(editor.schema).serialize(markdown)

// Inserts the HTML content into the editor while preserving the current selection
tr.replaceSelection(
Expand Down
4 changes: 2 additions & 2 deletions src/extensions/shared/copy-markdown-source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Extension, getHTMLFromFragment } from '@tiptap/core'

import { createMarkdownSerializer } from '../../serializers/markdown/markdown'
import { getMarkdownSerializerInstance } from '../../serializers/markdown/markdown'

/**
* The options available to customize the `CopyMarkdownSource` extension.
Expand Down Expand Up @@ -35,7 +35,7 @@ const CopyMarkdownSource = Extension.create<CopyMarkdownSourceOptions>({
)

// Serialize the selected content HTML to Markdown
const markdownContent = createMarkdownSerializer(editor.schema).serialize(
const markdownContent = getMarkdownSerializerInstance(editor.schema).serialize(
getHTMLFromFragment(nodeSelection.content, editor.schema),
)

Expand Down
10 changes: 9 additions & 1 deletion src/helpers/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getSchema } from '@tiptap/core'
import { PlainTextKit } from '../extensions/plain-text/plain-text-kit'
import { RichTextKit } from '../extensions/rich-text/rich-text-kit'

import { isMultilineDocument, isPlainTextDocument } from './schema'
import { computeSchemaId, isMultilineDocument, isPlainTextDocument } from './schema'

describe('Helper: Schema', () => {
describe('#isMultilineDocument', () => {
Expand Down Expand Up @@ -57,4 +57,12 @@ describe('Helper: Schema', () => {
expect(isPlainTextDocument(getSchema([RichTextKit]))).toBe(false)
})
})

describe('#computeSchemaId', () => {
test('returns a string ID that matches the given editor schema', () => {
expect(computeSchemaId(getSchema([RichTextKit]))).toBe(
'link,bold,code,italic,boldAndItalics,strike,paragraph,blockquote,bulletList,codeBlock,doc,hardBreak,heading,horizontalRule,image,listItem,orderedList,text',
)
})
})
})
13 changes: 12 additions & 1 deletion src/helpers/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,15 @@ function isPlainTextDocument(schema: Schema): boolean {
return Boolean(schema.topNodeType.spec.content?.startsWith('paragraph'))
}

export { isMultilineDocument, isPlainTextDocument }
/**
* Computes a string ID that identifies a given editor schema which can be used for object mapping.
*
* @param schema The current editor document schema.
*
* @returns A string ID matching the editor schema.
*/
function computeSchemaId(schema: Schema) {
return [...Object.keys(schema.marks), ...Object.keys(schema.nodes)].join()
}

export { computeSchemaId, isMultilineDocument, isPlainTextDocument }
7 changes: 5 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ export type {
} from './factories/create-suggestion-extension'
export { createSuggestionExtension } from './factories/create-suggestion-extension'
export { isMultilineDocument, isPlainTextDocument } from './helpers/schema'
export { createHTMLSerializer } from './serializers/html/html'
export { createMarkdownSerializer } from './serializers/markdown/markdown'
export { createHTMLSerializer, getHTMLSerializerInstance } from './serializers/html/html'
export {
createMarkdownSerializer,
getMarkdownSerializerInstance,
} from './serializers/markdown/markdown'
export { canInsertNodeAt } from './utilities/can-insert-node-at'
export { canInsertSuggestion } from './utilities/can-insert-suggestion'
export type { AnyConfig, Editor as CoreEditor, EditorEvents, MarkRange, Range } from '@tiptap/core'
Expand Down
22 changes: 21 additions & 1 deletion src/serializers/html/html.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { PlainTextKit } from '../../extensions/plain-text/plain-text-kit'
import { RichTextKit } from '../../extensions/rich-text/rich-text-kit'
import { createSuggestionExtension } from '../../factories/create-suggestion-extension'

import { createHTMLSerializer } from './html'
import { createHTMLSerializer, getHTMLSerializerInstance } from './html'

import type { HTMLSerializerReturnType } from './html'

Expand Down Expand Up @@ -242,6 +242,26 @@ const MARKDOWN_INPUT_TABLES = `| Syntax | Description |
| Paragraph | Text | And more |`

describe('HTML Serializer', () => {
describe('Singleton Instances', () => {
describe('when the editor schema for two HTML serializers are the same', () => {
test('`getHTMLSerializerInstance` returns the same instance', () => {
const htmlSerializerA = getHTMLSerializerInstance(getSchema([PlainTextKit]))
const htmlSerializerB = getHTMLSerializerInstance(getSchema([PlainTextKit]))

expect(htmlSerializerA).toBe(htmlSerializerB)
})
})

describe('when the editor schema for two HTML serializers are NOT the same', () => {
test('`getHTMLSerializerInstance` returns different instances', () => {
const htmlSerializerA = getHTMLSerializerInstance(getSchema([PlainTextKit]))
const htmlSerializerB = getHTMLSerializerInstance(getSchema([RichTextKit]))

expect(htmlSerializerA).not.toBe(htmlSerializerB)
})
})
})

describe('Plain-text Document', () => {
describe('with default extensions', () => {
let htmlSerializer: HTMLSerializerReturnType
Expand Down
33 changes: 31 additions & 2 deletions src/serializers/html/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { escape, kebabCase } from 'lodash-es'
import { marked } from 'marked'

import { REGEX_LINE_BREAKS } from '../../constants/regular-expressions'
import { isPlainTextDocument } from '../../helpers/schema'
import { computeSchemaId, isPlainTextDocument } from '../../helpers/schema'
import { buildSuggestionSchemaPartialRegex } from '../../helpers/serializer'

import { checkbox } from './extensions/checkbox'
Expand All @@ -29,6 +29,13 @@ type HTMLSerializerReturnType = {
serialize: (markdown: string) => string
}

/**
* The type for the object that holds multiple HTML serializer instances.
*/
type HTMLSerializerInstanceById = {
[id: string]: HTMLSerializerReturnType
}

/**
* Sensible default options to initialize the Marked parser with.
*
Expand Down Expand Up @@ -135,6 +142,28 @@ function createHTMLSerializer(schema: Schema): HTMLSerializerReturnType {
}
}

export { createHTMLSerializer, INITIAL_MARKED_OPTIONS }
/**
* Object that holds multiple HTML serializer instances based on a given ID.
*/
const htmlSerializerInstanceById: HTMLSerializerInstanceById = {}

/**
* Returns a singleton instance of a HTML serializer based on the provided editor schema.
*
* @param schema The editor schema connected to the HTML serializer instance.
*
* @returns The HTML serializer instance for the given editor schema.
*/
function getHTMLSerializerInstance(schema: Schema) {
const id = computeSchemaId(schema)

if (!htmlSerializerInstanceById[id]) {
htmlSerializerInstanceById[id] = createHTMLSerializer(schema)
}

return htmlSerializerInstanceById[id]
}

export { createHTMLSerializer, getHTMLSerializerInstance, INITIAL_MARKED_OPTIONS }

export type { HTMLSerializerReturnType }
48 changes: 36 additions & 12 deletions src/serializers/markdown/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { PlainTextKit } from '../../extensions/plain-text/plain-text-kit'
import { RichTextKit } from '../../extensions/rich-text/rich-text-kit'
import { createSuggestionExtension } from '../../factories/create-suggestion-extension'

import { createMarkdownSerializer } from './markdown'
import { createMarkdownSerializer, getMarkdownSerializerInstance } from './markdown'

import type { MarkdownSerializerReturnType } from './markdown'

Expand Down Expand Up @@ -214,6 +214,26 @@ const HTML_INPUT_PONCTUATION_CHARACTERS = `<p>\\' text \\'</p>
<p>\\~ text \\~</p>`

describe('Markdown Serializer', () => {
describe('Singleton Instances', () => {
describe('when the editor schema for two Markdown serializers are the same', () => {
test('`getMarkdownSerializerInstance` returns the same instance', () => {
const markdownSerializerA = getMarkdownSerializerInstance(getSchema([PlainTextKit]))
const markdownSerializerB = getMarkdownSerializerInstance(getSchema([PlainTextKit]))

expect(markdownSerializerA).toBe(markdownSerializerB)
})
})

describe('when the editor schema for two Markdown serializers are NOT the same', () => {
test('`getMarkdownSerializerInstance` returns different instances', () => {
const markdownSerializerA = getMarkdownSerializerInstance(getSchema([PlainTextKit]))
const markdownSerializerB = getMarkdownSerializerInstance(getSchema([RichTextKit]))

expect(markdownSerializerA).not.toBe(markdownSerializerB)
})
})
})

describe('Plain-text Document', () => {
describe('with default extensions', () => {
let markdownSerializer: MarkdownSerializerReturnType
Expand Down Expand Up @@ -640,17 +660,21 @@ See the section on [\`code\`](#code).`,
})

describe('without custom extensions', () => {
const markdownSerializer = createMarkdownSerializer(
getSchema([
RichTextKit.configure({
bulletList: false,
image: false,
listItem: false,
orderedList: false,
strike: false,
}),
]),
)
let markdownSerializer: MarkdownSerializerReturnType

beforeEach(() => {
markdownSerializer = createMarkdownSerializer(
getSchema([
RichTextKit.configure({
bulletList: false,
image: false,
listItem: false,
orderedList: false,
strike: false,
}),
]),
)
})

test('ordered lists Markdown output is correct', () => {
expect(markdownSerializer.serialize(HTML_INPUT_ORDERED_LISTS)).toBe(`1. First item
Expand Down
33 changes: 31 additions & 2 deletions src/serializers/markdown/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Turndown from 'turndown'

import { REGEX_PUNCTUATION } from '../../constants/regular-expressions'
import { isPlainTextDocument } from '../../helpers/schema'
import { computeSchemaId, isPlainTextDocument } from '../../helpers/schema'

import { image } from './plugins/image'
import { listItem } from './plugins/list-item'
Expand All @@ -26,6 +26,13 @@ type MarkdownSerializerReturnType = {
serialize: (html: string) => string
}

/**
* The type for the object that holds multiple Markdown serializer instances.
*/
type MarkdownSerializerInstanceById = {
[id: string]: MarkdownSerializerReturnType
}

/**
* The bullet list marker for both standard and task list items.
*/
Expand Down Expand Up @@ -187,6 +194,28 @@ function createMarkdownSerializer(schema: Schema): MarkdownSerializerReturnType
}
}

export { BULLET_LIST_MARKER, createMarkdownSerializer }
/**
* Object that holds multiple Markdown serializer instances based on a given ID.
*/
const markdownSerializerInstanceById: MarkdownSerializerInstanceById = {}

/**
* Returns a singleton instance of a Markdown serializer based on the provided editor schema.
*
* @param schema The editor schema connected to the Markdown serializer instance.
*
* @returns The Markdown serializer instance for the given editor schema.
*/
function getMarkdownSerializerInstance(schema: Schema) {
const id = computeSchemaId(schema)

if (!markdownSerializerInstanceById[id]) {
markdownSerializerInstanceById[id] = createMarkdownSerializer(schema)
}

return markdownSerializerInstanceById[id]
}

export { BULLET_LIST_MARKER, createMarkdownSerializer, getMarkdownSerializerInstance }

export type { MarkdownSerializerReturnType }
13 changes: 13 additions & 0 deletions stories/documentation/reference/serializers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Serializers

Unfortunately, Tiptap doesn't support Markdown as an input or output format, and while support for Markdown was considered, the Tiptap team decided against it (more information [here](https://tiptap.dev/guide/output#not-an-option-markdown)). However, at Doist, Markdown is a must, and that's why we implemented both an HTML and Markdown serializer to convert between both formats.

Although the serializers are mostly meant to be used internally by the `TypistEditor` component and/or internal extensions, it's sometimes useful to have access to the same serializers externally for custom extensions. With that in mind, the `create*Serializer` and `get*Serializer` methods are publicly exported for both the HTML and Markdown serializers.

## `get*Serializer`

This function is the one everyone should be using most of the time because once a serializer is created for the first time, it will be cached and reused the next time this function is called. You shouldn't worry about using this function for multiple editors loaded with different extensions because the cache mechanism caches multiple serializers based on the given editor `schema`.

## `create*Serializer`

This function is the one that actually creates the serializer instance, and while it's used internally by the `get*Serializer` function, it's also available for public comsumption in the event of a very specific use case where it might be useful. Most of the time you should not need to call this function directly, but you should know that a new serializer instance will be created every time you do call this function directly, and you may incur in a small performance penalty.
17 changes: 17 additions & 0 deletions stories/documentation/reference/serializers.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Meta } from '@storybook/addon-docs'

import { MarkdownRenderer } from '../../components/markdown-renderer.tsx'

import rawSerializers from './serializers.md?raw'

<Meta
title="Documentation/Reference/Serializers"
parameters={{
viewMode: 'docs',
options: {
isToolshown: false,
},
}}
/>

<MarkdownRenderer markdown={rawSerializers} />

0 comments on commit b2c77c3

Please sign in to comment.