feat(richtext-lexical): mdx support (#9160)

Supports bi-directional import/export between MDX <=> Lexical. JSX will
be mapped to lexical blocks back and forth.

This will allow editing our mdx docs in payload while keeping mdx as the
source of truth

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
This commit is contained in:
Alessio Gravili
2024-11-17 15:03:45 -07:00
committed by GitHub
parent 324af8a5f9
commit d4f1add2ab
79 changed files with 7540 additions and 304 deletions

2
test/lexical-mdx/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload'
export const mediaSlug = 'media'
export const MediaCollection: CollectionConfig = {
slug: mediaSlug,
access: {
create: () => true,
read: () => true,
},
fields: [],
upload: {
crop: true,
focalPoint: true,
imageSizes: [
{
name: 'thumbnail',
height: 200,
width: 200,
},
{
name: 'medium',
height: 800,
width: 800,
},
{
name: 'large',
height: 1200,
width: 1200,
},
],
},
}

View File

@@ -0,0 +1,41 @@
'use client'
import type { CodeFieldClientProps } from 'payload'
import { CodeField, useFormFields } from '@payloadcms/ui'
import React, { useMemo } from 'react'
import { languages } from './shared.js'
const languageKeyToMonacoLanguageMap = {
plaintext: 'plaintext',
ts: 'typescript',
tsx: 'typescript',
}
export const Code: React.FC<CodeFieldClientProps> = ({ field }) => {
const languageField = useFormFields(([fields]) => fields['language'])
const language: string =
(languageField?.value as string) || (languageField.initialValue as string) || 'typescript'
const label = languages[language as keyof typeof languages]
const props: typeof field = useMemo(
() => ({
...field,
admin: {
...field.admin,
components: field.admin?.components || {},
editorOptions: field.admin?.editorOptions || {},
label,
language: languageKeyToMonacoLanguageMap[language] || language,
},
}),
[field, language, label],
)
const key = `${field.name}-${language}-${label}`
return <CodeField field={props} key={key} />
}

View File

@@ -0,0 +1,107 @@
import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
lexicalEditor,
TreeViewFeature,
} from '@payloadcms/richtext-lexical'
import { loadMDXAfterRead, saveMDXBeforeChange } from '../../mdx/hooks.js'
import { BannerBlock } from '../../mdx/jsxBlocks/banner.js'
import { CodeBlock } from '../../mdx/jsxBlocks/code/code.js'
import { InlineCodeBlock } from '../../mdx/jsxBlocks/inlineCode.js'
import { PackageInstallOptions } from '../../mdx/jsxBlocks/packageInstallOptions.js'
import { TextContainerBlock } from '../../mdx/jsxBlocks/TextContainer.js'
import { TextContainerNoTrimBlock } from '../../mdx/jsxBlocks/TextContainerNoTrim.js'
export const postsSlug = 'posts'
export const PostsCollection: CollectionConfig = {
slug: postsSlug,
admin: {
useAsTitle: 'docPath',
},
hooks: {
beforeChange: [saveMDXBeforeChange],
afterRead: [loadMDXAfterRead],
},
fields: [
{
name: 'docPath',
type: 'text',
required: true,
},
{
type: 'collapsible',
label: 'FrontMatter',
admin: {
position: 'sidebar',
},
fields: [
{
name: 'frontMatter',
type: 'array',
fields: [
{
type: 'text',
name: 'key',
},
{
type: 'text',
name: 'value',
},
],
},
],
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
EXPERIMENTAL_TableFeature(),
FixedToolbarFeature(),
BlocksFeature({
blocks: [
BannerBlock,
CodeBlock,
PackageInstallOptions,
TextContainerNoTrimBlock,
TextContainerBlock,
],
inlineBlocks: [InlineCodeBlock],
}),
],
}),
},
{
name: 'richTextUnconverted',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
EXPERIMENTAL_TableFeature(),
FixedToolbarFeature(),
BlocksFeature({
blocks: [
BannerBlock,
CodeBlock,
PackageInstallOptions,
TextContainerNoTrimBlock,
TextContainerBlock,
],
inlineBlocks: [InlineCodeBlock],
}),
],
}),
},
],
versions: {
drafts: true,
},
}

View File

