diff --git a/package-lock.json b/package-lock.json index 79200ee..f05dbac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "prettier": "^3.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", + "type-fest": "^4.10.2", "typescript": "^5.1.6" } }, @@ -3573,6 +3574,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", @@ -6187,12 +6200,12 @@ } }, "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.10.2.tgz", + "integrity": "sha512-anpAG63wSpdEbLwOqH8L84urkL6PiVIov3EMmgIhhThevh9aiMQov+6Btx0wldNcvm4wV+e2/Rt1QdDwKHFbHw==", "dev": true, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 6f88a36..3b08005 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "prettier": "^3.0.0", "ts-jest": "^29.1.1", "ts-node": "^10.9.1", + "type-fest": "^4.10.2", "typescript": "^5.1.6" } } diff --git a/src/index.ts b/src/index.ts index 2340f44..f13809e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,14 @@ -export { default } from './lib/Document'; -export { Document } from './lib/Document'; -export { Text } from './lib/Text'; +export { default } from './lib/tempo'; +export { + /** @deprecated */ + TempoDocument as Document, + TempoDocument +} from './lib/TempoDocument'; +export { + /** @deprecated */ + TempoText as Text, + TempoText +} from './lib/TempoText'; export { SupportedLanguage, supportedLanguages, @@ -8,4 +16,4 @@ export { EmojiAlias, EmojiUnicode, assertSupportedEmoji -} from './lib/markdown'; +} from './lib/markdown/index'; diff --git a/src/lib/Document.ts b/src/lib/Document.ts deleted file mode 100644 index 5302db6..0000000 --- a/src/lib/Document.ts +++ /dev/null @@ -1,431 +0,0 @@ -import * as md from './markdown/markdown'; -import { Text, TextNode } from './Text'; - -/* -|========================================================================== -| Document -|========================================================================== -| -| Wrapper around markdown functions to make it easier to build a document, -| using a chaining API. -| -*/ - -/* -|------------------ -| Util -|------------------ -*/ - -export type TextInput = string | Text | ((text: Text) => Text | string); - -/* -|------------------ -| DocumentNode Types -|------------------ -*/ - -export type DocumentNodeType = - | 'heading' - | 'paragraph' - | 'table' - | 'html' - | 'codeBlock' - | 'blockQuote' - | 'image' - | 'break' - | 'numberList' - | 'bulletList'; - -interface BaseDocumentNode { - type: DocumentNodeType; - data: T; - computed: string; -} - -export interface HeadingNode - extends BaseDocumentNode<{ - level: 1 | 2 | 3 | 4 | 5 | 6; - data: TextNode[]; - }> { - type: 'heading'; -} - -export interface ParagraphNode extends BaseDocumentNode { - type: 'paragraph'; -} - -interface TableRow { - type: T; - order: T extends 'row' ? number : undefined; - data: TextNode[][]; - computed: string; -} - -export interface TableNode - extends BaseDocumentNode<[TableRow<'header'>, ...TableRow<'row'>[]]> { - type: 'table'; -} - -export interface HtmlNode extends BaseDocumentNode { - type: 'html'; -} - -export interface CodeBlockNode - extends BaseDocumentNode<{ - code: string; - language?: md.SupportedLanguage; - }> { - type: 'codeBlock'; -} - -export interface BlockQuoteNode extends BaseDocumentNode { - type: 'blockQuote'; -} - -export interface ImageNode - extends BaseDocumentNode<{ - alt: string; - src: string; - }> { - type: 'image'; - computed: string; -} - -export interface BreakNode extends BaseDocumentNode { - type: 'break'; -} - -interface ListItem { - type: 'listItem'; - order: T extends 'numberList' ? number : undefined; - data: TextNode[]; - computed: string; -} - -export interface BulletListNode - extends BaseDocumentNode[]> { - type: 'bulletList'; -} - -export interface NumberListNode - extends BaseDocumentNode[]> { - type: 'numberList'; -} - -export type DocumentNode = - | HeadingNode - | ParagraphNode - | TableNode - | HtmlNode - | CodeBlockNode - | BlockQuoteNode - | ImageNode - | BreakNode - | NumberListNode - | BulletListNode; - -/* -|---------------------------------- -| Utils -|---------------------------------- -| -| Utility functions for working with DocumentNodes, Documents, TextNodes, and -| other base types. -| -*/ - -function formatTextNode(text: string): TextNode { - return { - type: 'plaintext', - data: text, - computed: text - }; -} - -export function computeNodes(textInput: TextInput): TextNode[] { - if (typeof textInput === 'string') { - return [formatTextNode(textInput)]; - } else if (textInput instanceof Text) { - return textInput.toJSON(); - } else if (typeof textInput === 'function') { - const result = textInput(new Text()); - return computeNodes(result); - } else { - throw new TypeError(`Invalid text type: ${typeof textInput}`); - } -} - -export function computeText(textInput: TextInput): string { - if (typeof textInput === 'string') { - return textInput; - } else if (textInput instanceof Text) { - return textInput.toString(); - } else if (typeof textInput === 'function') { - const result = textInput(new Text()); - return computeText(result); - } else { - throw new TypeError(`Invalid text type: ${typeof textInput}`); - } -} - -/* -|---------------------------------- -| Document Class -|---------------------------------- -| -| The primary class for building a document. It is a wrapper around the -| markdown functions, and provides a chaining API for building a document. -| -*/ - -export class Document { - private nodes: DocumentNode[]; - - constructor(documentNodes?: DocumentNode[]) { - this.nodes = documentNodes ?? []; - } - - /* - |------------------ - | Headings - |------------------ - */ - - public h1(text: TextInput) { - this.nodes.push({ - type: 'heading', - data: { - level: 1, - data: computeNodes(text) - }, - computed: md.h1(computeText(text)) - }); - return this; - } - - public h2(text: TextInput) { - this.nodes.push({ - type: 'heading', - data: { - level: 2, - data: computeNodes(text) - }, - computed: md.h2(computeText(text)) - }); - return this; - } - - public h3(text: TextInput) { - this.nodes.push({ - type: 'heading', - data: { - level: 3, - data: computeNodes(text) - }, - computed: md.h3(computeText(text)) - }); - return this; - } - - public h4(text: TextInput) { - this.nodes.push({ - type: 'heading', - data: { - level: 4, - data: computeNodes(text) - }, - computed: md.h4(computeText(text)) - }); - return this; - } - - public h5(text: TextInput) { - this.nodes.push({ - type: 'heading', - data: { - level: 5, - data: computeNodes(text) - }, - computed: md.h5(computeText(text)) - }); - return this; - } - - public h6(text: TextInput) { - this.nodes.push({ - type: 'heading', - data: { - level: 6, - data: computeNodes(text) - }, - computed: md.h6(computeText(text)) - }); - return this; - } - - /* - |------------------ - | Text Elements - |------------------ - */ - - public paragraph(text: TextInput) { - this.nodes.push({ - type: 'paragraph', - data: computeNodes(text), - computed: md.paragraph(computeText(text)) - }); - return this; - } - - /* - |------------------ - | Special Elements - |------------------ - */ - - public table(tableRows: TextInput[][]) { - const [header, ...rows] = tableRows; - this.nodes.push({ - type: 'table', - data: [ - { - type: 'header', - order: undefined, - data: header.map(computeNodes), - computed: md.tableHeader(header.map(computeText)) - }, - ...(rows.map((row, i) => ({ - type: 'row', - order: i, - data: row.map(computeNodes), - computed: md.tableRow(row.map(computeText)) - })) as TableRow<'row'>[]) - ], - computed: md.table(tableRows.map(row => row.map(computeText))) - }); - return this; - } - - public html(html: string) { - this.nodes.push({ - type: 'html', - data: html, - computed: html - }); - return this; - } - - public codeBlock(code: string, language?: md.SupportedLanguage) { - this.nodes.push({ - type: 'codeBlock', - data: { - code, - language - }, - computed: md.codeBlock(code, language) - }); - return this; - } - - public blockQuote(text: TextInput) { - this.nodes.push({ - type: 'blockQuote', - data: computeNodes(text), - computed: md.blockQuote(computeText(text)) - }); - return this; - } - - public image(text: string, src: string) { - this.nodes.push({ - type: 'image', - data: { - alt: text, - src - }, - computed: md.image(text, src) - }); - return this; - } - - public break() { - this.nodes.push({ - type: 'break', - data: null, - computed: md.thematicBreak() - }); - return this; - } - - /* - |------------------ - | Lists - |------------------ - */ - - public numberList(text: TextInput[]) { - this.nodes.push({ - type: 'numberList', - data: text.map((t, i) => ({ - order: i, - type: 'listItem', - data: computeNodes(t), - computed: md.li(computeText(t), i) - })), - computed: md.ol(text.map(computeText)) - }); - return this; - } - - public bulletList(text: TextInput[]) { - this.nodes.push({ - type: 'bulletList', - data: text.map(t => ({ - type: 'listItem', - order: undefined, - data: computeNodes(t), - computed: md.li(computeText(t)) - })), - computed: md.ul(text.map(computeText)) - }); - return this; - } - - /* - |------------------ - | Outputs - |------------------ - */ - - public toString() { - return this.nodes - .map(section => section.computed) - .join('\n\n') - .trim() - .concat('\n'); - } - - public toJSON() { - return this.nodes; - } -} - -/* -|---------------------------------- -| Public API -|---------------------------------- -| -| The public API for creating a document. It is a wrapper around the Document class -| and provides a chaining API for building a document. -| -| NOTE: -| We do export the Document class, types, and interfaces, as we want to allow -| for custom implementations of the Document class. -| -*/ - -const createDocument = (documentNodes?: DocumentNode[]) => - new Document(documentNodes); -export default createDocument; diff --git a/src/lib/TempoDocument.ts b/src/lib/TempoDocument.ts new file mode 100644 index 0000000..639ba81 --- /dev/null +++ b/src/lib/TempoDocument.ts @@ -0,0 +1,721 @@ +import * as md from './markdown/markdown'; +import { TempoText, TempoTextNode } from './TempoText'; + +/* +|========================================================================== +| Document +|========================================================================== +| +| Wrapper around markdown functions to make it easier to build a document, +| using a chaining API. +| +*/ + +/* +|------------------ +| Util +|------------------ +*/ + +export type TextInput = + | string + | TempoText + | ((text: TempoText) => TempoText | string); + +/* +|------------------ +| DocumentNode Types +|------------------ +*/ + +export type DocumentNodeType = + | 'heading' + | 'paragraph' + | 'table' + | 'html' + | 'codeBlock' + | 'blockQuote' + | 'image' + | 'break' + | 'numberList' + | 'bulletList'; + +interface BaseDocumentNode { + type: DocumentNodeType; + data: T; + computed: string; +} + +export interface HeadingNode + extends BaseDocumentNode<{ + level: 1 | 2 | 3 | 4 | 5 | 6; + data: TempoTextNode[]; + }> { + type: 'heading'; +} + +export interface ParagraphNode extends BaseDocumentNode { + type: 'paragraph'; +} + +interface TableRow { + type: T; + order: T extends 'row' ? number : undefined; + data: TempoTextNode[][]; + computed: string; +} + +export interface TableNode + extends BaseDocumentNode<[TableRow<'header'>, ...TableRow<'row'>[]]> { + type: 'table'; +} + +export interface HtmlNode extends BaseDocumentNode { + type: 'html'; +} + +export interface CodeBlockNode + extends BaseDocumentNode<{ + code: string; + language?: md.SupportedLanguage; + }> { + type: 'codeBlock'; +} + +export interface BlockQuoteNode extends BaseDocumentNode { + type: 'blockQuote'; +} + +export interface ImageNode + extends BaseDocumentNode<{ + alt: string; + src: string; + }> { + type: 'image'; + computed: string; +} + +export interface BreakNode extends BaseDocumentNode { + type: 'break'; +} + +interface ListItem { + type: 'listItem'; + order: T extends 'numberList' ? number : undefined; + data: TempoTextNode[]; + computed: string; +} + +export interface BulletListNode + extends BaseDocumentNode[]> { + type: 'bulletList'; +} + +export interface NumberListNode + extends BaseDocumentNode[]> { + type: 'numberList'; +} + +export type TempoDocumentNode = + | HeadingNode + | ParagraphNode + | TableNode + | HtmlNode + | CodeBlockNode + | BlockQuoteNode + | ImageNode + | BreakNode + | NumberListNode + | BulletListNode; + +/* +|---------------------------------- +| Utils +|---------------------------------- +| +| Utility functions for working with DocumentNodes, Documents, TextNodes, and +| other base types. +| +*/ + +function formatTextNode(text: string): TempoTextNode { + return { + type: 'plaintext', + data: text, + computed: text + }; +} + +export function computeNodes(textInput: TextInput): TempoTextNode[] { + if (typeof textInput === 'string') { + return [formatTextNode(textInput)]; + } else if (textInput instanceof TempoText) { + return textInput.toJSON(); + } else if (typeof textInput === 'function') { + const result = textInput(new TempoText()); + return computeNodes(result); + } else { + throw new TypeError(`Invalid text type: ${typeof textInput}`); + } +} + +export function computeText(textInput: TextInput): string { + if (typeof textInput === 'string') { + return textInput; + } else if (textInput instanceof TempoText) { + return textInput.toString(); + } else if (typeof textInput === 'function') { + const result = textInput(new TempoText()); + return computeText(result); + } else { + throw new TypeError(`Invalid text type: ${typeof textInput}`); + } +} + +/* +|---------------------------------- +| Document Class +|---------------------------------- +| +| The primary class for building a document. It is a wrapper around the +| markdown functions, and provides a chaining API for building a document. +| +*/ + +/** + * A class for building a document, using a chaining API. + */ +export class TempoDocument { + private nodes: TempoDocumentNode[]; + + constructor(documentNodes?: TempoDocumentNode[]) { + this.nodes = documentNodes ?? []; + } + + /* + |------------------ + | Headings + |------------------ + */ + + /** + * Append a heading (h1) to the document. + * + * @example + * ```ts + * const doc = new Document() + * .h1('Hello, World!') + * .toString(); + * // Output: # Hello, World! + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the heading appended. + */ + public h1(text: TextInput): this { + this.nodes.push({ + type: 'heading', + data: { + level: 1, + data: computeNodes(text) + }, + computed: md.h1(computeText(text)) + }); + return this; + } + + /** + * Append a heading (h2) to the document. + * + * @example + * ```ts + * const doc = new Document() + * .h2('Hello, World!') + * .toString(); + * // Output: ## Hello, World! + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the heading appended. + */ + public h2(text: TextInput): this { + this.nodes.push({ + type: 'heading', + data: { + level: 2, + data: computeNodes(text) + }, + computed: md.h2(computeText(text)) + }); + return this; + } + + /** + * Append a heading (h3) to the document. + * + * @example + * ```ts + * const doc = new Document() + * .h3('Hello, World!') + * .toString(); + * // Output: ### Hello, World! + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the heading appended. + */ + public h3(text: TextInput): this { + this.nodes.push({ + type: 'heading', + data: { + level: 3, + data: computeNodes(text) + }, + computed: md.h3(computeText(text)) + }); + return this; + } + + /** + * Append a heading (h4) to the document. + * + * @example + * ```ts + * const doc = new Document() + * .h4('Hello, World!') + * .toString(); + * // Output: #### Hello, World! + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the heading appended. + */ + public h4(text: TextInput): this { + this.nodes.push({ + type: 'heading', + data: { + level: 4, + data: computeNodes(text) + }, + computed: md.h4(computeText(text)) + }); + return this; + } + + /** + * Append a heading (h5) to the document. + * + * @example + * ```ts + * const doc = new Document() + * .h5('Hello, World!') + * .toString(); + * // Output: ##### Hello, World! + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the heading appended. + */ + public h5(text: TextInput): this { + this.nodes.push({ + type: 'heading', + data: { + level: 5, + data: computeNodes(text) + }, + computed: md.h5(computeText(text)) + }); + return this; + } + + /** + * Append a heading (h6) to the document. + * + * @example + * ```ts + * const doc = new Document() + * .h6('Hello, World!') + * .toString(); + * // Output: ##### Hello, World! + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the heading appended. + */ + public h6(text: TextInput): this { + this.nodes.push({ + type: 'heading', + data: { + level: 6, + data: computeNodes(text) + }, + computed: md.h6(computeText(text)) + }); + return this; + } + + /* + |------------------ + | Text Elements + |------------------ + */ + + /** + * Append a paragraph to the document. + * + * @example + * ```ts + * const doc = new Document() + * .paragraph('This is a paragraph of text.') + * .toString(); + * // Output: This is a paragraph of text. + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the paragraph appended. + */ + public paragraph(text: TextInput): this { + this.nodes.push({ + type: 'paragraph', + data: computeNodes(text), + computed: md.paragraph(computeText(text)) + }); + return this; + } + + /* + |------------------ + | Special Elements + |------------------ + */ + + /** + * Append a table to the document. + * + * @example + * ```ts + * const doc = new Document() + * .table([ + * ['Name', 'email'], + * ['John Doe', 'jdoe@gmail.com'], + * ['Jane Doe', 'jane@gmail.com'] + * ]) + * .toString(); + * // Output: + * // | Name | email | + * // |----------|----------------| + * // | John Doe | jdoe@gmail.com | + * // | Jane Doe | jane@gmail.com | + * ``` + * + * @param tableDefinition An array of arrays of the TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. With the first array being the header row. + * @returns A new Document instance with the table appended. + */ + public table(tableDefinition: TextInput[][]): this { + const [header, ...rows] = tableDefinition; + this.nodes.push({ + type: 'table', + data: [ + { + type: 'header', + order: undefined, + data: header.map(computeNodes), + computed: md.tableHeader(header.map(computeText)) + }, + ...(rows.map((row, i) => ({ + type: 'row', + order: i, + data: row.map(computeNodes), + computed: md.tableRow(row.map(computeText)) + })) as TableRow<'row'>[]) + ], + computed: md.table(tableDefinition.map(row => row.map(computeText))) + }); + return this; + } + + /** + * Append a raw HTML string to the document. + * + * @example + * ```ts + * const doc = new Document() + * .html('