@@ -0,0 +1,15 @@
export const docsBasePath = '/Users/alessio/Documents/payloadcms-mdx-mock/docs'
export const languages = {
ts: 'TypeScript',
plaintext: 'Plain Text',
tsx: 'TSX',
js: 'JavaScript',
jsx: 'JSX',
}
export const bannerTypes = {
success: 'Success',
info: 'Info',
warning: 'Warning',
}

View File

@@ -0,0 +1,81 @@
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import * as fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { MediaCollection } from './collections/Media/index.js'
import { PostsCollection } from './collections/Posts/index.js'
import { docsBasePath } from './collections/Posts/shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
// ...extend config here
collections: [
PostsCollection,
{
slug: 'simple',
fields: [
{
name: 'text',
type: 'text',
},
],
},
MediaCollection,
],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
editor: lexicalEditor({}),
cors: ['http://localhost:3000', 'http://localhost:3001'],
globals: [],
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
await payload.delete({
collection: 'posts',
where: {},
})
// Recursively collect all paths to .mdx files RELATIVE to basePath
const walkSync = (dir: string, filelist: string[] = []) => {
fs.readdirSync(dir).forEach((file) => {
filelist = fs.statSync(path.join(dir, file)).isDirectory()
? walkSync(path.join(dir, file), filelist)
: filelist.concat(path.join(dir, file))
})
return filelist
}
const mdxFiles = walkSync(docsBasePath)
.filter((file) => file.endsWith('.mdx'))
.map((file) => file.replace(docsBasePath, ''))
for (const file of mdxFiles) {
await payload.create({
collection: 'posts',
depth: 0,
context: {
seed: true,
},
data: {
docPath: file,
},
})
}
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,20 @@
import { rootParserOptions } from '../../eslint.config.js'
import { testEslintConfig } from '../eslint.config.js'
/** @typedef {import('eslint').Linter.FlatConfig} */
let FlatConfig
/** @type {FlatConfig[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

1519
test/lexical-mdx/int.spec.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
import type { SerializedEditorState } from 'lexical'
import type { CollectionAfterReadHook, CollectionBeforeChangeHook, RichTextField } from 'payload'
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString } from '@lexical/markdown'
import {
$convertFromMarkdownString,
extractFrontmatter,
frontmatterToObject,
getEnabledNodes,
type LexicalRichTextAdapter,
objectToFrontmatter,
type SanitizedServerEditorConfig,
} from '@payloadcms/richtext-lexical'
import fs from 'node:fs'
import path from 'path'
import { deepCopyObjectSimple } from 'payload'
import { docsBasePath } from '../collections/Posts/shared.js'
export const editorJSONToMDX = ({
editorState,
editorConfig,
frontMatterData,
}: {
editorConfig: SanitizedServerEditorConfig
editorState: any
frontMatterData?: any
}) => {
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig,
}),
})
// Convert lexical state to markdown
// Import editor state into your headless editor
try {
headlessEditor.setEditorState(headlessEditor.parseEditorState(editorState)) // This should commit the editor state immediately
} catch (e) {
console.error('Error parsing editor state', e)
}
// Export to markdown
let markdown: string
headlessEditor.getEditorState().read(() => {
markdown = $convertToMarkdownString(editorConfig?.features?.markdownTransformers)
})
if (!frontMatterData) {
return markdown
}
const frontMatterOriginalData = deepCopyObjectSimple(frontMatterData)
//Frontmatter
const frontmatterData = {}
if (frontMatterOriginalData) {
for (const frontMatterArrayEntry of frontMatterOriginalData) {
frontmatterData[frontMatterArrayEntry.key] = frontMatterArrayEntry.value
}
const frontmatterString = objectToFrontmatter(frontmatterData)
if (frontmatterString?.length) {
markdown = frontmatterString + '\n' + markdown
}
}
return markdown
}
export const saveMDXBeforeChange: CollectionBeforeChangeHook = ({ collection, data, context }) => {
if (context.seed) {
return data
}
const docFilePath = path.join(docsBasePath, data.docPath)
const field: RichTextField = collection.fields.find(
(field) => 'name' in field && field.name === 'richText',
) as RichTextField
const value = data[field.name]
const editorConfig: SanitizedServerEditorConfig = (field.editor as LexicalRichTextAdapter)
.editorConfig
const markdown = editorJSONToMDX({
editorState: value,
editorConfig,
frontMatterData: data.frontMatter,
})
if (markdown?.trim()?.length) {
// Write markdown to '../../../../docs/admin/overview.mdx'
fs.writeFileSync(docFilePath, markdown, {
encoding: 'utf-8',
})
}
return null // Do not save anything to database
}
export function mdxToEditorJSON({
mdxWithFrontmatter,
editorConfig,
}: {
editorConfig: SanitizedServerEditorConfig
mdxWithFrontmatter: string
}): {
editorState: SerializedEditorState
frontMatter: { key: string; value: string }[]
} {
const frontMatter = extractFrontmatter(mdxWithFrontmatter)
const mdx = frontMatter.content
const headlessEditor = createHeadlessEditor({
nodes: getEnabledNodes({
editorConfig,
}),
})
headlessEditor.update(
() => {
$convertFromMarkdownString(mdx, editorConfig.features.markdownTransformers)
},
{ discrete: true },
)
const frontMatterArray = frontMatter?.frontmatter?.length
? Object.entries(frontmatterToObject(frontMatter.frontmatter)).map(([key, value]) => ({
key,
value,
}))
: []
return {
editorState: headlessEditor.getEditorState().toJSON(),
frontMatter: frontMatterArray,
}
}
export const loadMDXAfterRead: CollectionAfterReadHook = ({ collection, doc, context }) => {
if (context.seed) {
return doc
}
const field: RichTextField = collection.fields.find(
(field) => 'name' in field && field.name === 'richText',
) as RichTextField
const docFilePath = path.join(docsBasePath, doc.docPath)
const mdxWithFrontmatter = fs.readFileSync(docFilePath, {
encoding: 'utf-8',
})
const editorConfig: SanitizedServerEditorConfig = (field.editor as LexicalRichTextAdapter)
.editorConfig
const result = mdxToEditorJSON({
mdxWithFrontmatter,
editorConfig,
})
return {
...doc,
richText: result.editorState,
frontMatter: result.frontMatter,
}
}

View File

@@ -0,0 +1,24 @@
import type { Block } from 'payload'
export const TextContainerBlock: Block = {
slug: 'TextContainer',
jsx: {
import: ({ children }) => {
return {
text: children,
}
},
export: ({ fields }) => {
return {
props: {},
children: fields.text,
}
},
},
fields: [
{
name: 'text',
type: 'text',
},
],
}

View File

@@ -0,0 +1,25 @@
import type { Block } from 'payload'
export const TextContainerNoTrimBlock: Block = {
slug: 'TextContainerNoTrim',
jsx: {
import: ({ children }) => {
return {
text: children,
}
},
export: ({ fields }) => {
return {
props: {},
children: fields.text,
}
},
doNotTrimChildren: true,
},
fields: [
{
name: 'text',
type: 'text',
},
],
}

View File

@@ -0,0 +1,53 @@
import type { Block } from 'payload'
import { BlocksFeature, lexicalEditor, TreeViewFeature } from '@payloadcms/richtext-lexical'
import { bannerTypes } from '../../collections/Posts/shared.js'
import { InlineCodeBlock } from './inlineCode.js'
export const BannerBlock: Block = {
slug: 'Banner',
jsx: {
import: ({ props, children, markdownToLexical }) => {
return {
type: props?.type,
content: markdownToLexical({ markdown: children }),
}
},
export: ({ fields, lexicalToMarkdown }) => {
const props: any = {}
if (fields.type) {
props.type = fields.type
}
return {
props,
children: lexicalToMarkdown({ editorState: fields.content }),
}
},
},
fields: [
{
type: 'select',
name: 'type',
options: Object.entries(bannerTypes).map(([key, value]) => ({
label: value,
value: key,
})),
defaultValue: 'info',
},
{
name: 'content',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeViewFeature(),
BlocksFeature({
inlineBlocks: [InlineCodeBlock],
}),
],
}),
},
],
}

View File

@@ -0,0 +1,32 @@
import type { Block } from 'payload'
import { languages } from '../../../collections/Posts/shared.js'
import { codeConverter } from './converter.js'
export const CodeBlock: Block = {
slug: 'Code',
admin: {
jsx: './mdx/jsxBlocks/code/converterClient.js#codeConverterClient',
},
jsx: codeConverter,
fields: [
{
type: 'select',
name: 'language',
options: Object.entries(languages).map(([key, value]) => ({
label: value,
value: key,
})),
defaultValue: 'ts',
},
{
admin: {
components: {
Field: './collections/Posts/CodeFields.js#Code',
},
},
name: 'code',
type: 'code',
},
],
}

View File

@@ -0,0 +1,36 @@
import type { BlockJSX } from 'payload'
export const codeConverter: BlockJSX = {
customStartRegex: /^[ \t]*```(\w+)?/,
customEndRegex: {
optional: true,
regExp: /[ \t]*```$/,
},
doNotTrimChildren: true,
import: ({ openMatch, children, closeMatch }) => {
const language = openMatch[1]
const isSingleLineAndComplete =
!!closeMatch && !children.includes('\n') && openMatch.input?.trim() !== '```' + language
if (isSingleLineAndComplete) {
return {
language: '',
code: language + (children?.length ? children : ''), // No need to add space to children as they are not trimmed
}
}
return {
language,
code: children,
}
},
export: ({ fields }) => {
const isSingleLine = !fields.code.includes('\n') && !fields.language?.length
if (isSingleLine) {
return '```' + fields.code + '```'
}
return '```' + (fields.language || '') + (fields.code ? '\n' + fields.code : '') + '\n' + '```'
},
}

View File

@@ -0,0 +1,3 @@
'use client'
export { codeConverter as codeConverterClient } from './converter.js'

View File

@@ -0,0 +1,24 @@
import type { Block } from 'payload'
export const InlineCodeBlock: Block = {
slug: 'InlineCode',
jsx: {
import: ({ children }) => {
return {
code: children,
}
},
export: ({ fields }) => {
return {
props: {},
children: fields.code,
}
},
},
fields: [
{
name: 'code',
type: 'code',
},
],
}

View File

@@ -0,0 +1,52 @@
import type { Block } from 'payload'
export const PackageInstallOptions: Block = {
slug: 'PackageInstallOptions',
jsx: {
import: ({ props, children, markdownToLexical }) => {
return {
global: props?.global,
packageId: props?.packageId,
someNestedObject: props?.someNestedObject,
uniqueId: props?.uniqueId,
update: props?.update,
}
},
export: ({ fields, lexicalToMarkdown }) => {
return {
props: {
global: fields?.global,
packageId: fields?.packageId,
someNestedObject: fields?.someNestedObject,
uniqueId: fields?.uniqueId,
update: fields?.update,
},
}
},
},
fields: [
{
name: 'packageId',
type: 'text',
},
{
name: 'global',
type: 'checkbox',
},
{
name: 'update',
type: 'checkbox',
},
{
name: 'uniqueId',
type: 'text',
},
{
name: 'someNestedObject',
type: 'code',
admin: {
hidden: true,
},
},
],
}

View File

@@ -0,0 +1,231 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
simple: Simple;
media: Media;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
db: {
defaultIDType: string;
};
globals: {};
locale: null;
user: User & {
collection: 'users';
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
docPath: string;
frontMatter?:
| {
key?: string | null;
value?: string | null;
id?: string | null;
}[]
| null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "simple".
*/
export interface Simple {
id: string;
text?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
medium?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
large?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'simple';
value: string | Simple;
} | null)
| ({
relationTo: 'media';
value: string | Media;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
editedAt?: string | null;
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
export const tableJson = {
children: [
{
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' Option ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 1,
rowSpan: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' Default route ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 1,
rowSpan: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' Description ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 1,
rowSpan: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablerow',
version: 1,
},
{
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 16,
mode: 'normal',
style: '',
text: 'account',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 0,
rowSpan: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 0,
rowSpan: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: " The user's account page. ",
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 0,
rowSpan: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablerow',
version: 1,
},
{
children: [
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 16,
mode: 'normal',
style: '',
text: 'createFirstUser',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 0,
rowSpan: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
{
detail: 0,
format: 16,
mode: 'normal',
style: '',
text: '/create-first-user',
type: 'text',
version: 1,
},
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 0,
rowSpan: 1,
},
{
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: ' The page to create the first user. ',
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
textFormat: 0,
textStyle: '',
},
],
direction: null,
format: '',
indent: 0,
type: 'tablecell',
version: 1,
backgroundColor: null,
colSpan: 1,
headerState: 0,
rowSpan: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'tablerow',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'table',
version: 1,
}

View File

@@ -0,0 +1,33 @@
export function textToRichText(text: string) {
return {
root: {
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text,
type: 'text',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
textFormat: 0,
textStyle: '',
type: 'paragraph',
version: 1,
},
],
direction: null,
format: '',
indent: 0,
type: 'root',
version: 1,
},
}
}

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}