Hello, World!

') + * .toString(); + * // Output:

Hello, World!

+ * ``` + * + * @param html A string of raw HTML. + * @returns A new Document instance with the HTML appended. + */ + public html(html: string): this { + this.nodes.push({ + type: 'html', + data: html, + computed: html + }); + return this; + } + + /** + * Append a code block to the document. + * + * @example + * ```ts + * const doc = new Document() + * .codeBlock(` + * const x = 10; + * + * console.log(x); + * `.trim(), + * 'javascript' + * ) + * .toString(); + * // Output: + * // ```javascript + * // const x = 10; + * // + * // console.log(x); + * // ``` + * ``` + * + * @param code A string of code. + * @param language A supported language for the code block. + * @returns A new Document instance with the code block appended. + */ + public codeBlock(code: string, language?: md.SupportedLanguage): this { + this.nodes.push({ + type: 'codeBlock', + data: { + code, + language + }, + computed: md.codeBlock(code, language) + }); + return this; + } + + /** + * Append a block quote to the document. + * + * @example + * ```ts + * const doc = new Document() + * .blockQuote('This is a block quote.') + * .toString(); + * // Output: > This is a block quote. + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the block quote appended. + */ + public blockQuote(text: TextInput): this { + this.nodes.push({ + type: 'blockQuote', + data: computeNodes(text), + computed: md.blockQuote(computeText(text)) + }); + return this; + } + + /** + * Append an image to the document. + * + * @example + * ```ts + * const doc = new Document() + * .image('Alt text', 'https://example.com/image.png') + * .toString(); + * // Output: ![Alt text](https://example.com/image.png) + * ``` + * + * @param text A TextInput type, which can be a string, Text instance, or a function that returns a string or Text instance. + * @param src A string of the image source. + * @returns A new Document instance with the image appended. + */ + public image(text: string, src: string): this { + this.nodes.push({ + type: 'image', + data: { + alt: text, + src + }, + computed: md.image(text, src) + }); + return this; + } + + /** + * Append a thematic break to the document. + * + * @example + * ```ts + * const doc = new Document() + * .break() + * .toString(); + * // Output: --- + * ``` + * + * @returns A new Document instance with the thematic break appended. + */ + public break(): this { + this.nodes.push({ + type: 'break', + data: null, + computed: md.thematicBreak() + }); + return this; + } + + /* + |------------------ + | Lists + |------------------ + */ + + /** + * Append a number list to the document. + * + * @example + * ```ts + * const doc = new Document() + * .numberList([ + * 'Item 1', + * 'Item 2', + * 'Item 3' + * ]) + * .toString(); + * // Output: + * // 1. Item 1 + * // 2. Item 2 + * // 3. Item 3 + * ``` + * + * @param text An array of TextInput types, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the number list appended. + */ + public numberList(text: TextInput[]): this { + this.nodes.push({ + type: 'numberList', + data: text.map((t, i) => ({ + order: i, + type: 'listItem', + data: computeNodes(t), + computed: md.li(computeText(t), i) + })), + computed: md.ol(text.map(computeText)) + }); + return this; + } + + /** + * Append a bullet list to the document. + * + * @example + * ```ts + * const doc = new Document() + * .bulletList([ + * 'Item 1', + * 'Item 2', + * 'Item 3' + * ]) + * .toString(); + * // Output: + * // - Item 1 + * // - Item 2 + * // - Item 3 + * ``` + * + * @param text An array of TextInput types, which can be a string, Text instance, or a function that returns a string or Text instance. + * @returns A new Document instance with the bullet list appended. + */ + public bulletList(text: TextInput[]): this { + this.nodes.push({ + type: 'bulletList', + data: text.map(t => ({ + type: 'listItem', + order: undefined, + data: computeNodes(t), + computed: md.li(computeText(t)) + })), + computed: md.ul(text.map(computeText)) + }); + return this; + } + + /* + |------------------ + | Outputs + |------------------ + */ + + /** + * Convert the document to string representation, that can be used for rendering. + * + * @example + * ```ts + * const doc = new Document() + * .h1('Hello, World!') + * .paragraph('This is a paragraph of text.') + * .toString(); + * // Output: + * // # Hello, World! + * // + * // This is a paragraph of text. + * ``` + * + * @returns A string representation of the document, that can be used for rendering. + */ + public toString(): string { + return this.nodes + .map(section => section.computed) + .join('\n\n') + .trim() + .concat('\n'); + } + + /** + * Convert the document to a JSON representation, that can be used for serialization. + * + * @example + * ```ts + * const doc = new Document() + * .h1('Hello, World!') + * .paragraph('This is a paragraph of text.') + * .toJSON(); + * // Output: + * // [ + * // { + * // "type": "heading", + * // "data": { + * // "level": 1, + * // "data": [ + * // { + * // "type": "plaintext", + * // "data": "Hello, World!", + * // "computed": "Hello, World!" + * // } + * // ] + * // }, + * // "computed": "# Hello, World!" + * // }, + * // { + * // "type": "paragraph", + * // "data": [ + * // { + * // "type": "plaintext", + * // "data": "This is a paragraph of text.", + * // "computed": "This is a paragraph of text." + * // } + * // ], + * // "computed": "This is a paragraph of text." + * // } + * // ] + * ``` + * + * @returns A JSON representation of the document, that can be used for serialization. + */ + public toJSON(): TempoDocumentNode[] { + return this.nodes; + } +} diff --git a/src/lib/TempoText.ts b/src/lib/TempoText.ts new file mode 100644 index 0000000..4dab4a8 --- /dev/null +++ b/src/lib/TempoText.ts @@ -0,0 +1,321 @@ +import * as md from './markdown/markdown'; + +/* +|========================================================================== +| Text +|========================================================================== +| +| A collection of functions to generate markdown strings, with a chaining +| API for building a collection of TextNodes. +| +*/ + +/* +|------------------ +| TextNode Types +|------------------ +*/ + +export type TempoTextNodeType = + | 'plaintext' + | 'code' + | 'append' + | 'bold' + | 'italic' + | 'strikeThrough' + | 'link' + | 'emoji'; + +interface BaseTextNode { + type: TempoTextNodeType; + data: T; + computed: string; +} + +export interface PlainTextNode extends BaseTextNode { + type: 'plaintext'; + options?: { + append?: boolean; + }; +} + +export interface AppendTextNode extends BaseTextNode { + type: 'append'; +} + +export interface CodeTextNode extends BaseTextNode { + type: 'code'; +} + +export interface BoldTextNode extends BaseTextNode { + type: 'bold'; +} + +export interface ItalicTextNode extends BaseTextNode { + type: 'italic'; +} + +export interface StrikeThroughTextNode extends BaseTextNode { + type: 'strikeThrough'; +} + +export interface LinkTextNode + extends BaseTextNode<{ alt: string; src: string }> { + type: 'link'; +} + +export interface EmojiTextNode extends BaseTextNode { + type: 'emoji'; +} + +export type TempoTextNode = + | PlainTextNode + | CodeTextNode + | AppendTextNode + | BoldTextNode + | ItalicTextNode + | StrikeThroughTextNode + | LinkTextNode + | EmojiTextNode; + +/* +|---------------------------------- +| Text Class +|---------------------------------- +| +| The primary class for building a set of TextNodes. This is used as the basis of +| certain Node within the Document class. +| +*/ + +/** + * A class for building a collection of TextNodes, using a chaining API. + */ +export class TempoText { + private nodes: TempoTextNode[] = []; + + /** + * Append a plaintext string to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .plainText('foobar') + * .toString(); + * // Output: 'foobar' + * ``` + * + * @param value A plaintext string to append to the collection of TextNodes. + * @param options An optional object to specify options for the append operation. + * @returns A new instance of the Text class, with the appended plaintext string. + */ + public plainText(value: string, options?: { append?: boolean }): this { + this.nodes.push({ + type: 'plaintext', + data: value, + computed: value, + options + }); + return this; + } + + /** + * Append a code string to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .code('foobar') + * .toString(); + * // Output: '`foobar`' + * ``` + * + * @param value A code string to append to the collection of TextNodes. + * @returns A new instance of the Text class, with the appended code string. + */ + public code(value: string) { + this.nodes.push({ + type: 'code', + data: value, + computed: md.code(value) + }); + return this; + } + + /** + * Append a bold string to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .bold('foobar') + * .toString(); + * // Output: '**foobar**' + * ``` + * + * @param value A bold string to append to the collection of TextNodes. + * @returns A new instance of the Text class, with the appended bold string. + */ + public bold(value: string) { + this.nodes.push({ + type: 'bold', + data: value, + computed: md.bold(value) + }); + return this; + } + + /** + * Append an italic string to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .italic('foobar') + * .toString(); + * // Output: '_foobar_' + * ``` + * + * @param value An italic string to append to the collection of TextNodes. + * @returns A new instance of the Text class, with the appended italic string. + */ + public italic(value: string) { + this.nodes.push({ + type: 'italic', + data: value, + computed: md.italic(value) + }); + return this; + } + + /** + * Append a strikethrough string to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .strikeThrough('foobar') + * .toString(); + * // Output: '~~foobar~~' + * ``` + * + * @param value A strikethrough string to append to the collection of TextNodes. + * @returns A new instance of the Text class, with the appended strikethrough string. + */ + public strikeThrough(value: string) { + this.nodes.push({ + type: 'strikeThrough', + data: value, + computed: md.strikeThrough(value) + }); + return this; + } + + /** + * Append a link to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .link('Google', 'https://www.google.com') + * .toString(); + * // Output: '[Google](https://www.google.com)' + * + * @param value The alt text for the link. + * @param href The href for the link. + * @returns A new instance of the Text class, with the appended link. + */ + public link(value: string, href: string) { + this.nodes.push({ + type: 'link', + data: { + alt: value, + src: href + }, + computed: md.link(value, href) + }); + return this; + } + + /** + * Append an emoji to the collection of TextNodes. + * + * @example + * ```ts + * const doc = new Text() + * .emoji('smile') + * .emoji('🙂') + * .toString(); + * // Output: ':smile: 🙂' + * + * @param value An emoji alias or unicode string to append to the collection of TextNodes. + * @returns A new instance of the Text class, with the appended emoji. + */ + public emoji(value: md.EmojiAlias | md.EmojiUnicode) { + this.nodes.push({ + type: 'emoji', + data: value.toString(), + computed: value.toString() + }); + return this; + } + + /* + |------------------ + | Outputs + |------------------ + */ + + /** + * Convert the text to a JSON representation, that can be used for serialization. + * + * @example + * ```ts + * const doc = new Text() + * .plaintext('Hello, World!') + * .code('console.log("foobar")') + * .toJSON(); + * // Output: + * // [ + * // { type: 'plaintext', data: 'Hello, World!', computed: 'Hello, World!' }, + * // { type: 'code', data: 'console.log("foobar")', computed: '`console.log("foobar")`' } + * // ] + * ``` + * + * @returns A JSON representation of the text, that can be used for serialization. + */ + public toJSON() { + return this.nodes; + } + + /** + * Convert the text to string representation, that can be used for rendering. + * + * @example + * ```ts + * const doc = new Text() + * .plaintext('Hello, World!') + * .code('console.log("foobar")') + * .toString(); + * // Output: 'Hello, World! `console.log("foobar")`' + * ``` + * + * @returns A string representation of the document, that can be used for rendering. + */ + public toString() { + let output = ''; + + for (const node of this.nodes) { + if (node.type === 'plaintext') { + if (node.options && node.options.append) { + output += node.computed; + continue; + } + } + + output += ` ${node.computed}`; + } + + return output.trim(); + } +} diff --git a/src/lib/Text.ts b/src/lib/Text.ts deleted file mode 100644 index 8f5270d..0000000 --- a/src/lib/Text.ts +++ /dev/null @@ -1,190 +0,0 @@ -import * as md from './markdown/markdown'; - -/* -|========================================================================== -| Text -|========================================================================== -| -| A collection of functions to generate markdown strings, with a chaining -| API for building a collection of TextNodes. -| -*/ - -/* -|------------------ -| TextNode Types -|------------------ -*/ - -export type TextNodeType = - | 'plaintext' - | 'code' - | 'append' - | 'bold' - | 'italic' - | 'strikeThrough' - | 'link' - | 'emoji'; - -interface BaseTextNode { - type: TextNodeType; - data: T; - computed: string; -} - -export interface PlainTextNode extends BaseTextNode { - type: 'plaintext'; - options?: { - append?: boolean; - }; -} - -export interface AppendTextNode extends BaseTextNode { - type: 'append'; -} - -export interface CodeTextNode extends BaseTextNode { - type: 'code'; -} - -export interface BoldTextNode extends BaseTextNode { - type: 'bold'; -} - -export interface ItalicTextNode extends BaseTextNode { - type: 'italic'; -} - -export interface StrikeThroughTextNode extends BaseTextNode { - type: 'strikeThrough'; -} - -export interface LinkTextNode - extends BaseTextNode<{ alt: string; src: string }> { - type: 'link'; -} - -export interface EmojiTextNode extends BaseTextNode { - type: 'emoji'; -} - -export type TextNode = - | PlainTextNode - | CodeTextNode - | AppendTextNode - | BoldTextNode - | ItalicTextNode - | StrikeThroughTextNode - | LinkTextNode - | EmojiTextNode; - -/* -|---------------------------------- -| Text Class -|---------------------------------- -| -| The primary class for building a set of TextNodes. This is used as the basis of -| certain Node within the Document class. -| -*/ - -export class Text { - private nodes: TextNode[] = []; - - public plainText(value: string, options?: { append?: boolean }) { - this.nodes.push({ - type: 'plaintext', - data: value, - computed: value, - options - }); - return this; - } - - public code(value: string) { - this.nodes.push({ - type: 'code', - data: value, - computed: md.code(value) - }); - return this; - } - - public bold(value: string) { - this.nodes.push({ - type: 'bold', - data: value, - computed: md.bold(value) - }); - return this; - } - - public italic(value: string) { - this.nodes.push({ - type: 'italic', - data: value, - computed: md.italic(value) - }); - return this; - } - - public strikeThrough(value: string) { - this.nodes.push({ - type: 'strikeThrough', - data: value, - computed: md.strikeThrough(value) - }); - return this; - } - - public link(value: string, href: string) { - this.nodes.push({ - type: 'link', - data: { - alt: value, - src: href - }, - computed: md.link(value, href) - }); - return this; - } - - public emoji(value: md.EmojiAlias | md.EmojiUnicode) { - this.nodes.push({ - type: 'emoji', - data: value.toString(), - computed: value.toString() - }); - return this; - } - - /* - |------------------ - | Outputs - |------------------ - */ - - public toJSON() { - return this.nodes; - } - - public toString() { - let output = ''; - - for (const node of this.nodes) { - if (node.type === 'plaintext') { - if (node.options && node.options.append) { - output += node.computed; - continue; - } - } - - output += ` ${node.computed}`; - } - - return output.trim(); - } -} - -const createText = () => new Text(); -export default createText; diff --git a/src/lib/__tests__/Document.test.ts b/src/lib/__tests__/TempoDocument.test.ts similarity index 87% rename from src/lib/__tests__/Document.test.ts rename to src/lib/__tests__/TempoDocument.test.ts index 196f9e6..a06e7f7 100644 --- a/src/lib/__tests__/Document.test.ts +++ b/src/lib/__tests__/TempoDocument.test.ts @@ -1,19 +1,14 @@ -import exp from 'constants'; -import createDocument, { +import { computeNodes, computeText, - Document, - DocumentNode -} from '../Document'; -import createText from '../Text'; + TempoDocument, + TempoDocumentNode +} from '../TempoDocument'; +import { TempoText } from '../TempoText'; describe('initialization', () => { - it('should return a document', () => { - expect(createDocument()).toBeInstanceOf(Document); - }); - it('should prebuild nodes based off input', () => { - const initJSON: DocumentNode[] = [ + const initJSON: TempoDocumentNode[] = [ { type: 'heading', data: { @@ -40,12 +35,12 @@ describe('initialization', () => { computed: 'Hello World!' } ]; - const document = createDocument(initJSON); + const document = new TempoDocument(initJSON); expect(document.toJSON()).toEqual(initJSON); }); it('should return empty nodes if no input', () => { - const document = createDocument(); + const document = new TempoDocument(); expect(document.toJSON()).toEqual([]); }); }); @@ -56,7 +51,7 @@ describe('computeText', () => { }); it('should compute text from `Text`', () => { - expect(computeText(createText().plainText('Hello World!'))).toEqual( + expect(computeText(new TempoText().plainText('Hello World!'))).toEqual( 'Hello World!' ); }); @@ -86,7 +81,7 @@ describe('computeNodes', () => { }); it('should compute nodes from `Text`', () => { - expect(computeNodes(createText().plainText('Hello World!'))).toEqual([ + expect(computeNodes(new TempoText().plainText('Hello World!'))).toEqual([ { type: 'plaintext', data: 'Hello World!', @@ -133,39 +128,39 @@ describe('Headings', () => { }; it('should add a h1 heading', () => { - const document = createDocument().h1('Hello World!'); + const document = new TempoDocument().h1('Hello World!'); expect(document.toJSON()).toEqual(getResult(1, 'Hello World!')); }); it('should add a h2 heading', () => { - const document = createDocument().h2('Hello World!'); + const document = new TempoDocument().h2('Hello World!'); expect(document.toJSON()).toEqual(getResult(2, 'Hello World!')); }); it('should add a h3 heading', () => { - const document = createDocument().h3('Hello World!'); + const document = new TempoDocument().h3('Hello World!'); expect(document.toJSON()).toEqual(getResult(3, 'Hello World!')); }); it('should add a h4 heading', () => { - const document = createDocument().h4('Hello World!'); + const document = new TempoDocument().h4('Hello World!'); expect(document.toJSON()).toEqual(getResult(4, 'Hello World!')); }); it('should add a h5 heading', () => { - const document = createDocument().h5('Hello World!'); + const document = new TempoDocument().h5('Hello World!'); expect(document.toJSON()).toEqual(getResult(5, 'Hello World!')); }); it('should add a h6 heading', () => { - const document = createDocument().h6('Hello World!'); + const document = new TempoDocument().h6('Hello World!'); expect(document.toJSON()).toEqual(getResult(6, 'Hello World!')); }); }); describe('Text Elements', () => { it('should add a paragraph', () => { - const document = createDocument().paragraph('Hello World!'); + const document = new TempoDocument().paragraph('Hello World!'); expect(document.toJSON()).toEqual([ { type: 'paragraph', @@ -184,7 +179,7 @@ describe('Text Elements', () => { describe('Special Elements', () => { it('should add a table', () => { - const document = createDocument().table([ + const document = new TempoDocument().table([ ['Hello World!', 'Hello 2 World!'], ['Hello 3 World!', 'Hello 4 World!'] ]); @@ -248,7 +243,7 @@ describe('Special Elements', () => { }); it('should add html', () => { - const document = createDocument().html('
Hello World!
'); + const document = new TempoDocument().html('
Hello World!
'); expect(document.toJSON()).toEqual([ { type: 'html', @@ -259,7 +254,7 @@ describe('Special Elements', () => { }); it('should add a codeBlock', () => { - const document = createDocument().codeBlock( + const document = new TempoDocument().codeBlock( 'console.log("Hello World!");', 'javascript' ); @@ -278,7 +273,7 @@ describe('Special Elements', () => { }); it('should add a blockquote', () => { - const document = createDocument().blockQuote('Hello World!'); + const document = new TempoDocument().blockQuote('Hello World!'); expect(document.toJSON()).toEqual([ { type: 'blockQuote', @@ -295,7 +290,7 @@ describe('Special Elements', () => { }); it('should add an image', () => { - const document = createDocument().image( + const document = new TempoDocument().image( 'example', 'https://example.com/image.png' ); @@ -313,7 +308,7 @@ describe('Special Elements', () => { }); it('should add a horizontal rule (break)', () => { - const document = createDocument().break(); + const document = new TempoDocument().break(); expect(document.toJSON()).toEqual([ { type: 'break', @@ -326,7 +321,7 @@ describe('Special Elements', () => { describe('Lists', () => { it('should add an bullet (unordered) list', () => { - const document = createDocument().bulletList([ + const document = new TempoDocument().bulletList([ 'Hello World!', 'Hello 2 World!', txt => txt.bold('Hello 3 World!') @@ -375,7 +370,7 @@ describe('Lists', () => { }); it('should add an number (ordered) list', () => { - const document = createDocument().numberList([ + const document = new TempoDocument().numberList([ 'Hello World!', 'Hello 2 World!' ]); @@ -417,7 +412,7 @@ describe('Lists', () => { describe('Outputs', () => { describe('toString', () => { it('should return a string', () => { - const document = createDocument() + const document = new TempoDocument() .h1('Hello World!') .paragraph('Hello there!'); expect(document.toString()).toEqual( @@ -432,7 +427,7 @@ Hello there! }); }); describe('toJSON', () => { - const document = createDocument() + const document = new TempoDocument() .h1('Hello World!') .paragraph('Hello there!'); expect(document.toJSON()).toEqual([ diff --git a/src/lib/__tests__/Text.test.ts b/src/lib/__tests__/TempoText.test.ts similarity index 93% rename from src/lib/__tests__/Text.test.ts rename to src/lib/__tests__/TempoText.test.ts index 1cd78c3..e1db606 100644 --- a/src/lib/__tests__/Text.test.ts +++ b/src/lib/__tests__/TempoText.test.ts @@ -1,13 +1,12 @@ -import { before } from 'node:test'; -import createText, { Text } from '../Text'; +import { TempoText } from '../TempoText'; import md from '../markdown'; jest.mock('../markdown/markdown'); -let txt: Text; +let txt: TempoText; beforeEach(() => { - txt = createText(); + txt = new TempoText(); }); afterEach(() => { @@ -79,12 +78,12 @@ describe('toString', () => { }); describe('outputs', () => { - let createText: () => Text; + let createText: () => TempoText; beforeEach(async () => { jest.unmock('../markdown/markdown'); jest.resetModules(); - const textImport = await import('../Text'); - createText = textImport.default; + const textImport = await import('../TempoText'); + createText = () => new textImport.TempoText(); }); describe('toString', () => { diff --git a/src/lib/__tests__/tempo.test.ts b/src/lib/__tests__/tempo.test.ts new file mode 100644 index 0000000..55e7a05 --- /dev/null +++ b/src/lib/__tests__/tempo.test.ts @@ -0,0 +1,8 @@ +import { TempoDocument } from '../TempoDocument'; +import tempo from '../tempo'; + +describe('tempo', () => { + it('should return a document', () => { + expect(tempo()).toBeInstanceOf(TempoDocument); + }); +}); diff --git a/src/lib/markdown/codeBlock.ts b/src/lib/markdown/codeBlock.ts index 0954057..10e323c 100644 --- a/src/lib/markdown/codeBlock.ts +++ b/src/lib/markdown/codeBlock.ts @@ -74,11 +74,18 @@ export const supportedLanguages = [ 'xml', 'yaml' ] as const; + +/** + * A language supported by GitHub Linguist + * + * @link https://github.com/github-linguist/linguist/blob/master/lib/linguist/languages.yml + */ export type SupportedLanguage = (typeof supportedLanguages)[number]; /** - * @throws if the string is not a valid (supported) language by GitHub-Linguist which is used to provide - * syntax highlighting for GitHub markdown files. + * Assert that a language is supported by the codeblock. + * + * @throws if the string is not a valid (supported) language by GitHub-Linguist which is used to provide syntax highlighting for GitHub markdown files. */ export function assertSupportedLanguage(language: SupportedLanguage) { if (!supportedLanguages.includes(language)) { diff --git a/src/lib/markdown/emoji.ts b/src/lib/markdown/emoji.ts index 23e3e06..dcbe1ea 100644 --- a/src/lib/markdown/emoji.ts +++ b/src/lib/markdown/emoji.ts @@ -9101,7 +9101,15 @@ export const supportedEmojis = [ category: 'Symbol' } ] as const; + +/** + * Alias for emojis, primarily for use with colons, i.e. `:smile:` + */ export type EmojiAlias = (typeof supportedEmojis)[number]['alias']; + +/** + * The unicode value for emojis + */ export type EmojiUnicode = Exclude< (typeof supportedEmojis)[number]['unicode'], boolean @@ -9128,6 +9136,8 @@ export function isSupportedAlias( } /** + * Assert that the given emoji is a supported emoji + * * @throws if the given emoji is not valid */ export function assertSupportedEmoji( diff --git a/src/lib/tempo.ts b/src/lib/tempo.ts new file mode 100644 index 0000000..d456410 --- /dev/null +++ b/src/lib/tempo.ts @@ -0,0 +1,44 @@ +import { TempoDocument, type TempoDocumentNode } from './TempoDocument'; + +/* +|---------------------------------- +| Public API +|---------------------------------- +| +| The public API for creating a document. It is a wrapper around the Document class +| and provides a chaining API for building a document. +| +| NOTE: +| We do export the Document class, types, and interfaces, as we want to allow +| for custom implementations of the Document class. +| +*/ + +/** + * Create a new Document instance and build, append, or modify the DocumentNodes. + * + * @example + * ```ts + * const doc = tempo() + * .h1('Hello, World!') + * .paragraph((text) => { + * return text + * .plainText('This is a paragraph with ') + * .link('a link', 'https://example.com') + * }) + * .bulletList([ + * 'Item 1', + * (text) => text.bold('Item 2'), + * (text) => text.italic('Item 3') + * ]) + * .toString(); + * ``` + * + * @param documentNodes A list of DocumentNodes to initialize the Document. + * @returns A new Document instance. + */ +const tempo = (documentNodes?: TempoDocumentNode[]) => { + return new TempoDocument(documentNodes); +}; + +export default tempo; diff --git a/tsconfig.json b/tsconfig.json index 1ff982a..c7a7461 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ "moduleResolution": "node", "sourceMap": true, "declaration": true, + "removeComments": false, "lib": ["ES2022"], "outDir": "./dist", "baseUrl": ".",