├── src ├── Implementation │ ├── Parsing │ │ ├── Strings.ts │ │ ├── Inline │ │ │ ├── ParseableToken.ts │ │ │ ├── Tokenizing │ │ │ │ ├── EncloseWithinConventionArgs.ts │ │ │ │ ├── RichConvention.ts │ │ │ │ ├── Bracket.ts │ │ │ │ ├── ForgivingConventions │ │ │ │ │ └── ActiveStartDelimiter.ts │ │ │ │ ├── Token.ts │ │ │ │ ├── OpenConvention.ts │ │ │ │ ├── BacktrackedConventionHelper.ts │ │ │ │ ├── TextConsumer.ts │ │ │ │ ├── trimEscapedAndUnescapedOuterWhitespace.ts │ │ │ │ └── ConventionDefinition.ts │ │ │ ├── MediaConvention.ts │ │ │ ├── getInlineSyntaxNodes.ts │ │ │ ├── RichConventionWithoutExtraFields.ts │ │ │ ├── TokenRole.ts │ │ │ ├── MediaConventions.ts │ │ │ └── RichConventions.ts │ │ ├── isWhitespace.ts │ │ ├── parseInline.ts │ │ ├── Outline │ │ │ ├── OutlineParser.ts │ │ │ ├── tryToParseThematicBreakStreak.ts │ │ │ ├── isLineFancyOutlineConvention.ts │ │ │ ├── tryToPromoteMediaToOutline.ts │ │ │ ├── tryToParseBlockquote.ts │ │ │ ├── tryToParseRevealableBlock.ts │ │ │ ├── LineConsumer.ts │ │ │ ├── tryToParseCodeBlock.ts │ │ │ ├── tryToParseBlankLineSeparation.ts │ │ │ ├── HeadingLeveler.ts │ │ │ ├── getIndentedBlock.ts │ │ │ ├── tryToParseBulletedList.ts │ │ │ ├── getOutlineSyntaxNodes.ts │ │ │ └── tryToParseHeading.ts │ │ └── parse.ts │ ├── SyntaxNodes │ │ ├── InlineDocument.ts │ │ ├── ParentheticalSyntaxNode.ts │ │ ├── SyntaxNode.ts │ │ ├── getTextAppearingInline.ts │ │ ├── getInlineDescendants.ts │ │ ├── Audio.ts │ │ ├── Image.ts │ │ ├── Video.ts │ │ ├── InlineQuote.ts │ │ ├── Highlight.ts │ │ ├── Blockquote.ts │ │ ├── InlineRevealable.ts │ │ ├── NormalParenthetical.ts │ │ ├── SquareParenthetical.ts │ │ ├── Bold.ts │ │ ├── InlineSyntaxNode.ts │ │ ├── Italic.ts │ │ ├── Stress.ts │ │ ├── Emphasis.ts │ │ ├── InlineSyntaxNodeContainer.ts │ │ ├── Text.ts │ │ ├── OutlineSyntaxNode.ts │ │ ├── InlineCode.ts │ │ ├── RichInlineSyntaxNode.ts │ │ ├── ExampleUserInput.ts │ │ ├── RevealableBlock.ts │ │ ├── RichOutlineSyntaxNode.ts │ │ ├── README.md │ │ ├── CodeBlock.ts │ │ ├── ThematicBreak.ts │ │ ├── OutlineSyntaxNodeContainer.ts │ │ ├── Paragraph.ts │ │ ├── FootnoteBlock.ts │ │ ├── Link.ts │ │ ├── Heading.ts │ │ ├── LineBlock.ts │ │ ├── MediaSyntaxNode.ts │ │ ├── BulletedList.ts │ │ ├── Footnote.ts │ │ ├── NumberedList.ts │ │ ├── DescriptionList.ts │ │ ├── Table.ts │ │ ├── SectionLink.ts │ │ └── Document.ts │ ├── Rendering │ │ ├── Html │ │ │ ├── HtmlEscapingHelpers.ts │ │ │ └── HtmlElementHelpers.ts │ │ └── Renderer.ts │ ├── Patterns.ts │ ├── CollectionHelpers.ts │ ├── StringHelpers.ts │ ├── PatternPieces.ts │ ├── Settings.ts │ ├── PatternHelpers.ts │ └── Up.ts └── Test │ ├── Parsing │ ├── Settings │ │ ├── Helpers.ts │ │ ├── FancyEllipsis.ts │ │ ├── Video.ts │ │ ├── Audio.ts │ │ └── Image.ts │ ├── EdgeCasesAndPotentialBugs │ │ ├── InlineRevealable.ts │ │ ├── Emptiness │ │ │ └── Outline.ts │ │ ├── LinkifiedConvention.ts │ │ ├── Blockquote.ts │ │ ├── Bold.ts │ │ ├── Stress.ts │ │ ├── ThematicBreak.ts │ │ ├── EmptyTableCell.ts │ │ ├── InlineCode.ts │ │ ├── SourceMap.ts │ │ ├── Paragraph.ts │ │ ├── InflectionWithBothAsterisksAndUnderscores.ts │ │ ├── Escaping.ts │ │ ├── Overlapping.ts │ │ ├── RevealableBlock.ts │ │ ├── DelimitersRepresentingContent.ts │ │ ├── ImbalancedAsteriskInflection.ts │ │ ├── ImbalancedUnderscoreInflection.ts │ │ ├── Audio.ts │ │ └── Video.ts │ ├── Emoji.ts │ ├── InlineDocument │ │ ├── OutlineConventions.ts │ │ └── OuterWhitespace.ts │ ├── Bold.ts │ ├── Stress.ts │ ├── NumberedList │ │ └── Start.ts │ ├── Escaping.ts │ ├── Italics.ts │ ├── Emphasis.ts │ ├── Table │ │ ├── WithHeaderRowAndHeaderColumn │ │ │ └── OmittedCell.ts │ │ └── WithHeaderRow │ │ │ └── OmittedCell.ts │ ├── Overlapping │ │ ├── DoubleParentheticals.ts │ │ └── LinkifiedConventions.ts │ ├── PlusMinusSign.ts │ └── EnDash.ts │ └── Html │ └── Settings │ ├── SectionReferencedByTableOfContents.ts │ ├── BaseForUrlsStartingWithFragmentIdentifier.ts │ ├── Footnote.ts │ ├── FootnoteReference.ts │ ├── Reveal.ts │ └── Hide.ts ├── .gitignore ├── .eslintrc.js ├── README.md ├── tsconfig.json ├── verify-package-settings.js ├── package.json └── LICENSE /src/Implementation/Parsing/Strings.ts: -------------------------------------------------------------------------------- 1 | // "Escapes" the following character, disabling any special meaning it may have had. 2 | export const BACKSLASH = '\\' 3 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/ParseableToken.ts: -------------------------------------------------------------------------------- 1 | import { TokenRole } from './TokenRole' 2 | 3 | 4 | export interface ParseableToken { 5 | role: TokenRole 6 | value?: string 7 | } 8 | -------------------------------------------------------------------------------- /src/Test/Parsing/Settings/Helpers.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from '../../../Implementation/Settings' 2 | 3 | 4 | export function settingsFor(changes: Settings.Parsing): Settings { 5 | return { parsing: changes } 6 | } 7 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/InlineDocument.ts: -------------------------------------------------------------------------------- 1 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 2 | 3 | 4 | export class InlineDocument extends InlineSyntaxNodeContainer { 5 | protected readonly INLINE_DOCUMENT = undefined 6 | } 7 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/EncloseWithinConventionArgs.ts: -------------------------------------------------------------------------------- 1 | import { RichConvention } from './RichConvention' 2 | 3 | 4 | export interface EncloseWithinConventionArgs { 5 | richConvention: RichConvention 6 | startingBackAtTokenIndex: number 7 | } 8 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/ParentheticalSyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 2 | 3 | 4 | export abstract class ParentheticalSyntaxNode extends RichInlineSyntaxNode { 5 | protected readonly PARENTHETICAL_SYNTAX_NODE = undefined 6 | } 7 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/SyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | 4 | 5 | export interface SyntaxNode { 6 | inlineDescendants(): InlineSyntaxNode[] 7 | render(renderer: Renderer): string 8 | } 9 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/getTextAppearingInline.ts: -------------------------------------------------------------------------------- 1 | import { InlineSyntaxNode } from './InlineSyntaxNode' 2 | 3 | 4 | export function getTextAppearingInline(nodes: InlineSyntaxNode[]): string { 5 | return nodes 6 | .map(node => node.textAppearingInline()) 7 | .join('') 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # All JavaScript, source maps, and type declarations generated by TypeScript's compiler 2 | compiled 3 | 4 | # Visual Studio Code workspace settings 5 | .vscode 6 | 7 | # WebStorm workspace settings 8 | .idea 9 | 10 | # Folder appearance options for macOS 11 | *.DS_Store 12 | 13 | # Dependencies 14 | node_modules 15 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/getInlineDescendants.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | 4 | 5 | export function getInlineDescendants(nodes: InlineSyntaxNode[]): InlineSyntaxNode[] { 6 | return concat( 7 | nodes.map(node => [node, ...node.inlineDescendants()])) 8 | } 9 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/isWhitespace.ts: -------------------------------------------------------------------------------- 1 | import { BLANK_PATTERN } from '../Patterns' 2 | import { InlineSyntaxNode } from '../SyntaxNodes/InlineSyntaxNode' 3 | import { Text } from '../SyntaxNodes/Text' 4 | 5 | 6 | export function isWhitespace(node: InlineSyntaxNode): boolean { 7 | return (node instanceof Text) && BLANK_PATTERN.test(node.text) 8 | } 9 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Audio.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { MediaSyntaxNode } from './MediaSyntaxNode' 3 | 4 | 5 | export class Audio extends MediaSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.audio(this) 8 | } 9 | 10 | protected readonly AUDIO = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Image.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { MediaSyntaxNode } from './MediaSyntaxNode' 3 | 4 | 5 | export class Image extends MediaSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.image(this) 8 | } 9 | 10 | protected readonly IMAGE = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Video.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { MediaSyntaxNode } from './MediaSyntaxNode' 3 | 4 | 5 | export class Video extends MediaSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.video(this) 8 | } 9 | 10 | protected readonly VIDEO = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/InlineQuote.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineRevealable } from './InlineRevealable' 3 | 4 | 5 | export class InlineQuote extends InlineRevealable { 6 | render(renderer: Renderer): string { 7 | return renderer.inlineQuote(this) 8 | } 9 | 10 | protected readonly INLINE_QUOTE = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/RichConvention.ts: -------------------------------------------------------------------------------- 1 | import { TokenRole } from '../TokenRole' 2 | 3 | 4 | // In the context of tokenization, a rich inline convention is one that can contain 5 | // other inline conventions between its start and end tokens. 6 | export interface RichConvention { 7 | startTokenRole: TokenRole 8 | endTokenRole: TokenRole 9 | } 10 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Highlight.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 3 | 4 | 5 | export class Highlight extends RichInlineSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.highlight(this) 8 | } 9 | 10 | protected readonly HIGHLIGHT = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/Bracket.ts: -------------------------------------------------------------------------------- 1 | import { escapeForRegex } from '../../../PatternHelpers' 2 | 3 | 4 | export class Bracket { 5 | startPattern: string 6 | endPattern: string 7 | 8 | constructor(public open: string, public close: string) { 9 | this.startPattern = escapeForRegex(open) 10 | this.endPattern = escapeForRegex(close) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Blockquote.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichOutlineSyntaxNode } from './RichOutlineSyntaxNode' 3 | 4 | 5 | export class Blockquote extends RichOutlineSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.blockquote(this) 8 | } 9 | 10 | protected readonly BLOCKQUOTE = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/InlineRevealable.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 3 | 4 | 5 | export class InlineRevealable extends RichInlineSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.inlineRevealable(this) 8 | } 9 | 10 | protected readonly INLINE_REVEALABLE = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/NormalParenthetical.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { ParentheticalSyntaxNode } from './ParentheticalSyntaxNode' 3 | 4 | 5 | export class NormalParenthetical extends ParentheticalSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.normalParenthetical(this) 8 | } 9 | 10 | protected readonly NORMAL_PARENTHETICAL = undefined 11 | } 12 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/SquareParenthetical.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { ParentheticalSyntaxNode } from './ParentheticalSyntaxNode' 3 | 4 | 5 | export class SquareParenthetical extends ParentheticalSyntaxNode { 6 | render(renderer: Renderer): string { 7 | return renderer.squareParenthetical(this) 8 | } 9 | 10 | protected readonly SQUARE_PARENTHETICAL = undefined 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: [ 5 | '@typescript-eslint', 6 | ], 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | ], 11 | rules: { 12 | "no-constant-condition": ["error", { "checkLoops": false }], 13 | "@typescript-eslint/no-namespace": "off" 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Bold.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 3 | 4 | 5 | // Equivalent to the `` HTML element. 6 | // 7 | // Not to be confused with `Stress`! 8 | export class Bold extends RichInlineSyntaxNode { 9 | render(renderer: Renderer): string { 10 | return renderer.bold(this) 11 | } 12 | 13 | protected readonly BOLD = undefined 14 | } 15 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/InlineSyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { SyntaxNode } from './SyntaxNode' 2 | 3 | 4 | export interface InlineSyntaxNode extends SyntaxNode { 5 | // Represents the text of the syntax node as it should appear inline. Some inline conventions 6 | // don't have any (e.g. footnotes and images). 7 | // 8 | // Ultimately, table cells use this method determine whether their content is numeric. 9 | textAppearingInline(): string 10 | } 11 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Italic.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 3 | 4 | 5 | // Equivalent to the `` HTML element. 6 | // 7 | // Not to be confused with `Emphasis`! 8 | export class Italic extends RichInlineSyntaxNode { 9 | render(renderer: Renderer): string { 10 | return renderer.italic(this) 11 | } 12 | 13 | protected readonly ITALIC = undefined 14 | } 15 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Stress.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 3 | 4 | 5 | // Equivalent to the `` HTML element. 6 | // 7 | // Not to be confused with `Bold`! 8 | export class Stress extends RichInlineSyntaxNode { 9 | render(renderer: Renderer): string { 10 | return renderer.stress(this) 11 | } 12 | 13 | protected readonly STRESS = undefined 14 | } 15 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Emphasis.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 3 | 4 | 5 | // Equivalent to the `` HTML element. 6 | // 7 | // Not to be confused with `Italic`! 8 | export class Emphasis extends RichInlineSyntaxNode { 9 | render(renderer: Renderer): string { 10 | return renderer.emphasis(this) 11 | } 12 | 13 | protected readonly EMPHASIS = undefined 14 | } 15 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/MediaConvention.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from '../../NormalizedSettings' 2 | import { MediaSyntaxNodeType } from '../../SyntaxNodes/MediaSyntaxNode' 3 | import { TokenRole } from './TokenRole' 4 | 5 | 6 | export interface MediaConvention { 7 | keyword: (terms: NormalizedSettings.Parsing.Keywords) => NormalizedSettings.Parsing.Keyword 8 | SyntaxNodeType: MediaSyntaxNodeType 9 | tokenRoleForStartAndDescription: TokenRole 10 | } 11 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/InlineSyntaxNodeContainer.ts: -------------------------------------------------------------------------------- 1 | import { getInlineDescendants } from './getInlineDescendants' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | 4 | 5 | export abstract class InlineSyntaxNodeContainer { 6 | constructor(public children: InlineSyntaxNode[]) { } 7 | 8 | // All inline descendants (including `children`, grandchildren, etc.). 9 | inlineDescendants(): InlineSyntaxNode[] { 10 | return getInlineDescendants(this.children) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/parseInline.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from '../NormalizedSettings' 2 | import { InlineDocument } from '../SyntaxNodes/InlineDocument' 3 | import { getInlineSyntaxNodesForInlineDocument } from './Inline/getInlineSyntaxNodes' 4 | 5 | 6 | export function parseInline(inlineMarkup: string, settings: NormalizedSettings.Parsing): InlineDocument { 7 | const children = getInlineSyntaxNodesForInlineDocument(inlineMarkup, settings) 8 | return new InlineDocument(children) 9 | } 10 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Text.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | 4 | 5 | export class Text implements InlineSyntaxNode { 6 | constructor(public text: string) { } 7 | 8 | textAppearingInline(): string { 9 | return this.text 10 | } 11 | 12 | inlineDescendants(): InlineSyntaxNode[] { 13 | return [] 14 | } 15 | 16 | render(renderer: Renderer): string { 17 | return renderer.text(this) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/OutlineSyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { Heading } from './Heading' 2 | import { SyntaxNode } from './SyntaxNode' 3 | 4 | 5 | export interface OutlineSyntaxNode extends SyntaxNode { 6 | // The first line of markup that produced this syntax node. Source line numbers 7 | // start at 1, not 0. 8 | sourceLineNumber?: number 9 | 10 | // Any descendants (children, grandchildren, etc.) to include in the table of 11 | // contents. 12 | descendantsToIncludeInTableOfContents(): Heading[] 13 | } 14 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/InlineCode.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | 4 | 5 | export class InlineCode implements InlineSyntaxNode { 6 | constructor(public code: string) { } 7 | 8 | textAppearingInline(): string { 9 | return this.code 10 | } 11 | 12 | inlineDescendants(): InlineSyntaxNode[] { 13 | return [] 14 | } 15 | 16 | render(renderer: Renderer): string { 17 | return renderer.inlineCode(this) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/RichInlineSyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { getTextAppearingInline } from './getTextAppearingInline' 3 | import { InlineSyntaxNode } from './InlineSyntaxNode' 4 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 5 | 6 | 7 | export abstract class RichInlineSyntaxNode extends InlineSyntaxNodeContainer implements InlineSyntaxNode { 8 | textAppearingInline(): string { 9 | return getTextAppearingInline(this.children) 10 | } 11 | 12 | abstract render(renderer: Renderer): string 13 | } 14 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/ExampleUserInput.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | 4 | 5 | // Its HTML equivalent is the `` element. 6 | export class ExampleUserInput implements InlineSyntaxNode { 7 | constructor(public userInput: string) { } 8 | 9 | textAppearingInline(): string { 10 | return this.userInput 11 | } 12 | 13 | inlineDescendants(): InlineSyntaxNode[] { 14 | return [] 15 | } 16 | 17 | render(renderer: Renderer): string { 18 | return renderer.exampleUserInput(this) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | What’s Up? 2 | ========== 3 | 4 | Up is a markup language for writing structured documents in plain text. For more information, check out [tryup.org!](https://tryup.org) 5 | 6 | Up supports: 7 | 8 | - Super easy tables 9 | - Revealable content (e.g. spoilers) 10 | - Description lists 11 | - Overlapping inline writing conventions 12 | - So much more! 13 | 14 | This is the repository for [@xcvz/up](https://www.npmjs.com/package/@xcvz/up), a Node.js package for converting Up markup into HTML. 15 | 16 | If you’re looking for tryup.org’s GitHub repository, [it’s over this way.](https://github.com/start/tryup.org) 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es6"], 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noUnusedParameters": true, 10 | "noUnusedLocals": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "removeComments": true, 14 | "outDir": "compiled", 15 | "rootDir": "src", 16 | "declaration": true, 17 | "noEmitOnError": true 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "compiled" 22 | ] 23 | } -------------------------------------------------------------------------------- /src/Implementation/Rendering/Html/HtmlEscapingHelpers.ts: -------------------------------------------------------------------------------- 1 | export function escapeHtmlContent(content: string): string { 2 | return escapeHtml(content, /[&<]/g) 3 | } 4 | 5 | export function escapeHtmlAttrValue(attrValue: string | number): string { 6 | return escapeHtml(String(attrValue), /[&"]/g) 7 | } 8 | 9 | function escapeHtml(html: string, charsToEscape: RegExp): string { 10 | return html.replace( 11 | charsToEscape, 12 | char => ESCAPED_HTML_ENTITIES_BY_CHAR[char]) 13 | } 14 | 15 | const ESCAPED_HTML_ENTITIES_BY_CHAR: { [char: string]: string } = { 16 | '&': '&', 17 | '<': '<', 18 | '"': '"' 19 | } 20 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/OutlineParser.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from '../../NormalizedSettings' 2 | import { OutlineSyntaxNode } from '../../SyntaxNodes/OutlineSyntaxNode' 3 | import { HeadingLeveler } from './HeadingLeveler' 4 | 5 | export namespace OutlineParser { 6 | export interface Args { 7 | markupLines: string[] 8 | mostRecentSibling?: OutlineSyntaxNode 9 | sourceLineNumber: number 10 | headingLeveler: HeadingLeveler 11 | settings: NormalizedSettings.Parsing 12 | } 13 | 14 | export type Result = null | { 15 | parsedNodes: OutlineSyntaxNode[] 16 | countLinesConsumed: number 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/RevealableBlock.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { RichOutlineSyntaxNode } from './RichOutlineSyntaxNode' 4 | 5 | 6 | export class RevealableBlock extends RichOutlineSyntaxNode { 7 | render(renderer: Renderer): string { 8 | return renderer.revealableBlock(this) 9 | } 10 | 11 | // As a rule, we don't want to include any revealable (i.e. initially hidden) headings in the 12 | // table of contents. 13 | descendantsToIncludeInTableOfContents(): Heading[] { 14 | return [] 15 | } 16 | 17 | protected readonly REVEALABLE_BLOCK = undefined 18 | } 19 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/RichOutlineSyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 3 | import { OutlineSyntaxNodeContainer } from './OutlineSyntaxNodeContainer' 4 | 5 | 6 | export abstract class RichOutlineSyntaxNode extends OutlineSyntaxNodeContainer implements OutlineSyntaxNode { 7 | sourceLineNumber?: number 8 | 9 | constructor( 10 | children: OutlineSyntaxNode[], 11 | options?: { sourceLineNumber: number } 12 | ) { 13 | super(children) 14 | this.sourceLineNumber = options?.sourceLineNumber 15 | } 16 | 17 | abstract render(renderer: Renderer): string 18 | } 19 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/getInlineSyntaxNodes.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from '../../NormalizedSettings' 2 | import { InlineSyntaxNode } from '../../SyntaxNodes/InlineSyntaxNode' 3 | import { parse } from './parse' 4 | import { tokenize, tokenizeForInlineDocument } from './Tokenizing/tokenize' 5 | 6 | 7 | export function getInlineSyntaxNodes(inlineMarkup: string, settings: NormalizedSettings.Parsing): InlineSyntaxNode[] { 8 | return parse(tokenize(inlineMarkup, settings)) 9 | } 10 | 11 | export function getInlineSyntaxNodesForInlineDocument(inlineMarkup: string, settings: NormalizedSettings.Parsing): InlineSyntaxNode[] { 12 | return parse(tokenizeForInlineDocument(inlineMarkup, settings)) 13 | } 14 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/ForgivingConventions/ActiveStartDelimiter.ts: -------------------------------------------------------------------------------- 1 | export class ActiveStartDelimiter { 2 | constructor( 3 | public tokenIndex: number, 4 | public delimiterText: string, 5 | public remainingLength = delimiterText.length) { 6 | } 7 | 8 | isUnused(): boolean { 9 | return this.remainingLength === this.delimiterText.length 10 | } 11 | 12 | isFullyExhausted(): boolean { 13 | return this.remainingLength <= 0 14 | } 15 | 16 | shortenBy(length: number): void { 17 | this.remainingLength -= length 18 | } 19 | 20 | clone(): ActiveStartDelimiter { 21 | return new ActiveStartDelimiter(this.tokenIndex, this.delimiterText, this.remainingLength) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/parse.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from '../NormalizedSettings' 2 | import { Document } from '../SyntaxNodes/Document' 3 | import { getOutlineSyntaxNodes } from './Outline/getOutlineSyntaxNodes' 4 | import { HeadingLeveler } from './Outline/HeadingLeveler' 5 | 6 | 7 | export function parse(markup: string, settings: NormalizedSettings.Parsing): Document { 8 | const children = getOutlineSyntaxNodes({ 9 | markupLines: markup.split(MARKUP_LINE_BREAK), 10 | sourceLineNumber: 1, 11 | headingLeveler: new HeadingLeveler(), 12 | settings 13 | }) 14 | 15 | return Document.create(children) 16 | } 17 | 18 | // Eventually, this should be configurable. 19 | export const MARKUP_LINE_BREAK = '\n' 20 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/README.md: -------------------------------------------------------------------------------- 1 | We want our syntax node classes to be considered distinct by TypeScript's type system. 2 | 3 | Unfortunately for us, TypeScript uses [structural typing](https://en.wikipedia.org/wiki/Structural_type_system), which means type compatibility is determined *only* by an object's structure. 4 | 5 | To work around this, syntax node classes that would otherwise be considered equivalent are each given a unique "anti-structural-typing" field. 6 | 7 | These fields are: 8 | 9 | 1. Named in screaming case after their class (e.g. `EMPHASIS`) and their parent classes if there are any (e.g. `NUMBERED_LIST_ITEM`) 10 | 2. Set to undefined 11 | 3. Protected, because unused private fields are disallowed by the `noUnusedLocals` compiler option -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/Token.ts: -------------------------------------------------------------------------------- 1 | import { ParseableToken } from '../ParseableToken' 2 | import { TokenRole } from '../TokenRole' 3 | 4 | 5 | export class Token implements ParseableToken { 6 | // TODO: Use separate types for start/end tokens? 7 | correspondingEnclosingToken?: Token 8 | 9 | constructor( 10 | public role: TokenRole, 11 | public value?: string 12 | ) { } 13 | 14 | // Associates a start token with an end token. 15 | // 16 | // This helps us determine when conventions overlap. 17 | enclosesContentBetweenItselfAnd(correspondingEnclosingToken: Token): void { 18 | this.correspondingEnclosingToken = correspondingEnclosingToken 19 | correspondingEnclosingToken.correspondingEnclosingToken = this 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/CodeBlock.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { InlineSyntaxNode } from './InlineSyntaxNode' 4 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 5 | 6 | 7 | export class CodeBlock implements OutlineSyntaxNode { 8 | sourceLineNumber?: number 9 | 10 | constructor(public code: string, options?: { sourceLineNumber: number }) { 11 | this.sourceLineNumber = options?.sourceLineNumber 12 | } 13 | 14 | descendantsToIncludeInTableOfContents(): Heading[] { 15 | return [] 16 | } 17 | 18 | inlineDescendants(): InlineSyntaxNode[] { 19 | return [] 20 | } 21 | 22 | render(renderer: Renderer): string { 23 | return renderer.codeBlock(this) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/RichConventionWithoutExtraFields.ts: -------------------------------------------------------------------------------- 1 | import { InlineSyntaxNode } from '../../SyntaxNodes/InlineSyntaxNode' 2 | import { RichInlineSyntaxNode } from '../../SyntaxNodes/RichInlineSyntaxNode' 3 | import { TokenRole } from './TokenRole' 4 | 5 | 6 | // A rich inline convention is one that can contain other inline conventions. 7 | // 8 | // The `RichConventionWithoutExtraFields` interface represents rich inline conventions 9 | // whose syntax nodes can be produced without any extra fields. This excludes links, 10 | // because their URL is required. 11 | export interface RichConventionWithoutExtraFields { 12 | SyntaxNodeType: new (children: InlineSyntaxNode[]) => RichInlineSyntaxNode 13 | startTokenRole: TokenRole 14 | endTokenRole: TokenRole 15 | } 16 | -------------------------------------------------------------------------------- /src/Implementation/Patterns.ts: -------------------------------------------------------------------------------- 1 | import { anyCharFrom, either, exactly, patternStartingWith, solely, streakOf } from './PatternHelpers' 2 | import { INLINE_WHITESPACE_CHAR, URL_SCHEME, WHITESPACE_CHAR } from './PatternPieces' 3 | 4 | 5 | const INDENT = 6 | either('\t', exactly(2, INLINE_WHITESPACE_CHAR)) 7 | 8 | export const INDENTED_PATTERN = 9 | patternStartingWith(INDENT) 10 | 11 | export const DIVIDER_STREAK_PATTERN = 12 | streakOf( 13 | anyCharFrom('#', '=', '-', '+', '~', '*', '@', ':')) 14 | 15 | export const BLANK_PATTERN = 16 | solely('') 17 | 18 | export const NON_BLANK_PATTERN = 19 | /\S/ 20 | 21 | export const WHITESPACE_CHAR_PATTERN = 22 | new RegExp(WHITESPACE_CHAR) 23 | 24 | export const URL_SCHEME_PATTERN = 25 | patternStartingWith(URL_SCHEME) 26 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/TokenRole.ts: -------------------------------------------------------------------------------- 1 | export enum TokenRole { 2 | AudioStartAndDescription = 1, 3 | BareUrl, 4 | BoldEnd, 5 | BoldStart, 6 | EmphasisEnd, 7 | EmphasisStart, 8 | ExampleUserInput, 9 | FootnoteEnd, 10 | FootnoteStart, 11 | HighlightEnd, 12 | HighlightStart, 13 | ImageStartAndDescription, 14 | InlineCode, 15 | InlineQuoteEnd, 16 | InlineQuoteStart, 17 | InlineRevealableEnd, 18 | InlineRevealableStart, 19 | ItalicEnd, 20 | ItalicStart, 21 | LinkEndAndUrl, 22 | LinkStart, 23 | MediaEndAndUrl, 24 | NormalParentheticalEnd, 25 | NormalParentheticalStart, 26 | SectionLink, 27 | SquareParentheticalEnd, 28 | SquareParentheticalStart, 29 | StressEnd, 30 | StressStart, 31 | Text, 32 | VideoStartAndDescription 33 | } 34 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/ThematicBreak.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { InlineSyntaxNode } from './InlineSyntaxNode' 4 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 5 | 6 | 7 | export class ThematicBreak implements OutlineSyntaxNode { 8 | sourceLineNumber?: number 9 | 10 | constructor(options?: { sourceLineNumber: number }) { 11 | this.sourceLineNumber = options?.sourceLineNumber 12 | } 13 | 14 | descendantsToIncludeInTableOfContents(): Heading[] { 15 | return [] 16 | } 17 | 18 | inlineDescendants(): InlineSyntaxNode[] { 19 | return [] 20 | } 21 | 22 | render(renderer: Renderer): string { 23 | return renderer.thematicBreak(this) 24 | } 25 | 26 | protected readonly THEMATIC_BREAK = undefined 27 | } 28 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/InlineRevealable.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('An inline revealable convention', () => { 7 | it('can be the first convention inside another inline revealable convention using same bracket type', () => { 8 | expect(Up.parse('After you beat the Elite Four, [SPOILER: [SPOILER: Gary] fights you].')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('After you beat the Elite Four, '), 11 | new Up.InlineRevealable([ 12 | new Up.InlineRevealable([ 13 | new Up.Text('Gary') 14 | ]), 15 | new Up.Text(' fights you') 16 | ]), 17 | new Up.Text('.') 18 | ])) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/Implementation/CollectionHelpers.ts: -------------------------------------------------------------------------------- 1 | // Returns the last item from `collection`. 2 | export function last(collection: T[]): T | undefined{ 3 | return collection[collection.length - 1] 4 | } 5 | 6 | // Returns a single flattened array containing every item from every array in `collections`. 7 | // 8 | // The items' order is preserved. 9 | export function concat(collections: T[][]): T[] { 10 | return ([] as T[]).concat(...collections) 11 | } 12 | 13 | // Returns an array containing the distinct values in `values` using strict equality. 14 | // 15 | // The values' order is preserved. 16 | export function distinct(...values: T[]): T[] { 17 | return values.reduce((distinctValues, value) => 18 | (distinctValues.indexOf(value) !== -1) 19 | ? distinctValues 20 | : distinctValues.concat([value]) 21 | , [] as T[]) 22 | } 23 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/OutlineSyntaxNodeContainer.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { Document } from './Document' 3 | import { Heading } from './Heading' 4 | import { InlineSyntaxNode } from './InlineSyntaxNode' 5 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 6 | 7 | 8 | export abstract class OutlineSyntaxNodeContainer { 9 | constructor(public children: OutlineSyntaxNode[]) { } 10 | 11 | // Any descendants (children, grandchildren, etc.) to include in the table of 12 | // contents. 13 | descendantsToIncludeInTableOfContents(): Heading[] { 14 | return Document.TableOfContents.getEntries(this.children) 15 | } 16 | 17 | // All inline descendants of `children`. 18 | inlineDescendants(): InlineSyntaxNode[] { 19 | return concat( 20 | this.children.map(node => node.inlineDescendants())) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Emptiness/Outline.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../../Main' 3 | 4 | 5 | describe('An empty document', () => { 6 | it('produces an empty document object', () => { 7 | expect(Up.parse('')).to.deep.equal(new Up.Document([])) 8 | }) 9 | }) 10 | 11 | 12 | describe('A document with only blank lines', () => { 13 | it('produces an empty document object', () => { 14 | const markup = ` 15 | 16 | \t 17 | 18 | 19 | ` 20 | expect(Up.parse(markup)).to.deep.equal(new Up.Document([])) 21 | }) 22 | }) 23 | 24 | 25 | describe('A document with only escaped blank lines', () => { 26 | it('produces an empty document object', () => { 27 | const markup = ` 28 | \\ \t 29 | \\\t 30 | \\ \\ \\ 31 | \\\t \\ 32 | \\ ` 33 | expect(Up.parse(markup)).to.deep.equal(new Up.Document([])) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/Implementation/StringHelpers.ts: -------------------------------------------------------------------------------- 1 | import { escapeForRegex } from './PatternHelpers' 2 | 3 | 4 | // Returns a new string consisting of `count` copies of `text` 5 | export function repeat(text: string, count: number): string { 6 | return new Array(count + 1).join(text) 7 | } 8 | 9 | // Returns true if `first` equals `second` (ignoring any capitalization) 10 | export function isEqualIgnoringCapitalization(first: string, second: string): boolean { 11 | const pattern = 12 | new RegExp('^' + escapeForRegex(first) + '$', 'i') 13 | 14 | return pattern.test(second) 15 | } 16 | 17 | // Returns true if `haystack` contains `needle` (ignoring any capitalization) 18 | export function containsStringIgnoringCapitalization(args: { haystack: string, needle: string }): boolean { 19 | const pattern = 20 | new RegExp(escapeForRegex(args.needle), 'i') 21 | 22 | return pattern.test(args.haystack) 23 | } 24 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/MediaConventions.ts: -------------------------------------------------------------------------------- 1 | import { Audio } from '../../SyntaxNodes/Audio' 2 | import { Image } from '../../SyntaxNodes/Image' 3 | import { Video } from '../../SyntaxNodes/Video' 4 | import { MediaConvention } from './MediaConvention' 5 | import { TokenRole } from './TokenRole' 6 | 7 | 8 | export const AUDIO: MediaConvention = { 9 | keyword: keywords => keywords.audio(), 10 | SyntaxNodeType: Audio, 11 | tokenRoleForStartAndDescription: TokenRole.AudioStartAndDescription 12 | } 13 | 14 | export const IMAGE: MediaConvention = { 15 | keyword: keywords => keywords.image(), 16 | SyntaxNodeType: Image, 17 | tokenRoleForStartAndDescription: TokenRole.ImageStartAndDescription 18 | } 19 | 20 | export const VIDEO: MediaConvention = { 21 | keyword: keywords => keywords.video(), 22 | SyntaxNodeType: Video, 23 | tokenRoleForStartAndDescription: TokenRole.VideoStartAndDescription 24 | } 25 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Paragraph.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { InlineSyntaxNode } from './InlineSyntaxNode' 4 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 5 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 6 | 7 | 8 | export class Paragraph extends InlineSyntaxNodeContainer implements OutlineSyntaxNode { 9 | sourceLineNumber?: number 10 | 11 | constructor( 12 | children: InlineSyntaxNode[], 13 | options?: { sourceLineNumber: number } 14 | ) { 15 | super(children) 16 | this.sourceLineNumber = options?.sourceLineNumber 17 | } 18 | 19 | descendantsToIncludeInTableOfContents(): Heading[] { 20 | return [] 21 | } 22 | 23 | render(renderer: Renderer): string { 24 | return renderer.paragraph(this) 25 | } 26 | 27 | protected readonly PARAGRAPH = undefined 28 | } 29 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/FootnoteBlock.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { Footnote } from './Footnote' 4 | import { getInlineDescendants } from './getInlineDescendants' 5 | import { InlineSyntaxNode } from './InlineSyntaxNode' 6 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 7 | 8 | 9 | export class FootnoteBlock implements OutlineSyntaxNode { 10 | constructor(public footnotes: Footnote[]) { } 11 | 12 | // The source line number of a footnote block wouldn't be particularly meaningful. 13 | readonly sourceLineNumber = undefined 14 | 15 | descendantsToIncludeInTableOfContents(): Heading[] { 16 | return [] 17 | } 18 | 19 | inlineDescendants(): InlineSyntaxNode[] { 20 | return getInlineDescendants(this.footnotes) 21 | } 22 | 23 | render(renderer: Renderer): string { 24 | return renderer.footnoteBlock(this) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/OpenConvention.ts: -------------------------------------------------------------------------------- 1 | import { ConventionDefinition } from './ConventionDefinition' 2 | import { ForgivingConventionHandler } from './ForgivingConventions/ForgivingConventionHandler' 3 | import { Token } from './Token' 4 | 5 | 6 | export class OpenConvention { 7 | constructor( 8 | public definition: ConventionDefinition, 9 | public tokenizerSnapshotWhenOpening: { 10 | markupIndex: number 11 | markupIndexThatLastOpenedAConvention?: number 12 | bufferedContent: string 13 | tokens: Token[] 14 | openConventions: OpenConvention[] 15 | forgivingConventionHandlers: ForgivingConventionHandler[] 16 | }, 17 | public startTokenIndex = tokenizerSnapshotWhenOpening.tokens.length) { } 18 | 19 | clone(): OpenConvention { 20 | return new OpenConvention( 21 | this.definition, 22 | this.tokenizerSnapshotWhenOpening, 23 | this.startTokenIndex) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /verify-package-settings.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const expect = require('chai').expect 4 | const path = require('node:path'); 5 | 6 | const packageSettings = require('./package.json') 7 | const Up = require('./' + packageSettings.main) 8 | 9 | 10 | context('In package.json', () => { 11 | specify('the `main` field points to the entry point of the library', () => { 12 | expect(Up.parseAndRender('It *actually* worked?')).to.equal('

It actually worked?

') 13 | }) 14 | 15 | specify('the `types` field points to the typings for the entry point of the library', () => { 16 | const typesBasename = path.basename(packageSettings.types, '.d.ts') 17 | const entryPointBasename = path.basename(packageSettings.main, '.js') 18 | 19 | expect(typesBasename).to.equal(entryPointBasename) 20 | }) 21 | 22 | specify('the `version` field matches the version of the library', () => { 23 | expect(packageSettings.version).to.equal(Up.VERSION) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/BacktrackedConventionHelper.ts: -------------------------------------------------------------------------------- 1 | import { OpenConvention } from './OpenConvention' 2 | import { ConventionDefinition } from './ConventionDefinition' 3 | 4 | 5 | // We use this class to keep track of which conventions we've been forced to backtrack. 6 | export class BacktrackedConventionHelper { 7 | private failedConventionsByMarkupIndex: { 8 | [markupIndex: number]: ConventionDefinition[] 9 | } = {} 10 | 11 | registerFailure(failure: OpenConvention): void { 12 | const { markupIndex } = failure.tokenizerSnapshotWhenOpening 13 | 14 | this.failedConventionsByMarkupIndex[markupIndex] ??= [] 15 | this.failedConventionsByMarkupIndex[markupIndex].push(failure.definition) 16 | } 17 | 18 | hasFailed(convention: ConventionDefinition, markupIndex: number): boolean { 19 | const failedConventions = (this.failedConventionsByMarkupIndex[markupIndex] ?? []) 20 | return failedConventions.some(failedConvention => failedConvention === convention) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Link.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { InlineSyntaxNode } from './InlineSyntaxNode' 4 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 5 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 6 | 7 | 8 | // If a line consists solely of media conventions *or media conventions within links*, 9 | // those media conventions are placed directly into the outline. 10 | export class Link extends RichInlineSyntaxNode implements OutlineSyntaxNode { 11 | sourceLineNumber?: number 12 | 13 | constructor( 14 | children: InlineSyntaxNode[], 15 | public url: string, 16 | options?: { sourceLineNumber: number } 17 | ) { 18 | super(children) 19 | this.sourceLineNumber = options?.sourceLineNumber 20 | } 21 | 22 | descendantsToIncludeInTableOfContents(): Heading[] { 23 | return [] 24 | } 25 | 26 | render(renderer: Renderer): string { 27 | return renderer.link(this) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Implementation/PatternPieces.ts: -------------------------------------------------------------------------------- 1 | import { anyCharMatching, anyCharNotMatching, escapeForRegex, everyOptional, oneOrMore } from './PatternHelpers' 2 | 3 | 4 | export const INLINE_WHITESPACE_CHAR = 5 | anyCharNotMatching('\\S', '\\r', '\\n') 6 | 7 | export const WHITESPACE_CHAR = 8 | '\\s' 9 | 10 | export const ANY_OPTIONAL_WHITESPACE = 11 | everyOptional(WHITESPACE_CHAR) 12 | 13 | export const WHITESPACE = 14 | oneOrMore(WHITESPACE_CHAR) 15 | 16 | export const LETTER_CLASS = 17 | 'a-zA-Z' 18 | 19 | export const DIGIT = 20 | '\\d' 21 | 22 | export const ANY_CHAR = 23 | '.' 24 | 25 | export const REST_OF_TEXT = 26 | everyOptional(ANY_CHAR) 27 | 28 | export const FORWARD_SLASH = 29 | '/' 30 | 31 | export const HASH_MARK = 32 | '#' 33 | 34 | export const LETTER_CHAR = 35 | anyCharMatching(LETTER_CLASS) 36 | 37 | const URL_SCHEME_NAME = 38 | LETTER_CHAR + everyOptional( 39 | anyCharMatching( 40 | LETTER_CLASS, DIGIT, ...['-', '+', '.'].map(escapeForRegex))) 41 | 42 | export const URL_SCHEME = 43 | URL_SCHEME_NAME + ':' + everyOptional('/') 44 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseThematicBreakStreak.ts: -------------------------------------------------------------------------------- 1 | import { DIVIDER_STREAK_PATTERN } from '../../Patterns' 2 | import { ThematicBreak } from '../../SyntaxNodes/ThematicBreak' 3 | import { LineConsumer } from './LineConsumer' 4 | import { OutlineParser } from './OutlineParser' 5 | 6 | 7 | // A horizontal streak of characters indicates purposeful separation between outline conventions. 8 | export function tryToParseThematicBreakStreak(args: OutlineParser.Args): OutlineParser.Result { 9 | const markupLineConsumer = new LineConsumer(args.markupLines) 10 | 11 | if (!markupLineConsumer.consumeLineIfMatches(DIVIDER_STREAK_PATTERN)) { 12 | return null 13 | } 14 | 15 | return { 16 | parsedNodes: ( 17 | // To produce a cleaner document, we condense multiple consecutive thematic breaks into one. 18 | // (If the most recent sibling is a thematic break, we don't need another.) 19 | args.mostRecentSibling instanceof ThematicBreak 20 | ? [] 21 | : [new ThematicBreak()]), 22 | countLinesConsumed: markupLineConsumer.countLinesConsumed() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@xcvz/up", 3 | "version": "1.0.2", 4 | "description": "A markup language for writing structured documents in plain text", 5 | "main": "compiled/Main.js", 6 | "types": "compiled/Main.d.ts", 7 | "files": [ 8 | "compiled/Main.js", 9 | "compiled/Main.d.ts", 10 | "compiled/Implementation" 11 | ], 12 | "scripts": { 13 | "build": "rm -rf ./compiled && tsc", 14 | "test": "npm run build && mocha --recursive ./compiled/Test && mocha ./verify-package-settings.js" 15 | }, 16 | "keywords": [ 17 | "formatting", 18 | "language", 19 | "markup", 20 | "writing" 21 | ], 22 | "author": "Daniel Miller ", 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/start/up.git" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/start/up/issues" 29 | }, 30 | "homepage": "https://tryup.org", 31 | "license": "MIT", 32 | "devDependencies": { 33 | "@types/chai": "^4.2.12", 34 | "@types/mocha": "^8.0.3", 35 | "chai": "^4.2.0", 36 | "mocha": "^10.3.0", 37 | "typescript": "^5.4.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Daniel Miller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to 7 | deal in the Software without restriction, including without limitation the 8 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 9 | sell copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 21 | IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Test/Parsing/Settings/FancyEllipsis.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | context('The "fancyEllipsis" setting', () => { 7 | const up = new Up.Up({ 8 | parsing: { fancyEllipsis: '⋯' } 9 | }) 10 | 11 | it('replaces consecutive periods in the markup', () => { 12 | expect(up.parse('I agree... to an extent.')).to.deep.equal( 13 | insideDocumentAndParagraph([ 14 | new Up.Text('I agree⋯ to an extent.') 15 | ])) 16 | }) 17 | 18 | it('does not replace instances of the default ellipsis character if the default character itself is used in the markup', () => { 19 | expect(up.parse('I agree… to an extent.')).to.deep.equal( 20 | insideDocumentAndParagraph([ 21 | new Up.Text('I agree… to an extent.') 22 | ])) 23 | }) 24 | 25 | it('can consist of multiple characters', () => { 26 | expect(Up.parse('I agree... to an extent.', { fancyEllipsis: '. . .' })).to.deep.equal( 27 | insideDocumentAndParagraph([ 28 | new Up.Text('I agree. . . to an extent.') 29 | ])) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Heading.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 4 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 5 | 6 | 7 | export class Heading extends InlineSyntaxNodeContainer implements OutlineSyntaxNode { 8 | level: number 9 | titleMarkup: string 10 | ordinalInTableOfContents?: number 11 | sourceLineNumber?: number 12 | 13 | constructor( 14 | children: InlineSyntaxNode[], 15 | options: { 16 | level: number 17 | titleMarkup: string 18 | ordinalInTableOfContents?: number 19 | sourceLineNumber?: number 20 | } 21 | ) { 22 | super(children) 23 | 24 | this.level = options.level 25 | this.titleMarkup = options.titleMarkup 26 | this.ordinalInTableOfContents = options.ordinalInTableOfContents 27 | this.sourceLineNumber = options.sourceLineNumber 28 | } 29 | 30 | descendantsToIncludeInTableOfContents(): Heading[] { 31 | return [] 32 | } 33 | 34 | render(renderer: Renderer): string { 35 | return renderer.heading(this) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/LineBlock.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { Renderer } from '../Rendering/Renderer' 3 | import { Heading } from './Heading' 4 | import { InlineSyntaxNode } from './InlineSyntaxNode' 5 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 6 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 7 | 8 | 9 | export class LineBlock implements OutlineSyntaxNode { 10 | sourceLineNumber?: number 11 | 12 | constructor( 13 | public lines: LineBlock.Line[], 14 | options?: { sourceLineNumber: number } 15 | ) { 16 | this.sourceLineNumber = options?.sourceLineNumber 17 | } 18 | 19 | descendantsToIncludeInTableOfContents(): Heading[] { 20 | return [] 21 | } 22 | 23 | inlineDescendants(): InlineSyntaxNode[] { 24 | return concat( 25 | this.lines.map(line => line.inlineDescendants())) 26 | } 27 | 28 | render(renderer: Renderer): string { 29 | return renderer.lineBlock(this) 30 | } 31 | } 32 | 33 | 34 | export namespace LineBlock { 35 | export class Line extends InlineSyntaxNodeContainer { 36 | protected readonly LINE_BLOCK_LINE = undefined 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Test/Html/Settings/SectionReferencedByTableOfContents.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('The ID of an element referenced by the table of contents', () => { 6 | it('uses the term for "sectionReferencedByTableOfContents"', () => { 7 | const up = new Up.Up({ 8 | rendering: { 9 | terms: { sectionReferencedByTableOfContents: 'table of contents entry' } 10 | } 11 | }) 12 | 13 | const IGNORED_FIELD: string = null! 14 | 15 | const heading = 16 | new Up.Heading([new Up.Text('I enjoy apples')], { 17 | level: 1, 18 | titleMarkup: IGNORED_FIELD, 19 | ordinalInTableOfContents: 1 20 | }) 21 | 22 | const document = 23 | new Up.Document([heading], new Up.Document.TableOfContents([heading])) 24 | 25 | const { tableOfContentsHtml, documentHtml } = 26 | up.renderWithTableOfContents(document) 27 | 28 | expect(tableOfContentsHtml).to.equal( 29 | '

I enjoy apples

') 30 | 31 | expect(documentHtml).to.equal( 32 | '

I enjoy apples

') 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /src/Implementation/Settings.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | parsing?: Settings.Parsing 3 | rendering?: Settings.Rendering 4 | } 5 | 6 | export namespace Settings { 7 | export interface Parsing { 8 | createSourceMap?: boolean 9 | defaultUrlScheme?: string 10 | baseForUrlsStartingWithSlash?: string 11 | baseForUrlsStartingWithHashMark?: string 12 | fancyEllipsis?: string 13 | keywords?: Parsing.Keywords 14 | } 15 | 16 | export namespace Parsing { 17 | export interface Keywords { 18 | audio?: Keyword 19 | image?: Keyword 20 | revealable?: Keyword 21 | sectionLink?: Keyword 22 | table?: Keyword 23 | video?: Keyword 24 | } 25 | 26 | export type Keyword = string[] | string 27 | } 28 | 29 | 30 | export interface Rendering { 31 | idPrefix?: string 32 | renderDangerousContent?: boolean 33 | terms?: Rendering.Terms 34 | } 35 | 36 | export namespace Rendering { 37 | export interface Terms { 38 | footnote?: Term 39 | footnoteReference?: Term 40 | hide?: Term 41 | reveal?: Term 42 | sectionReferencedByTableOfContents?: Term 43 | } 44 | 45 | export type Term = string 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Test/Parsing/Emoji.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | context('Emojis are always treated like any other other character. This includes when the emoji is within', () => { 7 | specify('a link URL', () => { 8 | expect(Up.parse('[American flag emoji](https://example.com/empojis/🇺🇸?info)')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Link([ 11 | new Up.Text('American flag emoji') 12 | ], 'https://example.com/empojis/🇺🇸?info') 13 | ])) 14 | }) 15 | 16 | specify('regular text', () => { 17 | expect(Up.parse("Okay. 🙄 I'll eat the tarantula. 🕷")).to.deep.equal( 18 | insideDocumentAndParagraph([ 19 | new Up.Text("Okay. 🙄 I'll eat the tarantula. 🕷") 20 | ])) 21 | }) 22 | }) 23 | 24 | 25 | describe('Escaped emojis', () => { 26 | it('are preserved appropriately (rather than split into two pieces)', () => { 27 | expect(Up.parse("Okay. \\🙄 I'll eat the tarantula. \\🕷")).to.deep.equal( 28 | insideDocumentAndParagraph([ 29 | new Up.Text("Okay. 🙄 I'll eat the tarantula. 🕷") 30 | ])) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/MediaSyntaxNode.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { Heading } from './Heading' 3 | import { InlineSyntaxNode } from './InlineSyntaxNode' 4 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 5 | 6 | 7 | // If a line consists solely of media conventions (or media conventions within links), 8 | // those media conventions are placed directly into the outline. 9 | export abstract class MediaSyntaxNode implements InlineSyntaxNode, OutlineSyntaxNode { 10 | sourceLineNumber?: number 11 | 12 | constructor( 13 | public description: string, 14 | public url: string, 15 | options?: { sourceLineNumber: number } 16 | ) { 17 | this.sourceLineNumber = options?.sourceLineNumber 18 | } 19 | 20 | textAppearingInline(): string { 21 | return '' 22 | } 23 | 24 | descendantsToIncludeInTableOfContents(): Heading[] { 25 | return [] 26 | } 27 | 28 | inlineDescendants(): InlineSyntaxNode[] { 29 | return [] 30 | } 31 | 32 | abstract render(renderer: Renderer): string 33 | 34 | protected readonly MEDIA_SYNTAX_NODE = undefined 35 | } 36 | 37 | export type MediaSyntaxNodeType = new (description: string, url: string) => MediaSyntaxNode 38 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/BulletedList.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { Renderer } from '../Rendering/Renderer' 3 | import { Heading } from './Heading' 4 | import { InlineSyntaxNode } from './InlineSyntaxNode' 5 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 6 | import { OutlineSyntaxNodeContainer } from './OutlineSyntaxNodeContainer' 7 | 8 | 9 | export class BulletedList implements OutlineSyntaxNode { 10 | sourceLineNumber?: number 11 | 12 | constructor( 13 | public items: BulletedList.Item[], 14 | options?: { sourceLineNumber: number } 15 | ) { 16 | this.sourceLineNumber = options?.sourceLineNumber 17 | } 18 | 19 | descendantsToIncludeInTableOfContents(): Heading[] { 20 | return concat( 21 | this.items.map(item => item.descendantsToIncludeInTableOfContents())) 22 | } 23 | 24 | inlineDescendants(): InlineSyntaxNode[] { 25 | return concat( 26 | this.items.map(item => item.inlineDescendants())) 27 | } 28 | 29 | render(renderer: Renderer): string { 30 | return renderer.bulletedList(this) 31 | } 32 | } 33 | 34 | 35 | export namespace BulletedList { 36 | export class Item extends OutlineSyntaxNodeContainer { 37 | protected readonly BULLETED_LIST_ITEM = undefined 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/LinkifiedConvention.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('An almost-linkified revealable convention (with whitespace between its content and URL) terminated early due to a space in its URL', () => { 7 | it('can contain an unclosed square bracket without affecting a linkified revealable convention with a square bracketed URL that follows it', () => { 8 | expect(Up.parse('(SPOILER: Ash dies) (https://example.com/ending:[ has all the info) ... [SPOILER: anyway, go here instead] [https://example.com/happy]')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.InlineRevealable([ 11 | new Up.Text('Ash dies') 12 | ]), 13 | new Up.Text(' '), 14 | new Up.NormalParenthetical([ 15 | new Up.Text('('), 16 | new Up.Link([ 17 | new Up.Text('example.com/ending:[') 18 | ], 'https://example.com/ending:['), 19 | new Up.Text(' has all the info)') 20 | ]), 21 | new Up.Text(' … '), 22 | new Up.InlineRevealable([ 23 | new Up.Link([ 24 | new Up.Text('anyway, go here instead') 25 | ], 'https://example.com/happy') 26 | ]) 27 | ])) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Blockquote.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('A single blank blockquoted line', () => { 6 | it('does not require any trailing whitespace after the blockquote delimiter', () => { 7 | expect(Up.parse('>')).to.deep.equal( 8 | new Up.Document([ 9 | new Up.Blockquote([]) 10 | ])) 11 | }) 12 | 13 | it('may have a trailing space after the blockquote delimiter', () => { 14 | expect(Up.parse('> ')).to.deep.equal( 15 | new Up.Document([ 16 | new Up.Blockquote([]) 17 | ])) 18 | }) 19 | 20 | it('may have a trailing tab after the blockquote delimiter', () => { 21 | expect(Up.parse('>\t')).to.deep.equal( 22 | new Up.Document([ 23 | new Up.Blockquote([]) 24 | ])) 25 | }) 26 | }) 27 | 28 | 29 | describe('A single line blockquote', () => { 30 | it('can be sandwiched by identical thematic break streaks without producing a heading', () => { 31 | const markup = ` 32 | --------------- 33 | > I choose you! 34 | ---------------` 35 | 36 | expect(Up.parse(markup)).to.deep.equal( 37 | new Up.Document([ 38 | new Up.ThematicBreak(), 39 | new Up.Blockquote([ 40 | new Up.Paragraph([ 41 | new Up.Text('I choose you!') 42 | ]) 43 | ]), 44 | new Up.ThematicBreak() 45 | ])) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Bold.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Double underscores followed by whitespace with matching double underscores touching the end of a word', () => { 7 | it('do not produce a bold node and are preserved as plain text', () => { 8 | expect(Up.parse('I believe__ my spelling__ was wrong.')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('I believe__ my spelling__ was wrong.') 11 | ])) 12 | }) 13 | }) 14 | 15 | 16 | describe('Double underscores touching the beginning of a word with matching double underscores preceded by whitespace', () => { 17 | it('do not produce a bold node and are preserved as plain text', () => { 18 | expect(Up.parse('I __believe my __spelling was wrong.')).to.deep.equal( 19 | insideDocumentAndParagraph([ 20 | new Up.Text('I __believe my __spelling was wrong.') 21 | ])) 22 | }) 23 | }) 24 | 25 | 26 | describe('Matching double underscores each surrounded by whitespace', () => { 27 | it('do not produce a bold node and are preserved as plain text', () => { 28 | expect(Up.parse('I believe __ will win the primary in __ easily.')).to.deep.equal( 29 | insideDocumentAndParagraph([ 30 | new Up.Text('I believe __ will win the primary in __ easily.') 31 | ])) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Stress.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Double asterisks followed by whitespace with matching double asterisks touching the end of a word', () => { 7 | it('do not produce a stress node and are preserved as plain text', () => { 8 | expect(Up.parse('I believe** my spelling** was wrong.')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('I believe** my spelling** was wrong.') 11 | ])) 12 | }) 13 | }) 14 | 15 | 16 | describe('Double asterisks touching the beginning of a word with matching double asterisks preceded by whitespace', () => { 17 | it('do not produce a stress node and are preserved as plain text', () => { 18 | expect(Up.parse('I **believe my **spelling was wrong.')).to.deep.equal( 19 | insideDocumentAndParagraph([ 20 | new Up.Text('I **believe my **spelling was wrong.') 21 | ])) 22 | }) 23 | }) 24 | 25 | 26 | describe('Matching double asterisks each surrounded by whitespace', () => { 27 | it('do not produce a stress node and are preserved as plain text', () => { 28 | expect(Up.parse('I believe ** will win the primary in ** easily.')).to.deep.equal( 29 | insideDocumentAndParagraph([ 30 | new Up.Text('I believe ** will win the primary in ** easily.') 31 | ])) 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Footnote.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { InlineSyntaxNode } from './InlineSyntaxNode' 3 | import { RichInlineSyntaxNode } from './RichInlineSyntaxNode' 4 | 5 | 6 | export class Footnote extends RichInlineSyntaxNode { 7 | // Before the document is finalized, this field will always be undefined. Afterward, it will 8 | // never be null. 9 | // 10 | // TODO: Research the best way to represent this using the type system. Should we create two 11 | // versions of this class: One pre-finalization and one post-finalization? 12 | referenceNumber?: number 13 | 14 | constructor( 15 | children: InlineSyntaxNode[], 16 | options?: { referenceNumber: number } 17 | ) { 18 | super(children) 19 | this.referenceNumber = options?.referenceNumber 20 | } 21 | 22 | // Footnotes are written inline, but they aren't meant to appear inline in the final document. 23 | // That would defeat the purpose of footnotes! Instead, footnotes are extracted and placed in 24 | // footnote blocks. 25 | // 26 | // This process is fully explained in `finalizeDocument.ts`. 27 | // 28 | // Long story short: footnotes don't represent inline content, so this method just returns an 29 | // empty string. 30 | textAppearingInline(): string { 31 | return '' 32 | } 33 | 34 | render(renderer: Renderer): string { 35 | return renderer.referenceToFootnote(this) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/ThematicBreak.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('A thematic break streak', () => { 6 | it('can directly precede a heading with a different combination of characters in its underline', () => { 7 | const markup = ` 8 | -------------------- 9 | Not me. Us! 10 | @---------@` 11 | 12 | const heading = 13 | new Up.Heading([new Up.Text('Not me. Us!')], { 14 | level: 1, 15 | titleMarkup: 'Not me. Us!', 16 | ordinalInTableOfContents: 1 17 | }) 18 | 19 | expect(Up.parse(markup)).to.deep.equal( 20 | new Up.Document([ 21 | new Up.ThematicBreak(), 22 | heading 23 | ], new Up.Document.TableOfContents([heading]))) 24 | }) 25 | 26 | it('can directly precede a heading with the same combination of characters in its underline, as long as that heading has an overline', () => { 27 | const markup = ` 28 | --------------------------------- 29 | ----------- 30 | Not me. Us! 31 | -----------` 32 | 33 | const heading = 34 | new Up.Heading([new Up.Text('Not me. Us!')], { 35 | level: 1, 36 | titleMarkup: 'Not me. Us!', 37 | ordinalInTableOfContents: 1 38 | }) 39 | 40 | expect(Up.parse(markup)).to.deep.equal( 41 | new Up.Document([ 42 | new Up.ThematicBreak(), 43 | heading 44 | ], new Up.Document.TableOfContents([heading]))) 45 | }) 46 | }) 47 | 48 | -------------------------------------------------------------------------------- /src/Test/Parsing/InlineDocument/OutlineConventions.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | context('Inline documents completely ignore outline conventions. This includes:', () => { 6 | specify('Thematic break streaks', () => { 7 | expect(Up.parseInline('#~#~#~#~#')).to.deep.equal( 8 | new Up.InlineDocument([ 9 | new Up.Text('#~#~#~#~#') 10 | ])) 11 | }) 12 | 13 | specify('Numbered lists', () => { 14 | expect(Up.parseInline('1) I agree.')).to.deep.equal( 15 | new Up.InlineDocument([ 16 | new Up.Text('1) I agree.') 17 | ])) 18 | }) 19 | 20 | specify('Bulleted lists', () => { 21 | expect(Up.parseInline('* Prices and participation may vary')).to.deep.equal( 22 | new Up.InlineDocument([ 23 | new Up.Text('* Prices and participation may vary') 24 | ])) 25 | }) 26 | 27 | specify('Blockquotes', () => { 28 | expect(Up.parseInline('> o_o <')).to.deep.equal( 29 | new Up.InlineDocument([ 30 | new Up.Text('> o_o <') 31 | ])) 32 | }) 33 | 34 | specify('Code blocks', () => { 35 | expect(Up.parseInline('`````````')).to.deep.equal( 36 | new Up.InlineDocument([ 37 | new Up.Text('`````````') 38 | ])) 39 | }) 40 | 41 | specify('Headings', () => { 42 | expect(Up.parseInline('Sneaky Snek\n=~=~=~=~=~=')).to.deep.equal( 43 | new Up.InlineDocument([ 44 | new Up.Text('Sneaky Snek\n=~=~=~=~=~=') 45 | ])) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/Test/Html/Settings/BaseForUrlsStartingWithFragmentIdentifier.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | 6 | describe('The "baseForUrlsStartingWithFragmentIdentifier" setting', () => { 7 | const up = new Up.Up({ 8 | parsing: { 9 | baseForUrlsStartingWithHashMark: 'https://example.com/page' 10 | } 11 | }) 12 | 13 | it("does not affect a footnote reference's link to its footnote", () => { 14 | const document = new Up.Document([ 15 | new Up.Paragraph([ 16 | new Up.Footnote([], { referenceNumber: 3 }) 17 | ]) 18 | ]) 19 | 20 | expect(up.render(document)).to.equal( 21 | '

3

') 22 | }) 23 | 24 | it("does not affect a footnote's link back to its reference", () => { 25 | const document = new Up.Document([ 26 | new Up.FootnoteBlock([ 27 | new Up.Footnote([ 28 | new Up.Text('Arwings') 29 | ], { referenceNumber: 2 }), 30 | new Up.Footnote([ 31 | new Up.Text('Killer Bees') 32 | ], { referenceNumber: 3 }) 33 | ]) 34 | ]) 35 | 36 | const html = 37 | '
' 38 | + '
2
Arwings
' 39 | + '
3
Killer Bees
' 40 | + '
' 41 | 42 | expect(up.render(document)).to.equal(html) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/Test/Html/Settings/Footnote.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe("A footnote's ID", () => { 6 | it('uses the provided term for "footnote"', () => { 7 | const up = new Up.Up({ 8 | rendering: { 9 | terms: { 10 | footnote: 'fn' 11 | } 12 | } 13 | }) 14 | 15 | const node = new Up.Document([ 16 | new Up.FootnoteBlock([ 17 | new Up.Footnote([ 18 | new Up.Text('Arwings') 19 | ], { referenceNumber: 2 }), 20 | new Up.Footnote([ 21 | new Up.Text('Killer Bees') 22 | ], { referenceNumber: 3 }) 23 | ]) 24 | ]) 25 | 26 | const html = 27 | '
' 28 | + '
2
Arwings
' 29 | + '
3
Killer Bees
' 30 | + '
' 31 | 32 | expect(up.render(node)).to.equal(html) 33 | }) 34 | }) 35 | 36 | 37 | describe('The ID of the footnote referenced by a footnote reference', () => { 38 | it('uses the provided term for "footnote"', () => { 39 | const up = new Up.Up({ 40 | rendering: { 41 | terms: { 42 | footnote: 'fn' 43 | } 44 | } 45 | }) 46 | 47 | const document = new Up.Document([ 48 | new Up.Paragraph([ 49 | new Up.Footnote([], { referenceNumber: 3 }) 50 | ]) 51 | ]) 52 | 53 | expect(up.render(document)).to.equal( 54 | '

3

') 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/isLineFancyOutlineConvention.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from '../../NormalizedSettings' 2 | import { HeadingLeveler } from './HeadingLeveler' 3 | import { tryToParseBlockquote } from './tryToParseBlockquote' 4 | import { tryToParseBulletedList } from './tryToParseBulletedList' 5 | import { tryToParseCodeBlock } from './tryToParseCodeBlock' 6 | import { tryToParseNumberedList } from './tryToParseNumberedList' 7 | import { tryToParseThematicBreakStreak } from './tryToParseThematicBreakStreak' 8 | 9 | 10 | const OUTLINE_CONVENTIONS_POSSIBLY_ONE_LINE_LONG = [ 11 | tryToParseBulletedList, 12 | tryToParseNumberedList, 13 | tryToParseThematicBreakStreak, 14 | tryToParseBlockquote, 15 | tryToParseCodeBlock 16 | ] 17 | 18 | 19 | // We don't care about heading levels or source line numbers! We only care whether this line 20 | // is a regular paragraph. 21 | const DUMMY_HEADING_LEVELER = new HeadingLeveler() 22 | const DUMMY_SOURCE_LINE_NUMBER = 1 23 | 24 | 25 | // If `markupLine` would be considered anything but a regular paragraph, it's considered fancy. 26 | // 27 | // TODO: Make this function less disastrously wasteful. Currently, if `markupLine` does represent 28 | // a fancy outline convention, its contents are fully parsed. 29 | export function isLineFancyOutlineConvention(markupLine: string, settings: NormalizedSettings.Parsing): boolean { 30 | const markupLines = [markupLine] 31 | 32 | return OUTLINE_CONVENTIONS_POSSIBLY_ONE_LINE_LONG.some( 33 | parse => parse({ 34 | markupLines, 35 | settings, 36 | sourceLineNumber: DUMMY_SOURCE_LINE_NUMBER, 37 | headingLeveler: DUMMY_HEADING_LEVELER 38 | })) 39 | } 40 | -------------------------------------------------------------------------------- /src/Test/Html/Settings/FootnoteReference.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe("A footnote reference's ID", () => { 6 | it('uses the provided term for "footnote reference"', () => { 7 | const up = new Up.Up({ 8 | rendering: { 9 | terms: { 10 | footnoteReference: 'ref' 11 | } 12 | } 13 | }) 14 | 15 | const document = new Up.Document([ 16 | new Up.Paragraph([ 17 | new Up.Footnote([], { referenceNumber: 3 }) 18 | ]) 19 | ]) 20 | 21 | expect(up.render(document)).to.equal( 22 | '

3

') 23 | }) 24 | }) 25 | 26 | 27 | describe('The ID of the footnote reference referencing the footnote', () => { 28 | it('uses the provided term for "footnote reference"', () => { 29 | const up = new Up.Up({ 30 | rendering: { 31 | terms: { 32 | footnoteReference: 'ref' 33 | } 34 | } 35 | }) 36 | 37 | const document = new Up.Document([ 38 | new Up.FootnoteBlock([ 39 | new Up.Footnote([ 40 | new Up.Text('Arwings') 41 | ], { referenceNumber: 2 }), 42 | new Up.Footnote([ 43 | new Up.Text('Killer Bees') 44 | ], { referenceNumber: 3 }) 45 | ]) 46 | ]) 47 | 48 | const html = 49 | '
' 50 | + '
2
Arwings
' 51 | + '
3
Killer Bees
' 52 | + '
' 53 | 54 | expect(up.render(document)).to.equal(html) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/EmptyTableCell.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('A table header consisting only of a semicolon', () => { 6 | it('consists of a single empty cell', () => { 7 | const markup = ` 8 | Table: 9 | 10 | ; 11 | 12 | Chrono Trigger 13 | StarCraft` 14 | 15 | expect(Up.parse(markup)).to.deep.equal( 16 | new Up.Document([ 17 | new Up.Table( 18 | new Up.Table.Header([ 19 | new Up.Table.Header.Cell([]) 20 | ]), [ 21 | new Up.Table.Row([ 22 | new Up.Table.Row.Cell([new Up.Text('Chrono Trigger')]) 23 | ]), 24 | new Up.Table.Row([ 25 | new Up.Table.Row.Cell([new Up.Text('StarCraft')]) 26 | ]) 27 | ]) 28 | ])) 29 | }) 30 | }) 31 | 32 | 33 | describe('A table row consisting only of a semicolon', () => { 34 | it('consists of a single empty cell', () => { 35 | const markup = ` 36 | Table: 37 | 38 | Game 39 | 40 | Chrono Trigger 41 | ; 42 | StarCraft` 43 | 44 | expect(Up.parse(markup)).to.deep.equal( 45 | new Up.Document([ 46 | new Up.Table( 47 | new Up.Table.Header([ 48 | new Up.Table.Header.Cell([new Up.Text('Game')]) 49 | ]), [ 50 | new Up.Table.Row([ 51 | new Up.Table.Row.Cell([new Up.Text('Chrono Trigger')]) 52 | ]), 53 | new Up.Table.Row([ 54 | new Up.Table.Row.Cell([]) 55 | ]), 56 | new Up.Table.Row([ 57 | new Up.Table.Row.Cell([new Up.Text('StarCraft')]) 58 | ]) 59 | ]) 60 | ])) 61 | }) 62 | }) 63 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/InlineCode.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Inline code', () => { 7 | it('can be the last convention in a paragraph', () => { 8 | expect(Up.parse('Dropship `harass`')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Dropship '), 11 | new Up.InlineCode('harass') 12 | ])) 13 | }) 14 | }) 15 | 16 | 17 | context('Unmatched streaks of backticks are preserved as plain text. This applies for any unmatched streak, including', () => { 18 | specify('"streaks" of 1', () => { 19 | expect(Up.parse('I don`t ever do this')).to.deep.equal( 20 | insideDocumentAndParagraph([ 21 | new Up.Text('I don`t ever do this') 22 | ])) 23 | }) 24 | 25 | specify('streaks of 2', () => { 26 | expect(Up.parse('I don``t ever do this')).to.deep.equal( 27 | insideDocumentAndParagraph([ 28 | new Up.Text('I don``t ever do this') 29 | ])) 30 | }) 31 | 32 | specify('streaks of 3', () => { 33 | expect(Up.parse('I don```t ever do this')).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text('I don```t ever do this') 36 | ])) 37 | }) 38 | 39 | specify('streaks that would otherwise match a previously matched start delimiter', () => { 40 | expect(Up.parse('I always use `` elements, but I don`t ever do this.')).to.deep.equal( 41 | insideDocumentAndParagraph([ 42 | new Up.Text('I always use '), 43 | new Up.InlineCode(''), 44 | new Up.Text(' elements, but I don`t ever do this.') 45 | ])) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/SourceMap.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('When a blockquote starts with a blank line', () => { 6 | specify('its first convention is mapped to the correct line', () => { 7 | const markup = ` 8 | > 9 | > Who doesn't? 10 | > 11 | > Well, aside from you.` 12 | expect(Up.parse(markup, { createSourceMap: true })).to.deep.equal( 13 | new Up.Document([ 14 | new Up.Blockquote([ 15 | new Up.Paragraph([new Up.Text("Who doesn't?")], { sourceLineNumber: 3 }), 16 | new Up.Paragraph([new Up.Text('Well, aside from you.')], { sourceLineNumber: 5 }) 17 | ], { sourceLineNumber: 2 }) 18 | ])) 19 | }) 20 | }) 21 | 22 | 23 | context('When a single line of markup produces multiple "outlined" media nodes, and one of them is inside a link,', () => { 24 | specify('the link and the media nodes that are outside of it are mapped to that same line', () => { 25 | const markup = 26 | '[image: haunted house](example.com/hauntedhouse.svg)(example.com/gallery) [audio: haunted house](example.com/hauntedhouse.ogg) [video: haunted house](example.com/hauntedhouse.webm)' 27 | 28 | expect(Up.parse(markup, { createSourceMap: true })).to.deep.equal( 29 | new Up.Document([ 30 | new Up.Link([ 31 | new Up.Image('haunted house', 'https://example.com/hauntedhouse.svg') 32 | ], 'https://example.com/gallery', { sourceLineNumber: 1 }), 33 | new Up.Audio('haunted house', 'https://example.com/hauntedhouse.ogg', { sourceLineNumber: 1 }), 34 | new Up.Video('haunted house', 'https://example.com/hauntedhouse.webm', { sourceLineNumber: 1 }) 35 | ])) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Paragraph.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | context('Normally, consecutive non-blank lines produce a line block. However, if all but one of the lines consist solely of escaped whitespace, a paragraph is produced instead. This includes when:', () => { 7 | specify('The blank lines are all trailing, and none of them are indented', () => { 8 | const markup = ` 9 | You'll never believe this fake evidence! 10 | \\ \\ \t \\\t 11 | \\ \\ \t \\\t 12 | \\ \t\\ \\ \t \\\t ` 13 | 14 | expect(Up.parse(markup)).to.deep.equal( 15 | insideDocumentAndParagraph([ 16 | new Up.Text("You'll never believe this fake evidence!") 17 | ])) 18 | }) 19 | 20 | specify('The blank lines are all leading, and none but the first are indented', () => { 21 | const markup = ` 22 | \\ \t\\ \\ \t \\\t 23 | \\ \\ \t \\\t 24 | \\ \\ \t \\\t 25 | You'll never believe this fake evidence!` 26 | 27 | expect(Up.parse(markup)).to.deep.equal( 28 | insideDocumentAndParagraph([ 29 | new Up.Text("You'll never believe this fake evidence!") 30 | ])) 31 | }) 32 | 33 | specify('The blank lines surround the paragraph, and none but the first are indented', () => { 34 | const markup = ` 35 | \\ \t\\ \\ \t \\\t 36 | \\ \\ \t \\\t 37 | \\ \\ \t \\\t 38 | You'll never believe this fake evidence! 39 | \\ \t\\ \\ \t \\\t 40 | \\ \\ \t \\\t 41 | \\ \\ \t \\\t ` 42 | 43 | expect(Up.parse(markup)).to.deep.equal( 44 | insideDocumentAndParagraph([ 45 | new Up.Text("You'll never believe this fake evidence!") 46 | ])) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToPromoteMediaToOutline.ts: -------------------------------------------------------------------------------- 1 | import { InlineSyntaxNode } from '../../SyntaxNodes/InlineSyntaxNode' 2 | import { Link } from '../../SyntaxNodes/Link' 3 | import { MediaSyntaxNode } from '../../SyntaxNodes/MediaSyntaxNode' 4 | import { isWhitespace } from '../isWhitespace' 5 | 6 | 7 | // If a line consists solely of media conventions (and/or whitespace), those media conventions are 8 | // placed directly into the outline rather into a paragraph. 9 | // 10 | // If a media convention is "linkified", or if a link otherwise contains only media conventions (and 11 | // whitespace), the link counts as media. In that situation, the link itself is placed directly into 12 | // the outline, minus any whitespace. 13 | export type InlineSyntaxNodePromotableToOutline = 14 | MediaSyntaxNode | Link 15 | 16 | 17 | export function tryToPromoteMediaToOutline( 18 | inlineSyntaxNodes: InlineSyntaxNode[] 19 | ): null | InlineSyntaxNodePromotableToOutline[] { 20 | const promotedNodes: InlineSyntaxNodePromotableToOutline[] = [] 21 | 22 | for (const inlineNode of inlineSyntaxNodes) { 23 | if (inlineNode instanceof MediaSyntaxNode) { 24 | promotedNodes.push(inlineNode) 25 | continue 26 | } 27 | 28 | if (inlineNode instanceof Link) { 29 | const linkedPromotableChildren = tryToPromoteMediaToOutline(inlineNode.children) 30 | 31 | if (!linkedPromotableChildren) { 32 | return null 33 | } 34 | 35 | // By creating a new link node, we omit any whitespace the existing link held. 36 | promotedNodes.push(new Link(linkedPromotableChildren, inlineNode.url)) 37 | 38 | continue 39 | } 40 | 41 | if (!isWhitespace(inlineNode)) { 42 | return null 43 | } 44 | } 45 | 46 | return promotedNodes.length 47 | ? promotedNodes 48 | : null 49 | } 50 | -------------------------------------------------------------------------------- /src/Test/Parsing/Bold.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | describe('Text surrounded by 2 underscores', () => { 7 | it('is put inside a stress node', () => { 8 | expect(Up.parse('Hello, __world__!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Hello, '), 11 | new Up.Bold([ 12 | new Up.Text('world') 13 | ]), 14 | new Up.Text('!') 15 | ])) 16 | }) 17 | }) 18 | 19 | 20 | describe('Bold text', () => { 21 | it('is evaluated for inline conventions', () => { 22 | expect(Up.parse('Hello, __`world`__!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Hello, '), 25 | new Up.Bold([ 26 | new Up.InlineCode('world') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | 32 | it('can contain further bold text', () => { 33 | expect(Up.parse('Hello, __my __little__ world__!')).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text('Hello, '), 36 | new Up.Bold([ 37 | new Up.Text('my '), 38 | new Up.Bold([ 39 | new Up.Text('little') 40 | ]), 41 | new Up.Text(' world') 42 | ]), 43 | new Up.Text('!') 44 | ])) 45 | }) 46 | 47 | it('can contain italicized text', () => { 48 | expect(Up.parse('Hello, __my _little_ world__!')).to.deep.equal( 49 | insideDocumentAndParagraph([ 50 | new Up.Text('Hello, '), 51 | new Up.Bold([ 52 | new Up.Text('my '), 53 | new Up.Italic([ 54 | new Up.Text('little') 55 | ]), 56 | new Up.Text(' world') 57 | ]), 58 | new Up.Text('!') 59 | ])) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/Test/Parsing/Stress.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | describe('Text surrounded by 2 asterisks', () => { 7 | it('is put inside a stress node', () => { 8 | expect(Up.parse('Hello, **world**!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Hello, '), 11 | new Up.Stress([ 12 | new Up.Text('world') 13 | ]), 14 | new Up.Text('!') 15 | ])) 16 | }) 17 | }) 18 | 19 | 20 | describe('Stressed text', () => { 21 | it('is evaluated for inline conventions', () => { 22 | expect(Up.parse('Hello, **`world`**!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Hello, '), 25 | new Up.Stress([ 26 | new Up.InlineCode('world') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | 32 | it('can contain further stressed text', () => { 33 | expect(Up.parse('Hello, **my **little** world**!')).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text('Hello, '), 36 | new Up.Stress([ 37 | new Up.Text('my '), 38 | new Up.Stress([ 39 | new Up.Text('little') 40 | ]), 41 | new Up.Text(' world') 42 | ]), 43 | new Up.Text('!') 44 | ])) 45 | }) 46 | 47 | it('can contain emphasized text', () => { 48 | expect(Up.parse('Hello, **my *little* world**!')).to.deep.equal( 49 | insideDocumentAndParagraph([ 50 | new Up.Text('Hello, '), 51 | new Up.Stress([ 52 | new Up.Text('my '), 53 | new Up.Emphasis([ 54 | new Up.Text('little') 55 | ]), 56 | new Up.Text(' world') 57 | ]), 58 | new Up.Text('!') 59 | ])) 60 | })}) 61 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/InflectionWithBothAsterisksAndUnderscores.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Emphasis', () => { 7 | it('cannot be closed by an underscore', () => { 8 | expect(Up.parse('Xamarin is now *free_!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Xamarin is now *free_!') 11 | ])) 12 | }) 13 | }) 14 | 15 | 16 | describe('Italics', () => { 17 | it('cannot be closed by an asterisk', () => { 18 | expect(Up.parse('Xamarin is now _free*!')).to.deep.equal( 19 | insideDocumentAndParagraph([ 20 | new Up.Text('Xamarin is now _free*!') 21 | ])) 22 | }) 23 | }) 24 | 25 | 26 | describe('Text surrounded by an underscore and an asterisk on each side', () => { 27 | it('is italicized and emphasized', () => { 28 | expect(Up.parse('Koopas! _*Mario is on his way!*_ Grab your shells!')).to.deep.equal( 29 | insideDocumentAndParagraph([ 30 | new Up.Text('Koopas! '), 31 | new Up.Italic([ 32 | new Up.Emphasis([ 33 | new Up.Text('Mario is on his way!') 34 | ]) 35 | ]), 36 | new Up.Text(' Grab your shells!') 37 | ])) 38 | }) 39 | }) 40 | 41 | 42 | describe('Text surrounded by double asterisk and double underscores on each side', () => { 43 | it('is stressed and bold', () => { 44 | expect(Up.parse('Koopas! **__Mario is on his way!__** Grab your shells!')).to.deep.equal( 45 | insideDocumentAndParagraph([ 46 | new Up.Text('Koopas! '), 47 | new Up.Stress([ 48 | new Up.Bold([ 49 | new Up.Text('Mario is on his way!') 50 | ]) 51 | ]), 52 | new Up.Text(' Grab your shells!') 53 | ])) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/TextConsumer.ts: -------------------------------------------------------------------------------- 1 | // This class helps incrementally consume text using regular expression patterns. 2 | export class TextConsumer { 3 | // These are all indirectly set in the constructor 4 | private _index!: number 5 | private _remaining!: string 6 | private _currentChar!: string 7 | private _previousChar!: string 8 | 9 | constructor(private entireText: string) { 10 | this.setIndex(0) 11 | } 12 | 13 | setIndex(newIndex: number): void { 14 | this._index = newIndex 15 | this._remaining = this.entireText.substring(newIndex) 16 | this._currentChar = this._remaining[0] 17 | this._previousChar = this.entireText[newIndex - 1] 18 | } 19 | 20 | index(): number { 21 | return this._index 22 | } 23 | 24 | advance(by: number): void { 25 | this.setIndex(this._index + by) 26 | } 27 | 28 | remaining(): string { 29 | return this._remaining 30 | } 31 | 32 | currentChar(): string { 33 | return this._currentChar 34 | } 35 | 36 | previousChar(): string { 37 | return this._previousChar 38 | } 39 | 40 | done(): boolean { 41 | return this._index >= this.entireText.length 42 | } 43 | 44 | // This method consumes any text from the start of `remaining` if it matches `pattern`. 45 | // 46 | // NOTE: We assume `pattern` is anchored to the beginning of the input string! 47 | consume(pattern: RegExp): null | { 48 | match: string 49 | charAfterMatch: string 50 | captures: string[] 51 | } { 52 | const result = pattern.exec(this._remaining) 53 | 54 | if (!result) { 55 | return null 56 | } 57 | 58 | const [match, ...captures] = result 59 | const charAfterMatch = this.entireText[this._index + match.length] 60 | 61 | this.advance(match.length) 62 | 63 | return { match, charAfterMatch, captures } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseBlockquote.ts: -------------------------------------------------------------------------------- 1 | import { optional, patternStartingWith } from '../../PatternHelpers' 2 | import { ANY_OPTIONAL_WHITESPACE } from '../../PatternPieces' 3 | import { Blockquote } from '../../SyntaxNodes/Blockquote' 4 | import { getOutlineSyntaxNodes } from './getOutlineSyntaxNodes' 5 | import { LineConsumer } from './LineConsumer' 6 | import { OutlineParser } from './OutlineParser' 7 | 8 | 9 | // Consecutive lines starting with "> " form a blockquote. Blockquotes can contain any outline 10 | // convention, even other blockquotes. 11 | // 12 | // The space directly following the '>' can be omitted, but if it exists, it is considered part of 13 | // the delimiter (and is removed before parsing the blockquoted contents). 14 | export function tryToParseBlockquote(args: OutlineParser.Args): OutlineParser.Result { 15 | const markupLineConsumer = new LineConsumer(args.markupLines) 16 | const blockquotedLines: string[] = [] 17 | 18 | // Collect all consecutive blockquoted lines 19 | while (true) { 20 | const result = markupLineConsumer.consumeLineIfMatches(BLOCKQUOTE_DELIMITER_PATTERN) 21 | 22 | if (!result) { 23 | break 24 | } 25 | 26 | blockquotedLines.push(result.line.replace(BLOCKQUOTE_DELIMITER_PATTERN, '')) 27 | } 28 | 29 | if (!blockquotedLines.length) { 30 | return null 31 | } 32 | 33 | const blockquoteChildren = getOutlineSyntaxNodes({ 34 | markupLines: blockquotedLines, 35 | sourceLineNumber: args.sourceLineNumber, 36 | headingLeveler: args.headingLeveler, 37 | settings: args.settings 38 | }) 39 | 40 | return { 41 | parsedNodes: [new Blockquote(blockquoteChildren)], 42 | countLinesConsumed: markupLineConsumer.countLinesConsumed() 43 | } 44 | } 45 | 46 | 47 | const BLOCKQUOTE_DELIMITER_PATTERN = 48 | patternStartingWith(ANY_OPTIONAL_WHITESPACE + '>' + optional(' ')) 49 | -------------------------------------------------------------------------------- /src/Test/Parsing/NumberedList/Start.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('A numbered list that does not start with a numeral bullet', () => { 6 | it('does not have an explicit starting ordinal', () => { 7 | const markup = ` 8 | #. Hello, world! 9 | # Goodbye, world! 10 | #) Goodbye, world!` 11 | 12 | expect(listStart(markup)).to.be.undefined 13 | }) 14 | 15 | it('does not have an explicit starting ordinal even if the second list item has a numeral bullet', () => { 16 | const markup = ` 17 | #. Hello, world! 18 | 5) Goodbye, world! 19 | #) Goodbye, world!` 20 | 21 | expect(listStart(markup)).to.be.undefined 22 | }) 23 | }) 24 | 25 | 26 | describe('A numbered list that starts with a numeral bullet', () => { 27 | it('has an explicit starting ordinal equal to the numeral value', () => { 28 | const markup = ` 29 | 10) Hello, world! 30 | #. Goodbye, world! 31 | #) Goodbye, world!` 32 | 33 | expect(listStart(markup)).to.equal(10) 34 | }) 35 | }) 36 | 37 | 38 | describe('A numbered list starting ordinal', () => { 39 | it('can be very high', () => { 40 | const markup = ` 41 | 9999) Hello, world! 42 | #. Goodbye, world! 43 | #) Goodbye, world!` 44 | 45 | expect(listStart(markup)).to.equal(9999) 46 | }) 47 | 48 | it('can be zero', () => { 49 | const markup = ` 50 | 0) Hello, world! 51 | #. Goodbye, world! 52 | #) Goodbye, world!` 53 | 54 | expect(listStart(markup)).to.equal(0) 55 | }) 56 | 57 | it('can be negative', () => { 58 | const markup = ` 59 | -5) Hello, world! 60 | #. Goodbye, world! 61 | #) Goodbye, world!` 62 | 63 | expect(listStart(markup)).to.equal(-5) 64 | }) 65 | }) 66 | 67 | 68 | function listStart(numberedListMarkup: string): number | undefined { 69 | const list = 70 | Up.parse(numberedListMarkup).children[0] as Up.NumberedList 71 | 72 | return list.start() 73 | } 74 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseRevealableBlock.ts: -------------------------------------------------------------------------------- 1 | import { either, escapeForRegex, optional, solelyAndIgnoringCapitalization } from '../../PatternHelpers' 2 | import { RevealableBlock } from '../../SyntaxNodes/RevealableBlock' 3 | import { getIndentedBlock } from './getIndentedBlock' 4 | import { getOutlineSyntaxNodes } from './getOutlineSyntaxNodes' 5 | import { LineConsumer } from './LineConsumer' 6 | import { OutlineParser } from './OutlineParser' 7 | 8 | 9 | // A revealable block consists of a "label line" (a keyword followed by an optional colon) followed by 10 | // an indented block. 11 | // 12 | // A revealable block can contain any outline convention, and its label's keyword is case-insensitive. 13 | export function tryToParseRevealableBlock(args: OutlineParser.Args): OutlineParser.Result { 14 | const markupLineConsumer = new LineConsumer(args.markupLines) 15 | const { keywords } = args.settings 16 | 17 | const labelLinePattern = 18 | solelyAndIgnoringCapitalization( 19 | either(...keywords.revealable().map(escapeForRegex)) + optional(':')) 20 | 21 | if (!markupLineConsumer.consumeLineIfMatches(labelLinePattern)) { 22 | return null 23 | } 24 | 25 | const indentedBlockResult = getIndentedBlock(markupLineConsumer.remaining()) 26 | 27 | const contentLines: string[] = [] 28 | 29 | if (indentedBlockResult) { 30 | contentLines.push(...indentedBlockResult.lines) 31 | markupLineConsumer.advance(indentedBlockResult.countLinesConsumed) 32 | } 33 | 34 | if (!contentLines.length) { 35 | return null 36 | } 37 | 38 | const children = getOutlineSyntaxNodes({ 39 | markupLines: contentLines, 40 | // We add 1 because of the label line. 41 | sourceLineNumber: args.sourceLineNumber + 1, 42 | headingLeveler: args.headingLeveler, 43 | settings: args.settings 44 | }) 45 | 46 | return { 47 | parsedNodes: [new RevealableBlock(children)], 48 | countLinesConsumed: markupLineConsumer.countLinesConsumed() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/LineConsumer.ts: -------------------------------------------------------------------------------- 1 | // This class helps incrementally consume a collection of lines (from start to finish) 2 | // using regular expression patterns and predicates. 3 | export class LineConsumer { 4 | private _countLinesConsumed = 0 5 | 6 | constructor(private lines: string[]) { } 7 | 8 | countLinesConsumed(): number { 9 | return this._countLinesConsumed 10 | } 11 | 12 | done(): boolean { 13 | return this._countLinesConsumed >= this.lines.length 14 | } 15 | 16 | remaining(): string[] { 17 | return this.lines.slice(this._countLinesConsumed) 18 | } 19 | 20 | advance(count: number): void { 21 | this._countLinesConsumed += count 22 | } 23 | 24 | // If the line doesn't satisfy the provided conditions, or if there are no more 25 | // lines, this method returns null. 26 | consumeLineIfMatches( 27 | linePattern: RegExp, 28 | options?: { 29 | andIf: (result: LineMatchResult) => boolean 30 | } 31 | ): LineMatchResult | null { 32 | if (this.done()) { 33 | return null 34 | } 35 | 36 | const line = this.nextRemainingLine() 37 | const result = linePattern.exec(line) 38 | 39 | if (result) { 40 | const lineMatchResult = { 41 | line, 42 | captures: result.slice(1) 43 | } 44 | 45 | if (!options || options.andIf(lineMatchResult)) { 46 | this.advance(1) 47 | return lineMatchResult 48 | } 49 | } 50 | 51 | return null 52 | } 53 | 54 | // This method consumes and returns the next remaining line. If there are 55 | // no remaining lines, this method throws an exception. 56 | consumeLine(): string { 57 | if (this.done()) { 58 | throw new Error('No remaining lines') 59 | } 60 | 61 | const line = this.nextRemainingLine() 62 | this.advance(1) 63 | 64 | return line 65 | } 66 | 67 | private nextRemainingLine(): string { 68 | return this.lines[this._countLinesConsumed] 69 | } 70 | } 71 | 72 | 73 | export interface LineMatchResult { 74 | line: string 75 | captures: string[] 76 | } 77 | -------------------------------------------------------------------------------- /src/Implementation/Rendering/Html/HtmlElementHelpers.ts: -------------------------------------------------------------------------------- 1 | import { escapeHtmlAttrValue, escapeHtmlContent } from './HtmlEscapingHelpers' 2 | 3 | 4 | export function htmlElement(tagName: string, unescapedContent: string, attrs: Attrs = {}): string { 5 | return htmlElementWithAlreadyEscapedChildren( 6 | tagName, 7 | [escapeHtmlContent(unescapedContent)], 8 | attrs) 9 | } 10 | 11 | export function htmlElementWithAlreadyEscapedChildren(tagName: string, escapedChildren: string[], attrs: Attrs = {}): string { 12 | return ( 13 | htmlStartTag(tagName, attrs) 14 | + escapedChildren.join('') 15 | + ``) 16 | } 17 | 18 | export function singleTagHtmlElement(tagName: string, attrs: Attrs = {}): string { 19 | return htmlStartTag(tagName, attrs) 20 | } 21 | 22 | // If an attribute's value is `undefined`, we render only its name without any value. 23 | // 24 | // For example, we don't render a value for the `reversed` attribute of numbered lists: 25 | // 26 | //
    27 | //
  1. 28 | //

    Ivysaur

    29 | //
  2. 30 | //
  3. 31 | //

    Bulbasaur

    32 | //
  4. 33 | //
34 | // 35 | // The purpose of this constant is to make that behavior a bit more explicit. 36 | export const EMPTY_ATTRIBUTE_VALUE = undefined 37 | 38 | export type Attrs = { 39 | [name: string]: string | number | typeof EMPTY_ATTRIBUTE_VALUE 40 | } 41 | 42 | 43 | function htmlStartTag(tagName: string, attrs: Attrs): string { 44 | const tagNameWithAttrs = 45 | [tagName, ...htmlAttrs(attrs)].join(' ') 46 | 47 | return `<${tagNameWithAttrs}>` 48 | } 49 | 50 | function htmlAttrs(attrs: Attrs): string[] { 51 | const alphabetizedAttrNames = 52 | Object.keys(attrs).sort() 53 | 54 | return alphabetizedAttrNames.map(attrName => htmlAttr(attrs, attrName)) 55 | } 56 | 57 | function htmlAttr(attrs: Attrs, attrName: string): string { 58 | const value = attrs[attrName] 59 | 60 | return (value === EMPTY_ATTRIBUTE_VALUE) 61 | ? attrName 62 | : `${attrName}="${escapeHtmlAttrValue(value)}"` 63 | } 64 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/NumberedList.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { Renderer } from '../Rendering/Renderer' 3 | import { Heading } from './Heading' 4 | import { InlineSyntaxNode } from './InlineSyntaxNode' 5 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 6 | import { OutlineSyntaxNodeContainer } from './OutlineSyntaxNodeContainer' 7 | 8 | 9 | export class NumberedList implements OutlineSyntaxNode { 10 | sourceLineNumber?: number 11 | 12 | constructor( 13 | public items: NumberedList.Item[], 14 | options?: { sourceLineNumber: number } 15 | ) { 16 | this.sourceLineNumber = options?.sourceLineNumber 17 | } 18 | 19 | start(): number | undefined { 20 | return this.items[0].ordinal 21 | } 22 | 23 | order(): NumberedList.Order { 24 | const [firstOrdinal, secondOrdinal] = 25 | this.items 26 | .filter(item => item.ordinal != null) 27 | .map(item => item.ordinal) 28 | 29 | const firstTwoOrdinalsAreDescending = 30 | firstOrdinal != null 31 | && secondOrdinal != null 32 | && firstOrdinal > secondOrdinal 33 | 34 | return firstTwoOrdinalsAreDescending 35 | ? 'desc' 36 | : 'asc' 37 | } 38 | 39 | descendantsToIncludeInTableOfContents(): Heading[] { 40 | return concat( 41 | this.items.map(item => item.descendantsToIncludeInTableOfContents())) 42 | } 43 | 44 | inlineDescendants(): InlineSyntaxNode[] { 45 | return concat( 46 | this.items.map(item => item.inlineDescendants())) 47 | } 48 | 49 | render(renderer: Renderer): string { 50 | return renderer.numberedList(this) 51 | } 52 | } 53 | 54 | 55 | export namespace NumberedList { 56 | export class Item extends OutlineSyntaxNodeContainer { 57 | ordinal?: number 58 | 59 | constructor( 60 | public children: OutlineSyntaxNode[], 61 | options?: { ordinal?: number } 62 | ) { 63 | super(children) 64 | this.ordinal = options?.ordinal 65 | } 66 | 67 | protected readonly NUMBERED_LIST_ITEM = undefined 68 | } 69 | 70 | export type Order = 'asc' | 'desc' 71 | } 72 | -------------------------------------------------------------------------------- /src/Test/Html/Settings/Reveal.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | context('The "reveal" term is used on the reveal button of revealable content:', () => { 6 | specify('Inline revealables', () => { 7 | const up = new Up.Up({ 8 | rendering: { 9 | terms: { reveal: 'expand' } 10 | } 11 | }) 12 | 13 | const node = new Up.Document([ 14 | new Up.Paragraph([ 15 | new Up.InlineRevealable([ 16 | new Up.Text('Boo!') 17 | ]) 18 | ]) 19 | ]) 20 | 21 | const html = 22 | '

' 23 | + '' 24 | + '' 25 | + '' 26 | + '' 27 | + '' 28 | + 'Boo!' 29 | + '' 30 | + '

' 31 | 32 | expect(up.render(node)).to.equal(html) 33 | }) 34 | 35 | specify('Revealable blocks', () => { 36 | const up = new Up.Up({ 37 | rendering: { 38 | terms: { reveal: 'show' } 39 | } 40 | }) 41 | 42 | const node = new Up.Document([ 43 | new Up.RevealableBlock([ 44 | new Up.Paragraph([ 45 | new Up.Text('Boo!') 46 | ]) 47 | ]) 48 | ]) 49 | 50 | const html = 51 | '
' 52 | + '' 53 | + '' 54 | + '' 55 | + '' 56 | + '
' 57 | + '

Boo!

' 58 | + '
' 59 | + '
' 60 | 61 | expect(up.render(node)).to.equal(html) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/Test/Html/Settings/Hide.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | context('The "hide" term is used on the hide button of revealable content:', () => { 6 | specify('Inline revealables', () => { 7 | const up = new Up.Up({ 8 | rendering: { 9 | terms: { hide: 'collapse' } 10 | } 11 | }) 12 | 13 | const node = new Up.Document([ 14 | new Up.Paragraph([ 15 | new Up.InlineRevealable([ 16 | new Up.Text('Boo!') 17 | ]) 18 | ]) 19 | ]) 20 | 21 | const html = 22 | '

' 23 | + '' 24 | + '' 25 | + '' 26 | + '' 27 | + '' 28 | + 'Boo!' 29 | + '' 30 | + '

' 31 | 32 | expect(up.render(node)).to.equal(html) 33 | }) 34 | 35 | specify('Revealable blocks', () => { 36 | const up = new Up.Up({ 37 | rendering: { 38 | terms: { hide: 'minimize' } 39 | } 40 | }) 41 | 42 | const node = new Up.Document([ 43 | new Up.RevealableBlock([ 44 | new Up.Paragraph([ 45 | new Up.Text('Boo!') 46 | ]) 47 | ]) 48 | ]) 49 | 50 | const html = 51 | '
' 52 | + '' 53 | + '' 54 | + '' 55 | + '' 56 | + '
' 57 | + '

Boo!

' 58 | + '
' 59 | + '
' 60 | 61 | expect(up.render(node)).to.equal(html) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/DescriptionList.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { Renderer } from '../Rendering/Renderer' 3 | import { Heading } from './Heading' 4 | import { InlineSyntaxNode } from './InlineSyntaxNode' 5 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 6 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 7 | import { OutlineSyntaxNodeContainer } from './OutlineSyntaxNodeContainer' 8 | 9 | 10 | export class DescriptionList implements OutlineSyntaxNode { 11 | sourceLineNumber?: number 12 | 13 | constructor( 14 | public items: DescriptionList.Item[], 15 | options?: { sourceLineNumber: number } 16 | ) { 17 | this.sourceLineNumber = options?.sourceLineNumber 18 | } 19 | 20 | descendantsToIncludeInTableOfContents(): Heading[] { 21 | return concat( 22 | this.items.map(item => item.descendantsToIncludeInTableOfContents())) 23 | } 24 | 25 | inlineDescendants(): InlineSyntaxNode[] { 26 | return concat( 27 | this.items.map(item => item.inlineDescendants())) 28 | } 29 | 30 | render(renderer: Renderer): string { 31 | return renderer.descriptionList(this) 32 | } 33 | } 34 | 35 | 36 | export namespace DescriptionList { 37 | export class Item { 38 | constructor( 39 | public subjects: DescriptionList.Item.Subject[], 40 | public description: DescriptionList.Item.Description) { } 41 | 42 | descendantsToIncludeInTableOfContents(): Heading[] { 43 | return this.description.descendantsToIncludeInTableOfContents() 44 | } 45 | 46 | inlineDescendants(): InlineSyntaxNode[] { 47 | return concat([ 48 | ...this.subjects, 49 | this.description 50 | ].map(subjectOrDescription => subjectOrDescription.inlineDescendants())) 51 | } 52 | } 53 | 54 | export namespace Item { 55 | export class Subject extends InlineSyntaxNodeContainer { 56 | protected readonly DESCRIPTION_LIST_ITEM_SUBJECT = undefined 57 | } 58 | 59 | export class Description extends OutlineSyntaxNodeContainer { 60 | protected readonly DESCRIPTION_LIST_ITEM_DESCRIPTION = undefined 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Escaping.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('A backslash that is the first character in a paragraph', () => { 7 | it('correctly escapes the next character', () => { 8 | expect(Up.parse('\\*So many* Tuesdays')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('*So many* Tuesdays') 11 | ])) 12 | }) 13 | }) 14 | 15 | 16 | describe("A backslash that is the first character in a line block's first line", () => { 17 | it('correctly escapes the next character', () => { 18 | const markup = ` 19 | \\Roses are red 20 | Violets are blue` 21 | 22 | expect(Up.parse(markup)).to.deep.equal( 23 | new Up.Document([ 24 | new Up.LineBlock([ 25 | new Up.LineBlock.Line([new Up.Text('Roses are red')]), 26 | new Up.LineBlock.Line([new Up.Text('Violets are blue')]) 27 | ]) 28 | ])) 29 | }) 30 | }) 31 | 32 | 33 | describe("A backslash that is the first character in a line block's second line", () => { 34 | it('correctly escapes the next character', () => { 35 | const markup = ` 36 | Roses are red 37 | \\Violets are blue` 38 | 39 | expect(Up.parse(markup)).to.deep.equal( 40 | new Up.Document([ 41 | new Up.LineBlock([ 42 | new Up.LineBlock.Line([new Up.Text('Roses are red')]), 43 | new Up.LineBlock.Line([new Up.Text('Violets are blue')]) 44 | ]) 45 | ])) 46 | }) 47 | }) 48 | 49 | 50 | describe('4 consecutive backslashes', () => { 51 | it('produce plain text consisting of 2 consecutive backslashes', () => { 52 | expect(Up.parse('\\\\\\\\')).to.deep.equal( 53 | insideDocumentAndParagraph([ 54 | new Up.Text('\\\\') 55 | ])) 56 | }) 57 | }) 58 | 59 | 60 | describe('An escaped character', () => { 61 | it('can immediately follow inline code', () => { 62 | expect(Up.parse('`pennsylvania()`\\ avenue')).to.deep.equal( 63 | insideDocumentAndParagraph([ 64 | new Up.InlineCode('pennsylvania()'), 65 | new Up.Text(' avenue') 66 | ])) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Overlapping.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('A paragraph with 2 separate instances of overlapped conventions with equal continuity priority', () => { 7 | it('produce the correct nodes for each', () => { 8 | expect(Up.parse('I *love ==drinking* whole== milk. I *love ==drinking* whole== milk.')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('I '), 11 | new Up.Emphasis([ 12 | new Up.Text('love '), 13 | new Up.Highlight([ 14 | new Up.Text('drinking') 15 | ]) 16 | ]), 17 | new Up.Highlight([ 18 | new Up.Text(' whole') 19 | ]), 20 | new Up.Text(' milk. I '), 21 | new Up.Emphasis([ 22 | new Up.Text('love '), 23 | new Up.Highlight([ 24 | new Up.Text('drinking') 25 | ]) 26 | ]), 27 | new Up.Highlight([ 28 | new Up.Text(' whole') 29 | ]), 30 | new Up.Text(' milk.') 31 | ])) 32 | }) 33 | }) 34 | 35 | 36 | describe('A paragraph with 2 (separately!) overlapped links', () => { 37 | it('produces the correct nodes for each', () => { 38 | const markup = 'I do *not [care* at][https://en.wikipedia.org/wiki/Carrot] all. I do *not [care* at][https://en.wikipedia.org/wiki/Carrot] all.' 39 | 40 | expect(Up.parse(markup)).to.deep.equal( 41 | insideDocumentAndParagraph([ 42 | new Up.Text('I do '), 43 | new Up.Emphasis([ 44 | new Up.Text('not ') 45 | ]), 46 | new Up.Link([ 47 | new Up.Emphasis([ 48 | new Up.Text('care') 49 | ]), 50 | new Up.Text(' at') 51 | ], 'https://en.wikipedia.org/wiki/Carrot'), 52 | new Up.Text(' all. I do '), 53 | new Up.Emphasis([ 54 | new Up.Text('not ') 55 | ]), 56 | new Up.Link([ 57 | new Up.Emphasis([ 58 | new Up.Text('care') 59 | ]), 60 | new Up.Text(' at') 61 | ], 'https://en.wikipedia.org/wiki/Carrot'), 62 | new Up.Text(' all.') 63 | ])) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/RevealableBlock.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | context("A revealable block's label line does not produce a spoiler block node if it is", () => { 7 | specify('the last line of the document', () => { 8 | expect(Up.parse('SPOILER:')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('SPOILER:') 11 | ])) 12 | }) 13 | 14 | specify('immediately followed by non-indented text', () => { 15 | const markup = ` 16 | Spoiler: 17 | No! 18 | Roses don't glow!` 19 | expect(Up.parse(markup)).to.deep.equal( 20 | new Up.Document([ 21 | new Up.LineBlock([ 22 | new Up.LineBlock.Line([new Up.Text('Spoiler:')]), 23 | new Up.LineBlock.Line([new Up.Text('No!')]), 24 | new Up.LineBlock.Line([new Up.Text("Roses don't glow!")]) 25 | ]) 26 | ])) 27 | }) 28 | 29 | specify('followed by a blank line then a non-indented line', () => { 30 | const markup = ` 31 | Spoiler: 32 | 33 | No!` 34 | expect(Up.parse(markup)).to.deep.equal( 35 | new Up.Document([ 36 | new Up.Paragraph([ 37 | new Up.Text('Spoiler:') 38 | ]), 39 | new Up.Paragraph([ 40 | new Up.Text('No!') 41 | ]) 42 | ])) 43 | }) 44 | 45 | specify('followed by 2 blank lines then a non-indented line', () => { 46 | const markup = ` 47 | Spoiler: 48 | 49 | 50 | No!` 51 | expect(Up.parse(markup)).to.deep.equal( 52 | new Up.Document([ 53 | new Up.Paragraph([ 54 | new Up.Text('Spoiler:') 55 | ]), 56 | new Up.Paragraph([ 57 | new Up.Text('No!') 58 | ]) 59 | ])) 60 | }) 61 | 62 | specify('followed by 3 or more blank lines then a non-indented line', () => { 63 | const markup = ` 64 | Spoiler: 65 | 66 | 67 | 68 | 69 | No!` 70 | expect(Up.parse(markup)).to.deep.equal( 71 | new Up.Document([ 72 | new Up.Paragraph([ 73 | new Up.Text('Spoiler:') 74 | ]), 75 | new Up.ThematicBreak(), 76 | new Up.Paragraph([ 77 | new Up.Text('No!') 78 | ]) 79 | ])) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseCodeBlock.ts: -------------------------------------------------------------------------------- 1 | import { streakOf } from '../../PatternHelpers' 2 | import { CodeBlock } from '../../SyntaxNodes/CodeBlock' 3 | import { LineConsumer } from './LineConsumer' 4 | import { OutlineParser } from './OutlineParser' 5 | 6 | 7 | // Code blocks are surrounded (underlined and overlined) by matching streaks of backticks. 8 | // 9 | // If no matching end streak is found, the code block extends to the end of the document (or to 10 | // the end of the current outline convention, if the code block is nested within one). 11 | // 12 | // Code blocks can contain streaks of backticks that aren't exactly as long as the enclosing streaks. 13 | export function tryToParseCodeBlock(args: OutlineParser.Args): OutlineParser.Result { 14 | const markupLineConsumer = new LineConsumer(args.markupLines) 15 | 16 | const startStreakResult = 17 | markupLineConsumer.consumeLineIfMatches(CODE_BLOCK_STREAK_PATTERN) 18 | 19 | if (!startStreakResult) { 20 | return null 21 | } 22 | 23 | const startStreak = startStreakResult.line.trim() 24 | const codeLines: string[] = [] 25 | 26 | // Let's keep consuming lines until we find a streak that matches the first one. 27 | while (!markupLineConsumer.done()) { 28 | const endStreakResult = 29 | markupLineConsumer.consumeLineIfMatches(CODE_BLOCK_STREAK_PATTERN) 30 | 31 | if (endStreakResult) { 32 | const endStreak = endStreakResult.line.trim() 33 | 34 | // Alright, we have a possible end streak! 35 | if (endStreak.length === startStreak.length) { 36 | // It matches the start streak! Let's bail. 37 | break 38 | } 39 | 40 | // The streak didn't match the start streak, so let's include it in the code block. 41 | codeLines.push(endStreak) 42 | continue 43 | } 44 | 45 | // Since we don't have a possible end streak, we'll just treat this line as code and move 46 | // on to the next one. 47 | codeLines.push(markupLineConsumer.consumeLine()) 48 | } 49 | 50 | return { 51 | parsedNodes: [new CodeBlock(codeLines.join(RENDERED_LINE_BREAK))], 52 | countLinesConsumed: markupLineConsumer.countLinesConsumed() 53 | } 54 | } 55 | 56 | 57 | const CODE_BLOCK_STREAK_PATTERN = 58 | streakOf('`') 59 | 60 | // Eventually, this should be configurable. 61 | const RENDERED_LINE_BREAK = '\n' 62 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/DelimitersRepresentingContent.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | context('An unmatched left parenthesis within quotation marks produces an inline quote containing the parenthesis:', () => { 7 | specify('At the end of a paragraph', () => { 8 | expect(Up.parse('"("')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.InlineQuote([ 11 | new Up.Text('(') 12 | ]) 13 | ])) 14 | }) 15 | 16 | specify('Before the end of a paragraph', () => { 17 | expect(Up.parse('"(" is a left parenthesis.')).to.deep.equal( 18 | insideDocumentAndParagraph([ 19 | new Up.InlineQuote([ 20 | new Up.Text('(') 21 | ]), 22 | new Up.Text(' is a left parenthesis.') 23 | ])) 24 | }) 25 | }) 26 | 27 | 28 | context('An unmatched left parenthesis within square brackets produces a square parenthetical containing the parenthesis:', () => { 29 | specify('At the end of a paragraph', () => { 30 | expect(Up.parse('[(]')).to.deep.equal( 31 | insideDocumentAndParagraph([ 32 | new Up.SquareParenthetical([ 33 | new Up.Text('[(]') 34 | ]) 35 | ])) 36 | }) 37 | 38 | specify('Before the end of a paragraph', () => { 39 | expect(Up.parse('[(] is a left parenthesis.')).to.deep.equal( 40 | insideDocumentAndParagraph([ 41 | new Up.SquareParenthetical([ 42 | new Up.Text('[(]') 43 | ]), 44 | new Up.Text(' is a left parenthesis.') 45 | ])) 46 | }) 47 | }) 48 | 49 | 50 | context('An unmatched quotation mark within square brackets produces a square parenthetical containing the quotation mark:', () => { 51 | specify('At the end of a paragraph', () => { 52 | expect(Up.parse('["]')).to.deep.equal( 53 | insideDocumentAndParagraph([ 54 | new Up.SquareParenthetical([ 55 | new Up.Text('["]') 56 | ]) 57 | ])) 58 | }) 59 | 60 | specify('Before the end of a paragraph', () => { 61 | expect(Up.parse('["] is a quotation mark.')).to.deep.equal( 62 | insideDocumentAndParagraph([ 63 | new Up.SquareParenthetical([ 64 | new Up.Text('["]') 65 | ]), 66 | new Up.Text(' is a quotation mark.') 67 | ])) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseBlankLineSeparation.ts: -------------------------------------------------------------------------------- 1 | import { BLANK_PATTERN } from '../../Patterns' 2 | import { ThematicBreak } from '../../SyntaxNodes/ThematicBreak' 3 | import { LineConsumer } from './LineConsumer' 4 | import { OutlineParser } from './OutlineParser' 5 | 6 | // Outline conventions (e.g. paragraphs, headings) are normally separated by 1 or 2 consecutive 7 | // blank lines. The blank lines themselves don't produce any syntax nodes. 8 | // 9 | // However, 3 or more consecutive blank lines indicates extra, purposeful separation between 10 | // outline conventions. We represent that separation with a `ThematicBreak` syntax node. 11 | // 12 | // NOTE: "Separation" is the magic word! Outer blank lines carry no semantic significance, and 13 | // they never produce thematic breaks. 14 | export function tryToParseBlankLineSeparation(args: OutlineParser.Args): OutlineParser.Result { 15 | const markupLineConsumer = new LineConsumer(args.markupLines) 16 | 17 | while (true) { 18 | if (markupLineConsumer.done()) { 19 | // We've reached the end of our text! As mentioned above, outer blank lines never 20 | // produce thematic breaks. 21 | return { 22 | parsedNodes: [], 23 | countLinesConsumed: args.markupLines.length 24 | } 25 | } 26 | 27 | if (!markupLineConsumer.consumeLineIfMatches(BLANK_PATTERN)) { 28 | break 29 | } 30 | } 31 | 32 | const countBlankLines = markupLineConsumer.countLinesConsumed() 33 | 34 | if (!countBlankLines) { 35 | // If there are no blank lines, we can't say we parsed anything. Bail! 36 | return null 37 | } 38 | 39 | return { 40 | parsedNodes: ( 41 | // This condition is easy. If we have fewer than 3 blank lines, we aren't dealing with a 42 | // thematic break. 43 | countBlankLines < 3 44 | // If we don't have a most recent sibling, that means we just parsed *leading* blank lines. 45 | // As mentioned above, outer blank lines never produce thematic breaks. 46 | || !args.mostRecentSibling 47 | // To produce a cleaner document, we condense multiple consecutive thematic breaks into one. 48 | // (If the most recent sibling is a thematic break, we don't need another.) 49 | || args.mostRecentSibling instanceof ThematicBreak 50 | ) 51 | ? [] 52 | : [new ThematicBreak()], 53 | countLinesConsumed: countBlankLines 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Test/Parsing/Settings/Video.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('The keyword that represents video conventions', () => { 6 | const up = new Up.Up({ 7 | parsing: { 8 | keywords: { video: 'watch' } 9 | } 10 | }) 11 | 12 | it('comes from the "video" keyword', () => { 13 | const markup = '[watch: Nevada caucus footage][https://example.com/video.webm]' 14 | 15 | expect(up.parse(markup)).to.deep.equal( 16 | new Up.Document([ 17 | new Up.Video('Nevada caucus footage', 'https://example.com/video.webm') 18 | ])) 19 | }) 20 | 21 | it('is case-insensitive', () => { 22 | const lowercase = '[watch: Nevada caucus footage][https://example.com/video.webm]' 23 | const mixedCase = '[WaTCH: Nevada caucus footage][https://example.com/video.webm]' 24 | 25 | expect(up.parse(lowercase)).to.deep.equal(up.parse(mixedCase)) 26 | }) 27 | 28 | it('is trimmed', () => { 29 | const markup = '[watch: Nevada caucus footage][https://example.com/video.webm]' 30 | 31 | const document = Up.parse(markup, { 32 | keywords: { 33 | video: ' \t watch \t ' 34 | } 35 | }) 36 | 37 | expect(document).to.deep.equal( 38 | new Up.Document([ 39 | new Up.Video('Nevada caucus footage', 'https://example.com/video.webm') 40 | ])) 41 | }) 42 | 43 | it('ignores inline conventions and regular expression rules', () => { 44 | const markup = '[*watch*: Nevada caucus footage][https://example.com/video.webm]' 45 | 46 | const document = Up.parse(markup, { 47 | keywords: { 48 | video: '*watch*' 49 | } 50 | }) 51 | 52 | expect(document).to.deep.equal( 53 | new Up.Document([ 54 | new Up.Video('Nevada caucus footage', 'https://example.com/video.webm') 55 | ])) 56 | }) 57 | 58 | it('can have multiple variations', () => { 59 | const markup = '[watch: Nevada caucus footage](https://example.com/video.webm) [view: Nevada caucus footage](https://example.com/video.webm)' 60 | 61 | const document = Up.parse(markup, { 62 | keywords: { 63 | video: ['view', 'watch'] 64 | } 65 | }) 66 | 67 | expect(document).to.deep.equal( 68 | new Up.Document([ 69 | new Up.Video('Nevada caucus footage', 'https://example.com/video.webm'), 70 | new Up.Video('Nevada caucus footage', 'https://example.com/video.webm') 71 | ])) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/HeadingLeveler.ts: -------------------------------------------------------------------------------- 1 | import { distinct } from '../../CollectionHelpers' 2 | 3 | 4 | // Instances of this class track of which underline and overline characters are 5 | // associated with which heading level. 6 | export class HeadingLeveler { 7 | private headingSignatures: string[] = [] 8 | 9 | registerHeadingAndGetLevel(underline: string, hasOverline: boolean): number { 10 | // Yes, this requires some explanation! 11 | // 12 | // As mentioned in `tryToParseHeading.ts`: 13 | // 14 | // 1. Headings with the same combination of underline characters share the same 15 | // level 16 | // 2. Headings with overlines are always considered distinct from headings without 17 | // overlines, even if their underlines are the same. So a heading with an 18 | // overline will never have the same level has a heading without an overline. 19 | // 20 | // Therefore, when determining the level of a given heading, we need just two 21 | // pieces of information: 22 | // 23 | // 1. The distinct characters in the underline 24 | // 2. Whether the heading has an overline 25 | // 26 | // These two pieces of information comprise a heading's signature, hence the 27 | // following weird line. 28 | const headingSignature = 29 | fingerprint(underline) + (hasOverline ? 'with overline' : '') 30 | 31 | const hasCombinationOfUnderlineAndOverlineAlreadyBeenUsed = 32 | this.headingSignatures.indexOf(headingSignature) !== -1 33 | 34 | if (!hasCombinationOfUnderlineAndOverlineAlreadyBeenUsed) { 35 | this.headingSignatures.push(headingSignature) 36 | } 37 | 38 | return this.getLevel(headingSignature) 39 | } 40 | 41 | private getLevel(headingSignature: string): number { 42 | return this.headingSignatures.indexOf(headingSignature) + 1 43 | } 44 | } 45 | 46 | 47 | export function isUnderlineConsistentWithOverline(underline: string, overline: string | null): boolean { 48 | return !overline || (fingerprint(underline) === fingerprint(overline)) 49 | } 50 | 51 | 52 | // Returns a string containing only the distinct characters from trimmed `line`, sorted 53 | // according to the characters' Unicode code points. 54 | // 55 | // For example, when `line` is " =-~-=-~-=-~-= ", this function returns "-=~". 56 | function fingerprint(line: string): string { 57 | const chars = 58 | line.trim().split('') 59 | 60 | return distinct(...chars).sort().join('') 61 | } 62 | -------------------------------------------------------------------------------- /src/Test/Parsing/Settings/Audio.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('The keyword that represents video conventions', () => { 6 | const up = new Up.Up({ 7 | parsing: { 8 | keywords: { audio: 'listen' } 9 | } 10 | }) 11 | 12 | it('comes from the "audio" keyword', () => { 13 | const markup = '[listen: chanting at Nevada caucus][https://example.com/audio.ogg]' 14 | 15 | expect(up.parse(markup)).to.deep.equal( 16 | new Up.Document([ 17 | new Up.Audio('chanting at Nevada caucus', 'https://example.com/audio.ogg') 18 | ])) 19 | }) 20 | 21 | it('is case-insensitive', () => { 22 | const lowercase = '[listen: chanting at Nevada caucus][https://example.com/audio.ogg]' 23 | const mixedCase = '[LiStEn: chanting at Nevada caucus][https://example.com/audio.ogg]' 24 | 25 | expect(up.parse(mixedCase)).to.deep.equal(up.parse(lowercase)) 26 | }) 27 | 28 | it('is trimmed', () => { 29 | const markup = '[listen: chanting at Nevada caucus][https://example.com/audio.ogg]' 30 | 31 | const document = Up.parse(markup, { 32 | keywords: { 33 | audio: ' \t listen \t ' 34 | } 35 | }) 36 | 37 | expect(document).to.deep.equal( 38 | new Up.Document([ 39 | new Up.Audio('chanting at Nevada caucus', 'https://example.com/audio.ogg') 40 | ])) 41 | }) 42 | 43 | it('ignores inline conventions and regular expression rules', () => { 44 | const markup = '[*listen*: chanting at Nevada caucus][https://example.com/audio.ogg]' 45 | 46 | const document = Up.parse(markup, { 47 | keywords: { 48 | audio: '*listen*' 49 | } 50 | }) 51 | 52 | expect(document).to.deep.equal( 53 | new Up.Document([ 54 | new Up.Audio('chanting at Nevada caucus', 'https://example.com/audio.ogg') 55 | ])) 56 | }) 57 | 58 | it('can have multiple variations', () => { 59 | const markup = '[hear: chanting at Nevada caucus](https://example.com/audio.ogg) [listen: chanting at Nevada caucus](https://example.com/audio.ogg)' 60 | 61 | const document = Up.parse(markup, { 62 | keywords: { 63 | audio: ['hear', 'listen'] 64 | } 65 | }) 66 | 67 | expect(document).to.deep.equal( 68 | new Up.Document([ 69 | new Up.Audio('chanting at Nevada caucus', 'https://example.com/audio.ogg'), 70 | new Up.Audio('chanting at Nevada caucus', 'https://example.com/audio.ogg') 71 | ])) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/getIndentedBlock.ts: -------------------------------------------------------------------------------- 1 | import { BLANK_PATTERN, INDENTED_PATTERN } from '../../Patterns' 2 | import { LineConsumer } from './LineConsumer' 3 | 4 | 5 | // Indented blocks include indented and blank lines. 6 | export function getIndentedBlock(lines: string[]): IndentedBlockResult | null { 7 | const markupLineConsumer = new LineConsumer(lines) 8 | const indentedLines: string[] = [] 9 | let countLinesWithoutTrailingBlankLines = 0 10 | 11 | while (!markupLineConsumer.done()) { 12 | const blankLineResult = 13 | markupLineConsumer.consumeLineIfMatches(BLANK_PATTERN) 14 | 15 | if (blankLineResult) { 16 | // The line was blank, so we don't yet know whether the author intended for the line to be 17 | // included in the indented block or not (it could be trailing). We'll move onto the next 18 | // line without updating `indentedBlockLineCount`. 19 | indentedLines.push(blankLineResult.line) 20 | continue 21 | } 22 | 23 | const indentedLineResult = 24 | markupLineConsumer.consumeLineIfMatches(INDENTED_PATTERN) 25 | 26 | if (!indentedLineResult) { 27 | // The current line is neither blank nor indented. We're done! 28 | break 29 | } 30 | 31 | indentedLines.push(indentedLineResult.line) 32 | countLinesWithoutTrailingBlankLines = indentedLines.length 33 | } 34 | 35 | if (!indentedLines.length) { 36 | return null 37 | } 38 | 39 | const countTrailingBlankLines = (indentedLines.length - countLinesWithoutTrailingBlankLines) 40 | const hasMultipleTrailingBlankLines = (countTrailingBlankLines >= 2) 41 | 42 | // If an indented block has a single trailing blank line, its trailing line is consumed but not 43 | // included as content. 44 | // 45 | // If there are two or more trailing blank lines, those trailing blank lines are neither consumed 46 | // nor included. They're instead left behind for another outline convention to deal with. 47 | const countLinesConsumed = 48 | hasMultipleTrailingBlankLines 49 | ? countLinesWithoutTrailingBlankLines 50 | : indentedLines.length 51 | 52 | const linesWithoutIndentation = indentedLines 53 | .slice(0, countLinesWithoutTrailingBlankLines) 54 | .map(line => line.replace(INDENTED_PATTERN, '')) 55 | 56 | return { 57 | lines: linesWithoutIndentation, 58 | countLinesConsumed, 59 | hasMultipleTrailingBlankLines 60 | } 61 | } 62 | 63 | 64 | export interface IndentedBlockResult { 65 | lines: string[] 66 | countLinesConsumed: number 67 | hasMultipleTrailingBlankLines: boolean 68 | } 69 | -------------------------------------------------------------------------------- /src/Test/Parsing/Escaping.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | describe('A backslash', () => { 7 | it('disables any special behavior of the character that follows, preserving the other character as plain text', () => { 8 | expect(Up.parse('Hello, \\*world\\*!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Hello, *world*!') 11 | ])) 12 | }) 13 | 14 | it("has no effect if the following character didn't have any special behavior to begin with", () => { 15 | expect(Up.parse('Hello, \\world!')).to.deep.equal( 16 | insideDocumentAndParagraph([ 17 | new Up.Text('Hello, world!') 18 | ])) 19 | }) 20 | 21 | it('can disable the special behavior of another backslash', () => { 22 | expect(Up.parse('Hello, \\\\*world*!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Hello, \\'), 25 | new Up.Emphasis([ 26 | new Up.Text('world') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | 32 | it('causes only the following character to be treated as plain text', () => { 33 | expect(Up.parse('Hello, \\\\, meet \\\\!')).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text('Hello, \\, meet \\!') 36 | ])) 37 | }) 38 | 39 | it('is ignored if it is the final character of the markup', () => { 40 | expect(Up.parse('Hello, Bob.\\')).to.deep.equal( 41 | insideDocumentAndParagraph([ 42 | new Up.Text('Hello, Bob.') 43 | ])) 44 | }) 45 | }) 46 | 47 | 48 | context("Backslashes don't disable line breaks:", () => { 49 | specify('At the end of a line in a line block', () => { 50 | const markup = ` 51 | Hello, world!\\ 52 | Goodbye, world!` 53 | expect(Up.parse(markup)).to.deep.equal( 54 | new Up.Document([ 55 | new Up.LineBlock([ 56 | new Up.LineBlock.Line([ 57 | new Up.Text('Hello, world!') 58 | ]), 59 | new Up.LineBlock.Line([ 60 | new Up.Text('Goodbye, world!') 61 | ]) 62 | ]) 63 | ])) 64 | }) 65 | 66 | specify('At the end of a paragraph', () => { 67 | const markup = ` 68 | Hello, world!\\ 69 | 70 | Goodbye, world!` 71 | expect(Up.parse(markup)).to.deep.equal( 72 | new Up.Document([ 73 | new Up.Paragraph([ 74 | new Up.Text('Hello, world!') 75 | ]), 76 | new Up.Paragraph([ 77 | new Up.Text('Goodbye, world!') 78 | ]) 79 | ])) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/trimEscapedAndUnescapedOuterWhitespace.ts: -------------------------------------------------------------------------------- 1 | import { WHITESPACE_CHAR_PATTERN } from '../../../Patterns' 2 | import { BACKSLASH } from '../../Strings' 3 | 4 | 5 | // For inline markup, any outer whitespace is considered meaningless, even when it's escaped. 6 | // This function strips it all away. 7 | export function trimEscapedAndUnescapedOuterWhitespace(markup: string): string { 8 | // Note: To avoid catastrophic slowdown, we don't use a single regular expression for this. For more 9 | // information, please see: http://stackstatus.net/post/147710624694/outage-postmortem-july-20-2016 10 | 11 | // Let's review the rules! 12 | // 13 | // Backslashes are only preserved when: 14 | // 15 | // 1. They are themselves escaped 16 | // 2. They appear in inline code 17 | // 18 | // If a given backslash is not escaped, it escapes the following character without being 19 | // preserved itself. And if there is no following character (i.e. if the backslash was 20 | // the last character in the markup), then the backslash is simply ignored. 21 | while (true) { 22 | markup = markup.trim() 23 | 24 | // We've trimmed away the outer layer of unescaped whitespace, but we haven't touched the outer 25 | // layer of escaped whitespace, if there is any. If there isn't any, we know we're done. 26 | const { length } = markup 27 | 28 | const isFirstCharEscapingWhitespace = 29 | markup[0] === BACKSLASH 30 | && ( 31 | markup.length === 1 32 | || WHITESPACE_CHAR_PATTERN.test(markup[1]) 33 | ) 34 | 35 | if (isFirstCharEscapingWhitespace) { 36 | // Let's trim away both the backslash and the single whitespace character it escapes. 37 | markup = markup.slice(2) 38 | } 39 | 40 | const secondToLastChar = markup[length - 2] 41 | const lastChar = markup[length - 1] 42 | 43 | if ((lastChar === BACKSLASH) && (secondToLastChar !== BACKSLASH)) { 44 | // This will trim away the final backslash, even if there isn't any whitespace before it. 45 | // That's fine! As discussed above, unless a trailing backslash is escaped, it should be 46 | // ignored. 47 | // 48 | // However, let's say the markup is "hello\\\". In this case, the last backslash isn't 49 | // escaped, because the preceding backslash is *itself* escaped! This function makes no 50 | // attempt to resolve those cases. The tokenizer will take care of that just fine. 51 | markup = markup.slice(0, -1) 52 | } 53 | 54 | if (markup.length === length) { 55 | // There was no outer escaped whitespace, so there's nothing more for us to do. 56 | return markup 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Test/Parsing/Italics.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | describe('Text surrounded by single underscores', () => { 7 | it('is put inside an italics node', () => { 8 | expect(Up.parse('Hello, _world_!!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Hello, '), 11 | new Up.Italic([ 12 | new Up.Text('world') 13 | ]), 14 | new Up.Text('!!') 15 | ])) 16 | }) 17 | }) 18 | 19 | 20 | describe('Italicized text', () => { 21 | it('is evaluated for inline conventions', () => { 22 | expect(Up.parse('Hello, _`world`_!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Hello, '), 25 | new Up.Italic([ 26 | new Up.InlineCode('world') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | 32 | it('can contain further italicized text', () => { 33 | expect(Up.parse('Hello, _my _little_ world_!')).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text('Hello, '), 36 | new Up.Italic([ 37 | new Up.Text('my '), 38 | new Up.Italic([ 39 | new Up.Text('little') 40 | ]), 41 | new Up.Text(' world') 42 | ]), 43 | new Up.Text('!') 44 | ])) 45 | }) 46 | 47 | it('can contain stressed text', () => { 48 | expect(Up.parse('Hello, _my __little__ world_!')).to.deep.equal( 49 | insideDocumentAndParagraph([ 50 | new Up.Text('Hello, '), 51 | new Up.Italic([ 52 | new Up.Text('my '), 53 | new Up.Bold([ 54 | new Up.Text('little') 55 | ]), 56 | new Up.Text(' world') 57 | ]), 58 | new Up.Text('!') 59 | ])) 60 | }) 61 | }) 62 | 63 | 64 | describe('Double underscores followed by two separate single closing underscores', () => { 65 | it('produces 2 nested italics nodes', () => { 66 | expect(Up.parse('__Warning:_ never feed this tarantula_')).to.deep.equal( 67 | insideDocumentAndParagraph([ 68 | new Up.Italic([ 69 | new Up.Italic([ 70 | new Up.Text('Warning:') 71 | ]), 72 | new Up.Text(' never feed this tarantula') 73 | ]) 74 | ])) 75 | }) 76 | }) 77 | 78 | 79 | describe('Text separated from (otherwise surrounding) underscores by whitespace', () => { 80 | it('is not put inside an italics node', () => { 81 | expect(Up.parse('Birdie Sanders _ won _ Wisconsin')).to.deep.equal( 82 | insideDocumentAndParagraph([ 83 | new Up.Text('Birdie Sanders _ won _ Wisconsin') 84 | ])) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/Test/Parsing/Emphasis.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | describe('Text surrounded by single asterisks', () => { 7 | it('is put inside an emphasis node', () => { 8 | expect(Up.parse('Hello, *world*!!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Hello, '), 11 | new Up.Emphasis([ 12 | new Up.Text('world') 13 | ]), 14 | new Up.Text('!!') 15 | ])) 16 | }) 17 | }) 18 | 19 | 20 | describe('Emphasized text', () => { 21 | it('is evaluated for inline conventions', () => { 22 | expect(Up.parse('Hello, *`world`*!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Hello, '), 25 | new Up.Emphasis([ 26 | new Up.InlineCode('world') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | 32 | it('can contain further emphasized text', () => { 33 | expect(Up.parse('Hello, *my *little* world*!')).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text('Hello, '), 36 | new Up.Emphasis([ 37 | new Up.Text('my '), 38 | new Up.Emphasis([ 39 | new Up.Text('little') 40 | ]), 41 | new Up.Text(' world') 42 | ]), 43 | new Up.Text('!') 44 | ])) 45 | }) 46 | 47 | it('can contain stressed text', () => { 48 | expect(Up.parse('Hello, *my **little** world*!')).to.deep.equal( 49 | insideDocumentAndParagraph([ 50 | new Up.Text('Hello, '), 51 | new Up.Emphasis([ 52 | new Up.Text('my '), 53 | new Up.Stress([ 54 | new Up.Text('little') 55 | ]), 56 | new Up.Text(' world') 57 | ]), 58 | new Up.Text('!') 59 | ])) 60 | }) 61 | }) 62 | 63 | 64 | describe('Double asterisks followed by two separate single closing asterisks', () => { 65 | it('produces 2 nested emphasis nodes', () => { 66 | expect(Up.parse('**Warning:* never feed this tarantula*')).to.deep.equal( 67 | insideDocumentAndParagraph([ 68 | new Up.Emphasis([ 69 | new Up.Emphasis([ 70 | new Up.Text('Warning:') 71 | ]), 72 | new Up.Text(' never feed this tarantula') 73 | ]) 74 | ])) 75 | }) 76 | }) 77 | 78 | 79 | describe('Text separated from (otherwise surrounding) single asterisks by whitespace', () => { 80 | it('is not put inside an emphasis node', () => { 81 | expect(Up.parse('Birdie Sanders * won * Wisconsin')).to.deep.equal( 82 | insideDocumentAndParagraph([ 83 | new Up.Text('Birdie Sanders * won * Wisconsin') 84 | ])) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/Tokenizing/ConventionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { patternIgnoringCapitalizationAndStartingWith, patternStartingWith } from '../../../PatternHelpers' 2 | import { TokenRole } from '../TokenRole' 3 | import { OpenConvention } from './OpenConvention' 4 | 5 | 6 | // The start/end delimiters of a convention definition are ultimately represented by 7 | // RegExp patterns anchored to the beginning of the input string (using `^`). 8 | // 9 | // For convenience, the ConventionDefinition constructor instead accepts un-anchored 10 | // string patterns for those delimiters. (Those strings are then converted into 11 | // anchored RegExp patterns.) 12 | 13 | type StringDelimiters = { 14 | startsWith: string 15 | endsWith?: string 16 | } 17 | 18 | export type ConventionDefinitionArgs = 19 | Omit 20 | & StringDelimiters 21 | 22 | 23 | // This represents the rules for a single variation of an inline writing convention. 24 | // For example: an inline revealable convention delimited by square brackets. 25 | export class ConventionDefinition { 26 | startsWith: RegExp 27 | endsWith?: RegExp 28 | canOnlyOpenIfDirectlyFollowing?: TokenRole[] 29 | isCutShortByWhitespace?: boolean 30 | canConsistSolelyOfWhitespace?: boolean 31 | flushesBufferToTextTokenBeforeOpening?: boolean 32 | whenOpening?: (...captures: string[]) => void 33 | insteadOfClosingOuterConventionsWhileOpen?: () => void 34 | insteadOfOpeningRegularConventionsWhileOpen?: () => void 35 | failsIfWhitespaceIsEncounteredBeforeClosing?: boolean 36 | beforeClosingItFlushesNonEmptyBufferTo?: TokenRole 37 | beforeClosingItAlwaysFlushesBufferTo?: TokenRole 38 | whenClosingItAlsoClosesInnerConventions?: boolean 39 | mustBeDirectlyFollowedBy?: ConventionDefinition[] 40 | whenClosing?: (thisOpenConvention: OpenConvention) => void 41 | insteadOfFailingWhenLeftUnclosed?: () => void 42 | 43 | constructor(args: ConventionDefinitionArgs) { 44 | // First, let's blindly copy everything from `args` to `this`. 45 | // 46 | // Alas! This also copies the string `startsWith`/`endsWith` fields! We'll 47 | // overwrite those below and convert them into anchored RegExp patterns. 48 | Object.assign(this, args) 49 | const { startsWith, endsWith } = args 50 | 51 | this.startsWith = 52 | // Some of our start delimiters can contain terms. As a rule, terms are 53 | // case-insensitive. 54 | // 55 | // Here, we determine whether we need to worry about case sensitivity. 56 | startsWith.toLowerCase() != startsWith.toUpperCase() 57 | ? patternIgnoringCapitalizationAndStartingWith(startsWith) 58 | : patternStartingWith(startsWith) 59 | 60 | this.endsWith = 61 | endsWith 62 | ? patternStartingWith(endsWith) 63 | : undefined 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseBulletedList.ts: -------------------------------------------------------------------------------- 1 | import { anyCharFrom, optional, patternStartingWith } from '../../PatternHelpers' 2 | import { INLINE_WHITESPACE_CHAR } from '../../PatternPieces' 3 | import { BulletedList } from '../../SyntaxNodes/BulletedList' 4 | import { getIndentedBlock } from './getIndentedBlock' 5 | import { getOutlineSyntaxNodes } from './getOutlineSyntaxNodes' 6 | import { LineConsumer } from './LineConsumer' 7 | import { OutlineParser } from './OutlineParser' 8 | 9 | 10 | // Bulleted lists are simply collections of bulleted list items. 11 | // 12 | // List items can contain any outline convention, even other lists! In list items with 13 | // multiple lines, all subsequent lines are indented. 14 | // 15 | // List items don't need to be separated by blank lines, but when they are, 2 or more 16 | // blank lines terminates the whole list. 17 | export function tryToParseBulletedList(args: OutlineParser.Args): OutlineParser.Result { 18 | const markupLineConsumer = new LineConsumer(args.markupLines) 19 | const listItems: BulletedList.Item[] = [] 20 | 21 | while (!markupLineConsumer.done()) { 22 | const linesOfMarkupInCurrentListItem: string[] = [] 23 | 24 | const sourceLineNumberForCurrentListItem = 25 | args.sourceLineNumber + markupLineConsumer.countLinesConsumed() 26 | 27 | const bulletedLineResult = markupLineConsumer.consumeLineIfMatches(BULLETED_LINE_PATTERN) 28 | 29 | if (!bulletedLineResult) { 30 | break 31 | } 32 | 33 | linesOfMarkupInCurrentListItem.push( 34 | bulletedLineResult.line.replace(BULLETED_LINE_PATTERN, '')) 35 | 36 | // Let's collect the rest of the lines in the current list item (if there are any) 37 | const indentedBlockResult = getIndentedBlock(markupLineConsumer.remaining()) 38 | 39 | let shouldTerminateList = false 40 | 41 | if (indentedBlockResult) { 42 | linesOfMarkupInCurrentListItem.push(...indentedBlockResult.lines) 43 | markupLineConsumer.advance(indentedBlockResult.countLinesConsumed) 44 | shouldTerminateList = indentedBlockResult.hasMultipleTrailingBlankLines 45 | } 46 | 47 | listItems.push( 48 | new BulletedList.Item( 49 | getOutlineSyntaxNodes({ 50 | markupLines: linesOfMarkupInCurrentListItem, 51 | sourceLineNumber: sourceLineNumberForCurrentListItem, 52 | headingLeveler: args.headingLeveler, 53 | settings: args.settings 54 | }))) 55 | 56 | if (shouldTerminateList) { 57 | break 58 | } 59 | } 60 | 61 | if (!listItems.length) { 62 | return null 63 | } 64 | 65 | return { 66 | parsedNodes: [new BulletedList(listItems)], 67 | countLinesConsumed: markupLineConsumer.countLinesConsumed() 68 | } 69 | } 70 | 71 | const BULLETED_LINE_PATTERN = 72 | patternStartingWith( 73 | optional(' ') + anyCharFrom('*', '-', '•') + INLINE_WHITESPACE_CHAR) 74 | -------------------------------------------------------------------------------- /src/Implementation/Rendering/Renderer.ts: -------------------------------------------------------------------------------- 1 | import * as Up from '../../Main' 2 | import { NormalizedSettings } from '../NormalizedSettings' 3 | import { WHITESPACE } from '../PatternPieces' 4 | 5 | 6 | export abstract class Renderer { 7 | constructor(protected settings: NormalizedSettings.Rendering) { } 8 | 9 | abstract document(document: Up.Document): string 10 | abstract inlineDocument(inlineDocument: Up.InlineDocument): string 11 | abstract tableOfContents(tableOfContents: Up.Document.TableOfContents): string 12 | 13 | // Ideally, the following abstract methods wouldn't be public! But for the purpose of 14 | // double dispatch, they need to be exposed to our syntax node classes. 15 | 16 | abstract audio(audio: Up.Audio): string 17 | abstract blockquote(blockquote: Up.Blockquote): string 18 | abstract bold(bold: Up.Bold): string 19 | abstract bulletedList(list: Up.BulletedList): string 20 | abstract codeBlock(codeBlock: Up.CodeBlock): string 21 | abstract descriptionList(list: Up.DescriptionList): string 22 | abstract emphasis(emphasis: Up.Emphasis): string 23 | abstract exampleUserInput(exampleUserInput: Up.ExampleUserInput): string 24 | abstract footnoteBlock(footnoteBlock: Up.FootnoteBlock): string 25 | abstract referenceToFootnote(footnote: Up.Footnote): string 26 | abstract heading(heading: Up.Heading): string 27 | abstract highlight(highlight: Up.Highlight): string 28 | abstract image(image: Up.Image): string 29 | abstract inlineCode(inlineCode: Up.InlineCode): string 30 | abstract inlineQuote(inlineQuote: Up.InlineQuote): string 31 | abstract italic(italic: Up.Italic): string 32 | abstract lineBlock(lineBlock: Up.LineBlock): string 33 | abstract link(link: Up.Link): string 34 | abstract numberedList(list: Up.NumberedList): string 35 | abstract thematicBreak(thematicBreak: Up.ThematicBreak): string 36 | abstract paragraph(paragraph: Up.Paragraph): string 37 | abstract normalParenthetical(normalParenthetical: Up.NormalParenthetical): string 38 | abstract text(text: Up.Text): string 39 | abstract inlineRevealable(inlineRevealable: Up.InlineRevealable): string 40 | abstract revealableBlock(revealableBlock: Up.RevealableBlock): string 41 | abstract sectionLink(sectionLink: Up.SectionLink): string 42 | abstract squareParenthetical(squareParenthetical: Up.SquareParenthetical): string 43 | abstract stress(stress: Up.Stress): string 44 | abstract table(table: Up.Table): string 45 | abstract video(video: Up.Video): string 46 | 47 | protected renderEach(nodes: Up.SyntaxNode[]): string[] { 48 | return nodes.map(node => node.render(this)) 49 | } 50 | 51 | protected renderAll(nodes: Up.SyntaxNode[]): string { 52 | return this.renderEach(nodes).join('') 53 | } 54 | 55 | protected idFor(...parts: Attr[]): string { 56 | const rawId = 57 | [this.settings.idPrefix, ...parts].join(' ') 58 | 59 | return rawId 60 | .trim() 61 | .replace(WHITESPACE_PATTERN, '-') 62 | } 63 | } 64 | 65 | export type Attr = number | string | undefined 66 | 67 | const WHITESPACE_PATTERN = new RegExp(WHITESPACE, 'g') 68 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/getOutlineSyntaxNodes.ts: -------------------------------------------------------------------------------- 1 | import { last } from '../../CollectionHelpers' 2 | import { OutlineSyntaxNode } from '../../SyntaxNodes/OutlineSyntaxNode' 3 | import { LineConsumer } from './LineConsumer' 4 | import { parseParagraphOrLineBlock } from './parseParagraphOrLineBlock' 5 | import { tryToParseBlankLineSeparation } from './tryToParseBlankLineSeparation' 6 | import { tryToParseBlockquote } from './tryToParseBlockquote' 7 | import { tryToParseBulletedList } from './tryToParseBulletedList' 8 | import { tryToParseCodeBlock } from './tryToParseCodeBlock' 9 | import { tryToParseDescriptionList } from './tryToParseDescriptionList' 10 | import { tryToParseHeading } from './tryToParseHeading' 11 | import { tryToParseNumberedList } from './tryToParseNumberedList' 12 | import { tryToParseRevealableBlock } from './tryToParseRevealableBlock' 13 | import { tryToParseTable } from './tryToParseTable' 14 | import { tryToParseThematicBreakStreak } from './tryToParseThematicBreakStreak' 15 | import { OutlineParser } from './OutlineParser' 16 | 17 | 18 | // This array includes every outline convention parser. 19 | // 20 | // We try these parsers in order until one of them finds a match. Then, we collect 21 | // the resulting syntax nodes, consume the matched markup, and repeat the process. 22 | const OUTLINE_CONVENTION_PARSERS = [ 23 | tryToParseBlankLineSeparation, 24 | tryToParseBulletedList, 25 | tryToParseNumberedList, 26 | tryToParseHeading, 27 | tryToParseThematicBreakStreak, 28 | tryToParseCodeBlock, 29 | tryToParseBlockquote, 30 | tryToParseTable, 31 | tryToParseRevealableBlock, 32 | tryToParseDescriptionList, 33 | parseParagraphOrLineBlock 34 | ] 35 | 36 | 37 | export function getOutlineSyntaxNodes(args: OutlineParser.Args): OutlineSyntaxNode[] { 38 | const markupLineConsumer = new LineConsumer(args.markupLines) 39 | const nodes: OutlineSyntaxNode[] = [] 40 | 41 | // This is our main parser loop! 42 | while (!markupLineConsumer.done()) { 43 | const sourceLineNumber = 44 | args.sourceLineNumber + markupLineConsumer.countLinesConsumed() 45 | 46 | // We pass these to every outline convention parser. 47 | const outlineParserArgs: OutlineParser.Args = { 48 | markupLines: markupLineConsumer.remaining(), 49 | mostRecentSibling: last(nodes), 50 | sourceLineNumber, 51 | headingLeveler: args.headingLeveler, 52 | settings: args.settings 53 | } 54 | 55 | for (const parse of OUTLINE_CONVENTION_PARSERS) { 56 | const result = parse(outlineParserArgs) 57 | 58 | if (!result) { 59 | continue 60 | } 61 | 62 | for (const newNode of result.parsedNodes) { 63 | if (args.settings.createSourceMap) { 64 | newNode.sourceLineNumber = sourceLineNumber 65 | } 66 | 67 | nodes.push(newNode) 68 | } 69 | 70 | // If we've made this far, it means our parser found a match. 71 | // 72 | // Let's advance our markup line consumer... 73 | markupLineConsumer.advance(result.countLinesConsumed) 74 | // ... and start over at the new markup position! 75 | break 76 | } 77 | } 78 | 79 | return nodes 80 | } 81 | -------------------------------------------------------------------------------- /src/Test/Parsing/Table/WithHeaderRowAndHeaderColumn/OmittedCell.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../../Main' 3 | 4 | 5 | describe('The header row of a table with a header column', () => { 6 | specify('can have more cells than one or more of its rows', () => { 7 | const markup = ` 8 | Table: 9 | 10 | Developer; Platform; Release Date 11 | 12 | Chrono Trigger; Square 13 | Terranigma; Quintet; Super Nintendo; October 20, 1995 14 | Command & Conquer 15 | StarCraft; Blizzard; PC; March 31, 1998` 16 | 17 | expect(Up.parse(markup)).to.deep.equal( 18 | new Up.Document([ 19 | new Up.Table( 20 | new Up.Table.Header([ 21 | new Up.Table.Header.Cell([]), 22 | new Up.Table.Header.Cell([new Up.Text('Developer')]), 23 | new Up.Table.Header.Cell([new Up.Text('Platform')]), 24 | new Up.Table.Header.Cell([new Up.Text('Release Date')]) 25 | ]), [ 26 | new Up.Table.Row([ 27 | new Up.Table.Row.Cell([new Up.Text('Square')]) 28 | ], new Up.Table.Header.Cell([new Up.Text('Chrono Trigger')])), 29 | new Up.Table.Row([ 30 | new Up.Table.Row.Cell([new Up.Text('Quintet')]), 31 | new Up.Table.Row.Cell([new Up.Text('Super Nintendo')]), 32 | new Up.Table.Row.Cell([new Up.Text('October 20, 1995')]) 33 | ], new Up.Table.Header.Cell([new Up.Text('Terranigma')])), 34 | new Up.Table.Row([], new Up.Table.Header.Cell([new Up.Text('Command & Conquer')])), 35 | new Up.Table.Row([ 36 | new Up.Table.Row.Cell([new Up.Text('Blizzard')]), 37 | new Up.Table.Row.Cell([new Up.Text('PC')]), 38 | new Up.Table.Row.Cell([new Up.Text('March 31, 1998')]) 39 | ], new Up.Table.Header.Cell([new Up.Text('StarCraft')])) 40 | ]) 41 | ])) 42 | }) 43 | 44 | specify('can have fewer cells than one or more of its rows', () => { 45 | const markup = ` 46 | Table: 47 | 48 | Release Date 49 | 50 | Final Fantasy; 1987; This game has some interesting bugs. 51 | Chrono Cross; 1999; Though not a proper sequel, it's my favorite game.` 52 | 53 | expect(Up.parse(markup)).to.deep.equal( 54 | new Up.Document([ 55 | new Up.Table( 56 | new Up.Table.Header([ 57 | new Up.Table.Header.Cell([]), 58 | new Up.Table.Header.Cell([new Up.Text('Release Date')]) 59 | ]), [ 60 | new Up.Table.Row([ 61 | new Up.Table.Row.Cell([new Up.Text('1987')]), 62 | new Up.Table.Row.Cell([new Up.Text('This game has some interesting bugs.')]) 63 | ], new Up.Table.Header.Cell([new Up.Text('Final Fantasy')])), 64 | new Up.Table.Row([ 65 | new Up.Table.Row.Cell([new Up.Text('1999')]), 66 | new Up.Table.Row.Cell([new Up.Text("Though not a proper sequel, it's my favorite game.")]) 67 | ], new Up.Table.Header.Cell([new Up.Text('Chrono Cross')])) 68 | ]) 69 | ])) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Inline/RichConventions.ts: -------------------------------------------------------------------------------- 1 | import { Bold } from '../../SyntaxNodes/Bold' 2 | import { Emphasis } from '../../SyntaxNodes/Emphasis' 3 | import { Footnote } from '../../SyntaxNodes/Footnote' 4 | import { Highlight } from '../../SyntaxNodes/Highlight' 5 | import { InlineQuote } from '../../SyntaxNodes/InlineQuote' 6 | import { InlineRevealable } from '../../SyntaxNodes/InlineRevealable' 7 | import { Italic } from '../../SyntaxNodes/Italic' 8 | import { Link } from '../../SyntaxNodes/Link' 9 | import { NormalParenthetical } from '../../SyntaxNodes/NormalParenthetical' 10 | import { SquareParenthetical } from '../../SyntaxNodes/SquareParenthetical' 11 | import { Stress } from '../../SyntaxNodes/Stress' 12 | import { RichConventionWithoutExtraFields } from './RichConventionWithoutExtraFields' 13 | import { TokenRole } from './TokenRole' 14 | 15 | 16 | export const EMPHASIS: RichConventionWithoutExtraFields = { 17 | SyntaxNodeType: Emphasis, 18 | startTokenRole: TokenRole.EmphasisStart, 19 | endTokenRole: TokenRole.EmphasisEnd 20 | } 21 | 22 | export const STRESS: RichConventionWithoutExtraFields = { 23 | SyntaxNodeType: Stress, 24 | startTokenRole: TokenRole.StressStart, 25 | endTokenRole: TokenRole.StressEnd 26 | } 27 | 28 | export const ITALIC: RichConventionWithoutExtraFields = { 29 | SyntaxNodeType: Italic, 30 | startTokenRole: TokenRole.ItalicStart, 31 | endTokenRole: TokenRole.ItalicEnd 32 | } 33 | 34 | export const BOLD: RichConventionWithoutExtraFields = { 35 | SyntaxNodeType: Bold, 36 | startTokenRole: TokenRole.BoldStart, 37 | endTokenRole: TokenRole.BoldEnd 38 | } 39 | 40 | export const HIGHLIGHT: RichConventionWithoutExtraFields = { 41 | SyntaxNodeType: Highlight, 42 | startTokenRole: TokenRole.HighlightStart, 43 | endTokenRole: TokenRole.HighlightEnd 44 | } 45 | 46 | export const INLINE_QUOTE: RichConventionWithoutExtraFields = { 47 | SyntaxNodeType: InlineQuote, 48 | startTokenRole: TokenRole.InlineQuoteStart, 49 | endTokenRole: TokenRole.InlineQuoteEnd 50 | } 51 | 52 | export const FOOTNOTE: RichConventionWithoutExtraFields = { 53 | SyntaxNodeType: Footnote, 54 | startTokenRole: TokenRole.FootnoteStart, 55 | endTokenRole: TokenRole.FootnoteEnd 56 | } 57 | 58 | export const NORMAL_PARENTHETICAL: RichConventionWithoutExtraFields = { 59 | SyntaxNodeType: NormalParenthetical, 60 | startTokenRole: TokenRole.NormalParentheticalStart, 61 | endTokenRole: TokenRole.NormalParentheticalEnd 62 | } 63 | 64 | export const SQUARE_PARENTHETICAL: RichConventionWithoutExtraFields = { 65 | SyntaxNodeType: SquareParenthetical, 66 | startTokenRole: TokenRole.SquareParentheticalStart, 67 | endTokenRole: TokenRole.SquareParentheticalEnd 68 | } 69 | 70 | export const INLINE_REVEALABLE: RichConventionWithoutExtraFields = { 71 | SyntaxNodeType: InlineRevealable, 72 | startTokenRole: TokenRole.InlineRevealableStart, 73 | endTokenRole: TokenRole.InlineRevealableEnd 74 | } 75 | 76 | // The link convention has an extra field: its URL. Therefore, it doesn't satisfy the 77 | // `RichConventionWithoutExtraFields` interface. 78 | export const LINK = { 79 | SyntaxNodeType: Link, 80 | startTokenRole: TokenRole.LinkStart, 81 | endTokenRole: TokenRole.LinkEndAndUrl 82 | } 83 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/ImbalancedAsteriskInflection.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Text surrounded by 2 asterisks to its left and 1 asterisk to its right', () => { 7 | it('is emphasized, and the extra asterisk on the left does not appear in the final document as plain text', () => { 8 | expect(Up.parse('Xamarin is now **free*!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Xamarin is now '), 11 | new Up.Emphasis([ 12 | new Up.Text('free') 13 | ]), 14 | new Up.Text('!') 15 | ])) 16 | }) 17 | }) 18 | 19 | 20 | describe('Text surrounded by 1 asterisk to its left and 2 asterisks to its right', () => { 21 | it('is emphasized, and the extra asterisk on the right does not appear in the final document as plain text', () => { 22 | expect(Up.parse('Xamarin is now *free**!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Xamarin is now '), 25 | new Up.Emphasis([ 26 | new Up.Text('free') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | }) 32 | 33 | 34 | describe('Text surrounded by 3 asterisks to its left and 1 asterisk to its right', () => { 35 | it('is emphasized, and the extra 2 asterisks on the left do not appear in the final document as plain text', () => { 36 | expect(Up.parse('Xamarin is now ***free*!')).to.deep.equal( 37 | insideDocumentAndParagraph([ 38 | new Up.Text('Xamarin is now '), 39 | new Up.Emphasis([ 40 | new Up.Text('free') 41 | ]), 42 | new Up.Text('!') 43 | ])) 44 | }) 45 | }) 46 | 47 | 48 | describe('Text surrounded by 3 asterisks to its left and 2 asterisks to its right', () => { 49 | it('is stressed, and the extra asterisk on the left does not appear in the final document as plain text', () => { 50 | expect(Up.parse('Xamarin is now ***free**!')).to.deep.equal( 51 | insideDocumentAndParagraph([ 52 | new Up.Text('Xamarin is now '), 53 | new Up.Stress([ 54 | new Up.Text('free') 55 | ]), 56 | new Up.Text('!') 57 | ])) 58 | }) 59 | }) 60 | 61 | 62 | describe('Text surrounded by 1 asterisk to its left and 3 asterisks to its right', () => { 63 | it('is emphasized, and the 2 extra asterisks on the right do not appear in the final document as plain text', () => { 64 | expect(Up.parse('Xamarin is now *free***!')).to.deep.equal( 65 | insideDocumentAndParagraph([ 66 | new Up.Text('Xamarin is now '), 67 | new Up.Emphasis([ 68 | new Up.Text('free') 69 | ]), 70 | new Up.Text('!') 71 | ])) 72 | }) 73 | }) 74 | 75 | 76 | describe('Text surrounded by 2 asterisk to its left and 3 asterisks to its right', () => { 77 | it('is stressed, and the extra asterisk on the right does not appear in the final document as plain text', () => { 78 | expect(Up.parse('Xamarin is now **free***!')).to.deep.equal( 79 | insideDocumentAndParagraph([ 80 | new Up.Text('Xamarin is now '), 81 | new Up.Stress([ 82 | new Up.Text('free') 83 | ]), 84 | new Up.Text('!') 85 | ])) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /src/Test/Parsing/Table/WithHeaderRow/OmittedCell.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../../Main' 3 | 4 | 5 | describe('A table header', () => { 6 | specify('can have more cells than one or more of its rows', () => { 7 | const markup = ` 8 | Table: 9 | 10 | Game; Developer; Platform; Release Date 11 | 12 | Chrono Trigger; Square 13 | Terranigma; Quintet; Super Nintendo; October 20, 1995 14 | Command & Conquer 15 | StarCraft; Blizzard; PC; March 31, 1998` 16 | 17 | expect(Up.parse(markup)).to.deep.equal( 18 | new Up.Document([ 19 | new Up.Table( 20 | new Up.Table.Header([ 21 | new Up.Table.Header.Cell([new Up.Text('Game')]), 22 | new Up.Table.Header.Cell([new Up.Text('Developer')]), 23 | new Up.Table.Header.Cell([new Up.Text('Platform')]), 24 | new Up.Table.Header.Cell([new Up.Text('Release Date')]) 25 | ]), [ 26 | new Up.Table.Row([ 27 | new Up.Table.Row.Cell([new Up.Text('Chrono Trigger')]), 28 | new Up.Table.Row.Cell([new Up.Text('Square')]) 29 | ]), 30 | new Up.Table.Row([ 31 | new Up.Table.Row.Cell([new Up.Text('Terranigma')]), 32 | new Up.Table.Row.Cell([new Up.Text('Quintet')]), 33 | new Up.Table.Row.Cell([new Up.Text('Super Nintendo')]), 34 | new Up.Table.Row.Cell([new Up.Text('October 20, 1995')]) 35 | ]), 36 | new Up.Table.Row([ 37 | new Up.Table.Row.Cell([new Up.Text('Command & Conquer')]) 38 | ]), 39 | new Up.Table.Row([ 40 | new Up.Table.Row.Cell([new Up.Text('StarCraft')]), 41 | new Up.Table.Row.Cell([new Up.Text('Blizzard')]), 42 | new Up.Table.Row.Cell([new Up.Text('PC')]), 43 | new Up.Table.Row.Cell([new Up.Text('March 31, 1998')]) 44 | ]) 45 | ]) 46 | ])) 47 | }) 48 | 49 | specify('can have fewer cells than one or more of its rows', () => { 50 | const markup = ` 51 | Table: 52 | 53 | Game; Release Date 54 | 55 | Final Fantasy; 1987; This game has some interesting bugs. 56 | Chrono Cross; 1999; Though not a proper sequel, it's my favorite game.` 57 | 58 | expect(Up.parse(markup)).to.deep.equal( 59 | new Up.Document([ 60 | new Up.Table( 61 | new Up.Table.Header([ 62 | new Up.Table.Header.Cell([new Up.Text('Game')]), 63 | new Up.Table.Header.Cell([new Up.Text('Release Date')]) 64 | ]), [ 65 | new Up.Table.Row([ 66 | new Up.Table.Row.Cell([new Up.Text('Final Fantasy')]), 67 | new Up.Table.Row.Cell([new Up.Text('1987')]), 68 | new Up.Table.Row.Cell([new Up.Text('This game has some interesting bugs.')]) 69 | ]), 70 | new Up.Table.Row([ 71 | new Up.Table.Row.Cell([new Up.Text('Chrono Cross')]), 72 | new Up.Table.Row.Cell([new Up.Text('1999')]), 73 | new Up.Table.Row.Cell([new Up.Text("Though not a proper sequel, it's my favorite game.")]) 74 | ]) 75 | ]) 76 | ])) 77 | }) 78 | }) 79 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/ImbalancedUnderscoreInflection.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Text surrounded by 2 underscores to its left and 1 underscore to its right', () => { 7 | it('is italicized, and the extra underscore on the left does not appear in the final document as plain text', () => { 8 | expect(Up.parse('Xamarin is now __free_!')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('Xamarin is now '), 11 | new Up.Italic([ 12 | new Up.Text('free') 13 | ]), 14 | new Up.Text('!') 15 | ])) 16 | }) 17 | }) 18 | 19 | 20 | describe('Text surrounded by 1 underscore to its left and 2 underscores to its right', () => { 21 | it('is italicized, and the extra underscore on the right does not appear in the final document as plain text', () => { 22 | expect(Up.parse('Xamarin is now _free__!')).to.deep.equal( 23 | insideDocumentAndParagraph([ 24 | new Up.Text('Xamarin is now '), 25 | new Up.Italic([ 26 | new Up.Text('free') 27 | ]), 28 | new Up.Text('!') 29 | ])) 30 | }) 31 | }) 32 | 33 | 34 | describe('Text surrounded by 3 underscores to its left and 1 underscore to its right', () => { 35 | it('is italicized, and the extra 2 underscores on the left do not appear in the final document as plain text', () => { 36 | expect(Up.parse('Xamarin is now ___free_!')).to.deep.equal( 37 | insideDocumentAndParagraph([ 38 | new Up.Text('Xamarin is now '), 39 | new Up.Italic([ 40 | new Up.Text('free') 41 | ]), 42 | new Up.Text('!') 43 | ])) 44 | }) 45 | }) 46 | 47 | 48 | describe('Text surrounded by 3 underscores to its left and 2 underscores to its right', () => { 49 | it('is made bold, and the extra underscore on the left does not appear in the final document as plain text', () => { 50 | expect(Up.parse('Xamarin is now ___free__!')).to.deep.equal( 51 | insideDocumentAndParagraph([ 52 | new Up.Text('Xamarin is now '), 53 | new Up.Bold([ 54 | new Up.Text('free') 55 | ]), 56 | new Up.Text('!') 57 | ])) 58 | }) 59 | }) 60 | 61 | 62 | describe('Text surrounded by 1 underscore to its left and 3 underscores to its right', () => { 63 | it('is italicized, and the 2 extra underscores on the right do not appear in the final document as plain text', () => { 64 | expect(Up.parse('Xamarin is now _free___!')).to.deep.equal( 65 | insideDocumentAndParagraph([ 66 | new Up.Text('Xamarin is now '), 67 | new Up.Italic([ 68 | new Up.Text('free') 69 | ]), 70 | new Up.Text('!') 71 | ])) 72 | }) 73 | }) 74 | 75 | 76 | describe('Text surrounded by 2 underscore to its left and 3 underscores to its right', () => { 77 | it('is made bold, and the extra underscore on the right does not appear in the final document as plain text', () => { 78 | expect(Up.parse('Xamarin is now __free___!')).to.deep.equal( 79 | insideDocumentAndParagraph([ 80 | new Up.Text('Xamarin is now '), 81 | new Up.Bold([ 82 | new Up.Text('free') 83 | ]), 84 | new Up.Text('!') 85 | ])) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Table.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { anyCharMatching } from '../PatternHelpers' 3 | import { DIGIT, LETTER_CLASS, WHITESPACE_CHAR } from '../PatternPieces' 4 | import { Renderer } from '../Rendering/Renderer' 5 | import { Heading } from './Heading' 6 | import { getInlineDescendants } from './getInlineDescendants' 7 | import { InlineSyntaxNode } from './InlineSyntaxNode' 8 | import { InlineSyntaxNodeContainer } from './InlineSyntaxNodeContainer' 9 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 10 | 11 | 12 | export class Table implements OutlineSyntaxNode { 13 | sourceLineNumber?: number 14 | 15 | constructor( 16 | public header: Table.Header, 17 | public rows: Table.Row[], 18 | public caption?: Table.Caption, 19 | options?: { sourceLineNumber: number } 20 | ) { 21 | this.sourceLineNumber = options?.sourceLineNumber 22 | } 23 | 24 | descendantsToIncludeInTableOfContents(): Heading[] { 25 | return [] 26 | } 27 | 28 | inlineDescendants(): InlineSyntaxNode[] { 29 | const captionAndCells = concat([ 30 | this.caption ? [this.caption] : [] as InlineSyntaxNodeContainer[], 31 | this.header.cells, 32 | ...this.rows.map(row => row.allCellsStartingWithHeaderColumnCell()) 33 | ]) 34 | 35 | return concat( 36 | captionAndCells.map(captionOrCell => getInlineDescendants(captionOrCell.children))) 37 | } 38 | 39 | render(renderer: Renderer): string { 40 | return renderer.table(this) 41 | } 42 | } 43 | 44 | 45 | export namespace Table { 46 | export class Caption extends InlineSyntaxNodeContainer { 47 | protected readonly TABLE_CAPTION = undefined 48 | } 49 | 50 | 51 | export abstract class Cell extends InlineSyntaxNodeContainer { 52 | constructor(children: InlineSyntaxNode[], public countColumnsSpanned = 1) { 53 | super(children) 54 | } 55 | 56 | isNumeric(): boolean { 57 | const textContent = this.children 58 | .map(child => child.textAppearingInline()) 59 | .join('') 60 | 61 | return CONTAINS_DIGIT.test(textContent) && !CONTAINS_NON_NUMERIC_CHARACTER.test(textContent) 62 | } 63 | } 64 | 65 | const CONTAINS_DIGIT = new RegExp(DIGIT) 66 | 67 | // TODO: Doesn't work for non-English 68 | // Todo: Use u flag 69 | const CONTAINS_NON_NUMERIC_CHARACTER = new RegExp( 70 | anyCharMatching(LETTER_CLASS, '_', WHITESPACE_CHAR)) 71 | 72 | 73 | export class Header { 74 | constructor(public cells: Header.Cell[]) { } 75 | } 76 | 77 | export namespace Header { 78 | export class Cell extends Table.Cell { 79 | protected readonly TABLE_HEADER_CELL = undefined 80 | } 81 | } 82 | 83 | 84 | export class Row { 85 | constructor(public cells: Row.Cell[], public headerColumnCell?: Header.Cell) { } 86 | 87 | allCellsStartingWithHeaderColumnCell(): Table.Cell[] { 88 | const allCells: Table.Cell[] = this.cells.slice() 89 | 90 | if (this.headerColumnCell) { 91 | allCells.unshift(this.headerColumnCell) 92 | } 93 | 94 | return allCells 95 | } 96 | } 97 | 98 | export namespace Row { 99 | export class Cell extends Table.Cell { 100 | protected readonly TABLE_ROW_CELL = undefined 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Test/Parsing/Settings/Image.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | describe('The keyword that represents image conventions', () => { 6 | const up = new Up.Up({ 7 | parsing: { 8 | keywords: { image: 'see' } 9 | } 10 | }) 11 | 12 | it('comes from the "image" keyword', () => { 13 | const markup = '[see: Chrono Cross logo][https://example.com/cc.png]' 14 | 15 | expect(up.parse(markup)).to.deep.equal( 16 | new Up.Document([ 17 | new Up.Image('Chrono Cross logo', 'https://example.com/cc.png') 18 | ])) 19 | }) 20 | 21 | it('is case-insensitive', () => { 22 | const lowercase = '[see: Chrono Cross logo][https://example.com/cc.png]' 23 | const mixedCase = '[SeE: Chrono Cross logo][https://example.com/cc.png]' 24 | 25 | expect(up.parse(mixedCase)).to.deep.equal(up.parse(lowercase)) 26 | }) 27 | 28 | it('is trimmed', () => { 29 | const markup = '[see: Chrono Cross logo][https://example.com/cc.png]' 30 | 31 | const document = Up.parse(markup, { 32 | keywords: { 33 | image: ' \t see \t' 34 | } 35 | }) 36 | 37 | expect(document).to.deep.equal( 38 | new Up.Document([ 39 | new Up.Image('Chrono Cross logo', 'https://example.com/cc.png') 40 | ])) 41 | }) 42 | 43 | it('ignores inline conventions and regular expression rules', () => { 44 | const markup = '[*see*: Chrono Cross logo][https://example.com/cc.png]' 45 | 46 | const document = Up.parse(markup, { 47 | keywords: { 48 | image: '*see*' 49 | } 50 | }) 51 | 52 | expect(document).to.deep.equal( 53 | new Up.Document([ 54 | new Up.Image('Chrono Cross logo', 'https://example.com/cc.png') 55 | ])) 56 | }) 57 | 58 | it('can have multiple variations', () => { 59 | const markup = '[look: Chrono Cross logo](https://example.com/cc.png) [view: Chrono Cross logo](https://example.com/cc.png)' 60 | 61 | const document = Up.parse(markup, { 62 | keywords: { 63 | image: ['view', 'look'] 64 | } 65 | }) 66 | 67 | expect(document).to.deep.equal( 68 | new Up.Document([ 69 | new Up.Image('Chrono Cross logo', 'https://example.com/cc.png'), 70 | new Up.Image('Chrono Cross logo', 'https://example.com/cc.png') 71 | ])) 72 | }) 73 | }) 74 | 75 | 76 | context('Image descriptions are evaluated for typographical conventions:', () => { 77 | specify('En dashes', () => { 78 | expect(Up.parse('[image: ghosts--eating luggage] (http://example.com/poltergeists.svg)')).to.deep.equal( 79 | new Up.Document([ 80 | new Up.Image('ghosts–eating luggage', 'http://example.com/poltergeists.svg') 81 | ])) 82 | }) 83 | 84 | specify('Em dashes', () => { 85 | expect(Up.parse('[image: ghosts---eating luggage] (http://example.com/poltergeists.svg)')).to.deep.equal( 86 | new Up.Document([ 87 | new Up.Image('ghosts—eating luggage', 'http://example.com/poltergeists.svg') 88 | ])) 89 | }) 90 | 91 | specify('Plus-minus signs', () => { 92 | expect(Up.parse('[image: ghosts eating luggage 10 pieces of luggage +-9] (http://example.com/poltergeists.svg)')).to.deep.equal( 93 | new Up.Document([ 94 | new Up.Image('ghosts eating luggage 10 pieces of luggage ±9', 'http://example.com/poltergeists.svg') 95 | ])) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/SectionLink.ts: -------------------------------------------------------------------------------- 1 | import { Renderer } from '../Rendering/Renderer' 2 | import { containsStringIgnoringCapitalization, isEqualIgnoringCapitalization } from '../StringHelpers' 3 | import { Document } from './Document' 4 | import { Heading } from './Heading' 5 | import { getInlineDescendants } from './getInlineDescendants' 6 | import { getTextAppearingInline } from './getTextAppearingInline' 7 | import { InlineSyntaxNode } from './InlineSyntaxNode' 8 | 9 | 10 | // A section link is essentially a reference to a table of contents entry. 11 | export class SectionLink implements InlineSyntaxNode { 12 | constructor( 13 | public markupSnippetFromSectionTitle: string, 14 | public entry?: Heading) { } 15 | 16 | referenceMostAppropriateTableOfContentsEntry(tableOfContents: Document.TableOfContents): void { 17 | // We'll use try to match our `markupSnippetFromSectionTitle` with the `titleMarkup` of the 18 | // most appropriate table of contents entry. 19 | // 20 | // Here's our strategy: 21 | // 22 | // First, we'll try to associate this section link with the first entry whose `titleMarkup` 23 | // exactly equals `markupSnippetFromSectionTitle`. We don't care about capitalization, but the 24 | // two otherwise have to be an exact match. 25 | // 26 | // If there are no exact matches, then we'll try to associate this section link with the first 27 | // entry whose `titleMarkup` contains `markupSnippetFromSectionTitle`. 28 | // 29 | // If we still don't have a match after that, then we're out of luck. We give up. 30 | // 31 | // TODO: Continue searching using another algorithm (e.g. string distance). 32 | // 33 | // TODO: When multiple entries match `markupSnippetFromSectionTitle`, consider preferring the 34 | // entry representing the heading closest to this section link. 35 | 36 | // As a rule, section links with empty snippets are never matched to a table of contents entry. 37 | if (!this.markupSnippetFromSectionTitle) { 38 | return 39 | } 40 | 41 | for (const entry of tableOfContents.entries) { 42 | if (!this.canMatch(entry)) { 43 | continue 44 | } 45 | 46 | const { titleMarkup } = entry 47 | const { markupSnippetFromSectionTitle } = this 48 | 49 | if (isEqualIgnoringCapitalization(titleMarkup, markupSnippetFromSectionTitle)) { 50 | // We found a perfect match! We're done. 51 | this.entry = entry 52 | return 53 | } 54 | 55 | if (!this.entry) { 56 | if (containsStringIgnoringCapitalization({ haystack: titleMarkup, needle: markupSnippetFromSectionTitle })) { 57 | // We've found a non-perfect match. We'll keep searching in case there's a perfect match 58 | // further in the table of contents. 59 | this.entry = entry 60 | } 61 | } 62 | } 63 | } 64 | 65 | textAppearingInline(): string { 66 | return this.entry 67 | ? getTextAppearingInline(this.entry.children) 68 | : this.markupSnippetFromSectionTitle 69 | } 70 | 71 | inlineDescendants(): InlineSyntaxNode[] { 72 | return [] 73 | } 74 | 75 | render(renderer: Renderer): string { 76 | return renderer.sectionLink(this) 77 | } 78 | 79 | private canMatch(entry: Heading): boolean { 80 | // We won't match a table of contents entry if it contains this section link. 81 | return getInlineDescendants(entry.children).indexOf(this) === -1 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/Implementation/PatternHelpers.ts: -------------------------------------------------------------------------------- 1 | export function group(pattern: string): string { 2 | return `(?:${pattern})` 3 | } 4 | 5 | export function capture(pattern: string): string { 6 | return `(${pattern})` 7 | } 8 | 9 | export function optional(pattern: string): string { 10 | return group(pattern) + '?' 11 | } 12 | 13 | export function everyOptional(pattern: string): string { 14 | return group(pattern) + '*' 15 | } 16 | 17 | export function oneOrMore(pattern: string): string { 18 | return atLeast(1, pattern) 19 | } 20 | 21 | export function multiple(pattern: string): string { 22 | return atLeast(2, pattern) 23 | } 24 | 25 | export function atLeast(count: number, pattern: string): string { 26 | return group(pattern) + `{${count},}` 27 | } 28 | 29 | export function exactly(count: number, pattern: string): string { 30 | return group(pattern) + `{${count}}` 31 | } 32 | 33 | export function either(...patterns: string[]): string { 34 | return group(patterns.join('|')) 35 | } 36 | 37 | // Matches any character that matches any of the `charClasses`. 38 | export function anyCharMatching(...charClasses: string[]): string { 39 | return `[${charClasses.join('')}]` 40 | } 41 | 42 | // Matches any character that does not match any of the `charClasses`. 43 | export function anyCharNotMatching(...charClasses: string[]): string { 44 | return `[^${charClasses.join('')}]` 45 | } 46 | 47 | // Matches any character from the set of `chars`. Does not support patterns. 48 | export function anyCharFrom(...chars: string[]): string { 49 | return anyCharMatching(...chars.map(escapeForRegex)) 50 | } 51 | 52 | export function followedBy(pattern: string): string { 53 | return `(?=${pattern})` 54 | } 55 | 56 | export function notFollowedBy(pattern: string): string { 57 | return `(?!${pattern})` 58 | } 59 | 60 | export function escapeForRegex(text: string): string { 61 | return text.replace(/[(){}[\].+*?^$\\|-]/g, '\\$&') 62 | } 63 | 64 | export function streakOf(charPattern: string): RegExp { 65 | return solely(atLeast(3, charPattern)) 66 | } 67 | 68 | export function solely(pattern: string): RegExp { 69 | return getRegExpSolelyConsistingOf({ pattern }) 70 | } 71 | 72 | export function solelyAndIgnoringCapitalization(pattern: string): RegExp { 73 | return getRegExpSolelyConsistingOf({ pattern, isCaseInsensitive: true }) 74 | } 75 | 76 | export function patternStartingWith(pattern: string): RegExp { 77 | return getRegExpStartingWith({ pattern }) 78 | } 79 | 80 | export function patternIgnoringCapitalizationAndStartingWith(pattern: string): RegExp { 81 | return getRegExpStartingWith({ pattern, isCaseInsensitive: true }) 82 | } 83 | 84 | export function patternEndingWith(pattern: string): RegExp { 85 | return new RegExp(pattern + '$') 86 | } 87 | 88 | 89 | import { ANY_OPTIONAL_WHITESPACE } from './PatternPieces' 90 | 91 | function getRegExpSolelyConsistingOf(args: { pattern: string, isCaseInsensitive?: boolean }): RegExp { 92 | return new RegExp( 93 | '^' + ANY_OPTIONAL_WHITESPACE + args.pattern + ANY_OPTIONAL_WHITESPACE + '$', 94 | getRegExpFlags(args.isCaseInsensitive === true)) 95 | } 96 | 97 | function getRegExpStartingWith(args: { pattern: string, isCaseInsensitive?: boolean }): RegExp { 98 | return new RegExp( 99 | '^' + args.pattern, 100 | getRegExpFlags(args.isCaseInsensitive === true)) 101 | } 102 | 103 | function getRegExpFlags(isCaseInsensitive: boolean): string | undefined { 104 | return isCaseInsensitive ? 'i' : undefined 105 | } 106 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Audio.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('A paragraph directly followed by audio on its own line', () => { 7 | it('produces a paragraph node followed by an audio node, not a line block', () => { 8 | const markup = ` 9 | Do not pour the spiders into your sister's cereal. 10 | [audio: six seconds of screaming][http://example.com/screaming.ogg]` 11 | expect(Up.parse(markup)).to.deep.equal( 12 | new Up.Document([ 13 | new Up.Paragraph([ 14 | new Up.Text("Do not pour the spiders into your sister's cereal.") 15 | ]), 16 | new Up.Audio('six seconds of screaming', 'http://example.com/screaming.ogg') 17 | ])) 18 | }) 19 | }) 20 | 21 | 22 | describe('An otherwise-valid audio convention with mismatched brackets surrounding its description', () => { 23 | it('does not produce an audio node', () => { 24 | expect(Up.parse('I like [audio: ghosts}(http://example.com/ghosts.ogg).')).to.deep.equal( 25 | insideDocumentAndParagraph([ 26 | new Up.Text('I like [audio: ghosts}'), 27 | new Up.NormalParenthetical([ 28 | new Up.Text('('), 29 | new Up.Link([ 30 | new Up.Text('example.com/ghosts.ogg') 31 | ], 'http://example.com/ghosts.ogg'), 32 | new Up.Text(')') 33 | ]), 34 | new Up.Text('.') 35 | ])) 36 | }) 37 | }) 38 | 39 | 40 | describe('An otherwise-valid audio convention with mismatched brackets surrounding its URL', () => { 41 | it('does not produce an audio node', () => { 42 | expect(Up.parse('I like [audio: ghosts][http://example.com/ghosts.ogg).')).to.deep.equal( 43 | insideDocumentAndParagraph([ 44 | new Up.Text('I like '), 45 | new Up.SquareParenthetical([ 46 | new Up.Text('[audio: ghosts]') 47 | ]), 48 | new Up.Text('['), 49 | new Up.Link([ 50 | new Up.Text('example.com/ghosts.ogg).') 51 | ], 'http://example.com/ghosts.ogg).') 52 | ])) 53 | }) 54 | }) 55 | 56 | 57 | context('Unmatched opening parentheses in an audio description have no affect on', () => { 58 | specify('parentheses surrounding the URL', () => { 59 | expect(Up.parse('[audio: sad :( sad :( sounds](http://example.com/sad.ogg)')).to.deep.equal( 60 | new Up.Document([ 61 | new Up.Audio('sad :( sad :( sounds', 'http://example.com/sad.ogg') 62 | ])) 63 | }) 64 | 65 | specify('parentheses that follow the convention', () => { 66 | expect(Up.parse('([audio: sad :( sad :( sounds][http://example.com/sad.ogg])')).to.deep.equal( 67 | new Up.Document([ 68 | new Up.Paragraph([ 69 | new Up.NormalParenthetical([ 70 | new Up.Text('('), 71 | new Up.Audio('sad :( sad :( sounds', 'http://example.com/sad.ogg'), 72 | new Up.Text(')') 73 | ]) 74 | ]) 75 | ])) 76 | }) 77 | }) 78 | 79 | 80 | describe('Unmatched opening parentheses in an audio URL', () => { 81 | it('do not affect any markup that follows the link', () => { 82 | const markup = '(^[audio: West Virginia exit polling][https://example.com/a(normal(url])' 83 | 84 | const footnote = new Up.Footnote([ 85 | new Up.Audio('West Virginia exit polling', 'https://example.com/a(normal(url') 86 | ], { referenceNumber: 1 }) 87 | 88 | expect(Up.parse(markup)).to.deep.equal( 89 | new Up.Document([ 90 | new Up.Paragraph([ 91 | footnote 92 | ]), 93 | new Up.FootnoteBlock([ 94 | footnote 95 | ]) 96 | ])) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/Test/Parsing/EdgeCasesAndPotentialBugs/Video.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('A paragraph directly followed by a video on its own line', () => { 7 | it('produces a paragraph node followed by a video node, not a line block', () => { 8 | const markup = ` 9 | Do not pour the spiders into your sister's cereal. 10 | [video: spiders crawling out of mouth][http://example.com/spiders.webm]` 11 | expect(Up.parse(markup)).to.deep.equal( 12 | new Up.Document([ 13 | new Up.Paragraph([ 14 | new Up.Text("Do not pour the spiders into your sister's cereal.") 15 | ]), 16 | new Up.Video('spiders crawling out of mouth', 'http://example.com/spiders.webm') 17 | ])) 18 | }) 19 | }) 20 | 21 | 22 | describe('An otherwise-valid video convention with mismatched brackets surrounding its description', () => { 23 | it('does not produce a video node', () => { 24 | expect(Up.parse('I like [video: ghosts}(http://example.com/ghosts.webm).')).to.deep.equal( 25 | insideDocumentAndParagraph([ 26 | new Up.Text('I like [video: ghosts}'), 27 | new Up.NormalParenthetical([ 28 | new Up.Text('('), 29 | new Up.Link([ 30 | new Up.Text('example.com/ghosts.webm') 31 | ], 'http://example.com/ghosts.webm'), 32 | new Up.Text(')') 33 | ]), 34 | new Up.Text('.') 35 | ])) 36 | }) 37 | }) 38 | 39 | 40 | describe('An otherwise-valid video convention with mismatched brackets surrounding its URL', () => { 41 | it('does not produce a video node', () => { 42 | expect(Up.parse('I like [video: ghosts][http://example.com/ghosts.webm).')).to.deep.equal( 43 | insideDocumentAndParagraph([ 44 | new Up.Text('I like '), 45 | new Up.SquareParenthetical([ 46 | new Up.Text('[video: ghosts]') 47 | ]), 48 | new Up.Text('['), 49 | new Up.Link([ 50 | new Up.Text('example.com/ghosts.webm).') 51 | ], 'http://example.com/ghosts.webm).') 52 | ])) 53 | }) 54 | }) 55 | 56 | 57 | context('Unmatched opening parentheses in a video description have no affect on', () => { 58 | specify('parentheses surrounding the URL', () => { 59 | expect(Up.parse('[video: sad :( sad :( sounds](http://example.com/sad.ogg)')).to.deep.equal( 60 | new Up.Document([ 61 | new Up.Video('sad :( sad :( sounds', 'http://example.com/sad.ogg') 62 | ])) 63 | }) 64 | 65 | specify('parentheses that follow the convention', () => { 66 | expect(Up.parse('([video: sad :( sad :( sounds][http://example.com/sad.ogg])')).to.deep.equal( 67 | new Up.Document([ 68 | new Up.Paragraph([ 69 | new Up.NormalParenthetical([ 70 | new Up.Text('('), 71 | new Up.Video('sad :( sad :( sounds', 'http://example.com/sad.ogg'), 72 | new Up.Text(')') 73 | ]) 74 | ]) 75 | ])) 76 | }) 77 | }) 78 | 79 | 80 | describe('Unmatched opening parentheses in a video URL', () => { 81 | it('do not affect any markup that follows the link', () => { 82 | const markup = '(^[video: West Virginia exit polling][https://example.com/a(normal(url])' 83 | 84 | const footnote = new Up.Footnote([ 85 | new Up.Video('West Virginia exit polling', 'https://example.com/a(normal(url') 86 | ], { referenceNumber: 1 }) 87 | 88 | expect(Up.parse(markup)).to.deep.equal( 89 | new Up.Document([ 90 | new Up.Paragraph([ 91 | footnote 92 | ]), 93 | new Up.FootnoteBlock([ 94 | footnote 95 | ]) 96 | ])) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /src/Implementation/SyntaxNodes/Document.ts: -------------------------------------------------------------------------------- 1 | import { concat } from '../CollectionHelpers' 2 | import { Heading } from './Heading' 3 | import { insertFootnoteBlocksAndAssignFootnoteReferenceNumbers } from './insertFootnoteBlocksAndAssignFootnoteReferenceNumbers' 4 | import { OutlineSyntaxNode } from './OutlineSyntaxNode' 5 | import { OutlineSyntaxNodeContainer } from './OutlineSyntaxNodeContainer' 6 | import { SectionLink } from './SectionLink' 7 | 8 | 9 | export class Document extends OutlineSyntaxNodeContainer { 10 | // Returns a `Document` object with: 11 | // 12 | // 1. Footnotes extracted into footnote blocks 13 | // 2. A table of contents produced from `children` 14 | // 3. Section links associated with the appropriate table of contents entries 15 | // 16 | // Responsibilities 1 and 3 mutate the `children` argument (and its descendants). 17 | static create(children: OutlineSyntaxNode[]): Document { 18 | // Unfortunately, this process of producing a ready-to-use Document has become a tad scattered. 19 | // 20 | // It needs to be revisited. 21 | 22 | // First, let's create our table of contents. It's up to each outline syntax node whether to allow 23 | // its descendants to be referenced by the table of contents. Some don't (e.g. blockquotes). 24 | const tableOfContents = 25 | Document.TableOfContents.createThenAssociateSectionLinksWithEntries(children) 26 | 27 | // Alright! We now have everything we need to produce our document! 28 | const document = new Document(children, tableOfContents) 29 | 30 | // Finally, if there are any footnotes, they still need their reference numbers, and they still 31 | // need to be extracted into blocks. Let's do it. 32 | insertFootnoteBlocksAndAssignFootnoteReferenceNumbers(document) 33 | 34 | // Whew. We're done! 35 | return document 36 | } 37 | 38 | constructor(children: OutlineSyntaxNode[], public tableOfContents = new Document.TableOfContents()) { 39 | super(children) 40 | } 41 | } 42 | 43 | 44 | export namespace Document { 45 | export class TableOfContents { 46 | // Returns a `TableOfContents` object with entries from `documentChildren`. 47 | // 48 | // If there are section links within `documentChildren`, they are associated with the 49 | // appropriate entries (mutating the section links). 50 | // 51 | // This method also mutates the entries themselves, assigning them their table of contents 52 | // ordinals. 53 | static createThenAssociateSectionLinksWithEntries(documentChildren: OutlineSyntaxNode[]): TableOfContents { 54 | const entries = TableOfContents.getEntries(documentChildren) 55 | 56 | // Let's let each entry know its (1-based!) ordinal within the table of contents 57 | for (let i = 0; i < entries.length; i++) { 58 | entries[i].ordinalInTableOfContents = i + 1 59 | } 60 | 61 | const tableOfContents = new TableOfContents(entries) 62 | 63 | const allInlineSyntaxNodes = concat( 64 | documentChildren.map(child => child.inlineDescendants())) 65 | 66 | for (const inlineSyntaxNode of allInlineSyntaxNodes) { 67 | if (inlineSyntaxNode instanceof SectionLink) { 68 | inlineSyntaxNode.referenceMostAppropriateTableOfContentsEntry(tableOfContents) 69 | } 70 | } 71 | 72 | return tableOfContents 73 | } 74 | 75 | static getEntries(nodes: OutlineSyntaxNode[]): Heading[] { 76 | // Only headings can be table of contents entries. 77 | return concat( 78 | nodes.map(node => 79 | node instanceof Heading ? [node] : node.descendantsToIncludeInTableOfContents())) 80 | } 81 | 82 | constructor(public entries: Heading[] = []) { } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Test/Parsing/Overlapping/DoubleParentheticals.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Overlapped doubly parenthesized text (closing at the same time) and stress', () => { 7 | it('splits the stress node, with 1 part inside both normal parenthetical nodes (up to the first closing parenthesis), 1 part only enclosing the second closing parenthesis, and 1 part following both normal parenthetical nodes', () => { 8 | expect(Up.parse("(I know. (Well, I don't **really.)) Ha!**")).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.NormalParenthetical([ 11 | new Up.Text('(I know. '), 12 | new Up.NormalParenthetical([ 13 | new Up.Text("(Well, I don't "), 14 | new Up.Stress([ 15 | new Up.Text('really.)') 16 | ]) 17 | ]), 18 | new Up.Stress([ 19 | new Up.Text(')') 20 | ]) 21 | ]), 22 | new Up.Stress([ 23 | new Up.Text(' Ha!') 24 | ]) 25 | ])) 26 | }) 27 | }) 28 | 29 | 30 | describe('Overlapped doubly parenthesized text (closing at different times) and stress', () => { 31 | it('splits the stress node, with 1 part inside both normal parenthetical nodes (up to first closing parenthesis), 1 part enclosing up to the second closing parenthesis, and 1 part following both normal parenthetical nodes', () => { 32 | expect(Up.parse("(I know. (Well, I don't **really.) So there.) Ha!**")).to.deep.equal( 33 | insideDocumentAndParagraph([ 34 | new Up.NormalParenthetical([ 35 | new Up.Text('(I know. '), 36 | new Up.NormalParenthetical([ 37 | new Up.Text("(Well, I don't "), 38 | new Up.Stress([ 39 | new Up.Text('really.)') 40 | ]) 41 | ]), 42 | new Up.Stress([ 43 | new Up.Text(' So there.)') 44 | ]) 45 | ]), 46 | new Up.Stress([ 47 | new Up.Text(' Ha!') 48 | ]) 49 | ])) 50 | }) 51 | }) 52 | 53 | 54 | describe('Overlapped stress and doubly parenthesized text (opening at the same time)', () => { 55 | it('does not split the stress node', () => { 56 | expect(Up.parse("**I need to sleep. ((So** what?) It's late.)")).to.deep.equal( 57 | insideDocumentAndParagraph([ 58 | new Up.Stress([ 59 | new Up.Text('I need to sleep. '), 60 | new Up.NormalParenthetical([ 61 | new Up.Text('('), 62 | new Up.NormalParenthetical([ 63 | new Up.Text('(So') 64 | ]) 65 | ]) 66 | ]), 67 | new Up.NormalParenthetical([ 68 | new Up.NormalParenthetical([ 69 | new Up.Text(' what?)') 70 | ]), 71 | new Up.Text(" It's late.)") 72 | ]) 73 | ])) 74 | }) 75 | }) 76 | 77 | 78 | describe('Overlapped stress and doubly parenthesized text (opening at different times)', () => { 79 | it('does not split the stress node', () => { 80 | expect(Up.parse("**I need to sleep. (I know. (Well**, I don't really.))")).to.deep.equal( 81 | insideDocumentAndParagraph([ 82 | new Up.Stress([ 83 | new Up.Text('I need to sleep. '), 84 | new Up.NormalParenthetical([ 85 | new Up.Text('(I know. '), 86 | new Up.NormalParenthetical([ 87 | new Up.Text('(Well') 88 | ]) 89 | ]) 90 | ]), 91 | new Up.NormalParenthetical([ 92 | new Up.NormalParenthetical([ 93 | new Up.Text(", I don't really.)") 94 | ]), 95 | new Up.Text(')') 96 | ]) 97 | ])) 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /src/Implementation/Parsing/Outline/tryToParseHeading.ts: -------------------------------------------------------------------------------- 1 | import { DIVIDER_STREAK_PATTERN, NON_BLANK_PATTERN } from '../../Patterns' 2 | import { Heading } from '../../SyntaxNodes/Heading' 3 | import { getInlineSyntaxNodes } from '../Inline/getInlineSyntaxNodes' 4 | import { isUnderlineConsistentWithOverline } from './HeadingLeveler' 5 | import { isLineFancyOutlineConvention } from './isLineFancyOutlineConvention' 6 | import { LineConsumer } from './LineConsumer' 7 | import { OutlineParser } from './OutlineParser' 8 | 9 | 10 | // If text is underlined, it's treated as a heading. 11 | // 12 | // The first heading in a document is always a top-level heading. All subsequent headings 13 | // with underlines consisting of the same characters are considered top-level. 14 | // 15 | // The first heading with a different combination of underline characters is considered a 16 | // second-level heading. Unsurprisingly, all subsequent headings with underlines consisting 17 | // of the same characters are also considered second-level. 18 | // 19 | // This process continues ad infinitum. There is no limit to the number of heading levels 20 | // in a document. 21 | // 22 | // A heading can have an optional "overline", but its overline must consist of the same 23 | // combination of characters as its underline. 24 | // 25 | // For the purpose of determining heading levels, a heading with an overline is always 26 | // considered distinct from a heading without one, even if both headings use the same 27 | // combination of underline characters. Therefore, a heading with an overline will never 28 | // have the same level as a heading without an overline. 29 | export function tryToParseHeading(args: OutlineParser.Args): OutlineParser.Result { 30 | const markupLineConsumer = new LineConsumer(args.markupLines) 31 | 32 | // First, let's try to consume the optional overline... 33 | const overlineResult = 34 | markupLineConsumer.consumeLineIfMatches(DIVIDER_STREAK_PATTERN) 35 | 36 | const optionalOverline = overlineResult 37 | ? overlineResult.line 38 | : null 39 | 40 | // Next, the heading's content... 41 | const contentResult = 42 | markupLineConsumer.consumeLineIfMatches(NON_BLANK_PATTERN) 43 | 44 | if (!contentResult) { 45 | return null 46 | } 47 | 48 | const contentMarkup = contentResult.line 49 | 50 | /// And finally its underline! 51 | const underlineResult = 52 | markupLineConsumer.consumeLineIfMatches(DIVIDER_STREAK_PATTERN) 53 | 54 | if (!underlineResult) { 55 | return null 56 | } 57 | 58 | const underline = underlineResult.line 59 | 60 | if (!isUnderlineConsistentWithOverline( underline, optionalOverline )) { 61 | return null 62 | } 63 | 64 | // We're still not convinced this is actually a heading. Why's that? 65 | // 66 | // Well, what if the content is a streak? For example: 67 | // 68 | // ============================================= 69 | // #~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~#~# 70 | // ============================================= 71 | // 72 | // Or what if the content is a list with a single item? For example: 73 | // 74 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 75 | // * Buy milk 76 | // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 77 | // 78 | // Neither of those should be parsed as headings. We only accept the heading's content if it 79 | // would otherwise be parsed as a regular paragraph. 80 | if (isLineFancyOutlineConvention(contentMarkup, args.settings)) { 81 | return null 82 | } 83 | 84 | const children = 85 | getInlineSyntaxNodes(contentMarkup, args.settings) 86 | 87 | const level = 88 | args.headingLeveler.registerHeadingAndGetLevel(underline, !!optionalOverline) 89 | 90 | return { 91 | parsedNodes: [new Heading(children, { level, titleMarkup: contentMarkup.trim() })], 92 | countLinesConsumed: markupLineConsumer.countLinesConsumed() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Test/Parsing/InlineDocument/OuterWhitespace.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | 4 | 5 | context("In inline documents, all outer whitespace is considered meaningless, even when it's escaped. This includes:", () => { 6 | context('Trailing whitespace:', () => { 7 | specify('Not escaped', () => { 8 | expect(Up.parseInline("I'm just a normal guy who only eats when it's raining. Isn't everyone like that? \t \t ")).to.deep.equal( 9 | new Up.InlineDocument([ 10 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 11 | ])) 12 | }) 13 | 14 | specify('Escaped', () => { 15 | expect(Up.parseInline("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?\\ \t ")).to.deep.equal( 16 | new Up.InlineDocument([ 17 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 18 | ])) 19 | }) 20 | 21 | specify('Both escaped and not escaped', () => { 22 | expect(Up.parseInline("I'm just a normal guy who only eats when it's raining. Isn't everyone like that? \t \\ \\\t \t ")).to.deep.equal( 23 | new Up.InlineDocument([ 24 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 25 | ])) 26 | }) 27 | 28 | specify('Both escaped and not escaped, all following a backslash itself following an escaped backslash', () => { 29 | expect(Up.parseInline("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?\\\\\\ \t \\ \\\t \t ")).to.deep.equal( 30 | new Up.InlineDocument([ 31 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?\\") 32 | ])) 33 | }) 34 | }) 35 | 36 | 37 | context('Leading whitespace:', () => { 38 | specify('Not escaped', () => { 39 | expect(Up.parseInline(" \t \t I'm just a normal guy who only eats when it's raining. Isn't everyone like that?")).to.deep.equal( 40 | new Up.InlineDocument([ 41 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 42 | ])) 43 | }) 44 | 45 | specify('Escaped', () => { 46 | expect(Up.parseInline("\\ I'm just a normal guy who only eats when it's raining. Isn't everyone like that?")).to.deep.equal( 47 | new Up.InlineDocument([ 48 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 49 | ])) 50 | }) 51 | 52 | specify('Both escaped and not escaped', () => { 53 | expect(Up.parseInline(" \\\t \\ \\ I'm just a normal guy who only eats when it's raining. Isn't everyone like that?")).to.deep.equal( 54 | new Up.InlineDocument([ 55 | new Up.Text("I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 56 | ])) 57 | }) 58 | 59 | specify('Both escaped and not escaped, all followed by a backslash escaping another backslash', () => { 60 | expect(Up.parseInline(" \t \\\t \\ \\\\I'm just a normal guy who only eats when it's raining. Isn't everyone like that?")).to.deep.equal( 61 | new Up.InlineDocument([ 62 | new Up.Text("\\I'm just a normal guy who only eats when it's raining. Isn't everyone like that?") 63 | ])) 64 | }) 65 | }) 66 | 67 | 68 | specify('Both trailing and leading whitespace together, in the most absurd arrangement possible', () => { 69 | expect(Up.parseInline(" \t \\\t \\ \\\\I'm just a normal guy who only eats when it's raining. Isn't everyone like that?\\\\\\ \t \\ \\\t \t ")).to.deep.equal( 70 | new Up.InlineDocument([ 71 | new Up.Text("\\I'm just a normal guy who only eats when it's raining. Isn't everyone like that?\\") 72 | ])) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/Implementation/Up.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedSettings } from './NormalizedSettings' 2 | import { parse } from './Parsing/parse' 3 | import { parseInline } from './Parsing/parseInline' 4 | import { HtmlRenderer } from './Rendering/Html/HtmlRenderer' 5 | import { Settings } from './Settings' 6 | import { Document } from './SyntaxNodes/Document' 7 | import { InlineDocument } from './SyntaxNodes/InlineDocument' 8 | 9 | 10 | export class Up { 11 | private settings: NormalizedSettings 12 | 13 | constructor(settings?: Settings) { 14 | this.settings = new NormalizedSettings(settings) 15 | } 16 | 17 | // Converts Up markup into HTML and returns the result. 18 | parseAndRender(markup: string, extraSettings?: Settings): string { 19 | const document = this.parse(markup, extraSettings?.parsing) 20 | return this.render(document, extraSettings?.rendering) 21 | } 22 | 23 | // Converts Up markup into two pieces of HTML, both of which are returned: 24 | // 25 | // 1. A table of contents 26 | // 2. The document itself 27 | parseAndRenderWithTableOfContents(markup: string, extraSettings?: Settings): DocumentAndTableOfContentsHtml { 28 | const document = this.parse(markup, extraSettings?.parsing) 29 | return this.renderWithTableOfContents(document, extraSettings?.rendering) 30 | } 31 | 32 | // Converts inline Up markup into inline HTML and returns the result. 33 | parseAndRenderInline(inlineMarkup: string, extraSettings?: Settings): string { 34 | const inlineDocument = this.parseInline(inlineMarkup, extraSettings?.parsing) 35 | return this.renderInline(inlineDocument, extraSettings?.rendering) 36 | } 37 | 38 | // Parses Up markup and returns the resulting syntax tree. 39 | parse(markup: string, extraSettings?: Settings.Parsing): Document { 40 | return parse(markup, this.getParsingSettings(extraSettings)) 41 | } 42 | 43 | // Parses inline Up markup and returns the resulting inline syntax tree. 44 | parseInline(inlineMarkup: string, extraSettings?: Settings.Parsing): InlineDocument { 45 | return parseInline(inlineMarkup, this.getParsingSettings(extraSettings)) 46 | } 47 | 48 | // Converts a syntax tree into HTML, then returns the result. 49 | render(document: Document, extraSettings?: Settings.Rendering): string { 50 | const htmlRenderer = this.getHtmlRenderer(extraSettings) 51 | return htmlRenderer.document(document) 52 | } 53 | 54 | // Converts a syntax tree into two pieces of HTML, both of which are returned: 55 | // 56 | // 1. A table of contents 57 | // 2. The document itself 58 | renderWithTableOfContents(document: Document, extraSettings?: Settings.Rendering): DocumentAndTableOfContentsHtml { 59 | const htmlRenderer = this.getHtmlRenderer(extraSettings) 60 | 61 | return { 62 | documentHtml: htmlRenderer.document(document), 63 | tableOfContentsHtml: htmlRenderer.tableOfContents(document.tableOfContents) 64 | } 65 | } 66 | 67 | // Converts an inline syntax tree into inline HTML and returns the result. 68 | renderInline(inlineDocument: InlineDocument, extraSettings?: Settings.Rendering): string { 69 | const htmlRenderer = this.getHtmlRenderer(extraSettings) 70 | return htmlRenderer.inlineDocument(inlineDocument) 71 | } 72 | 73 | private getHtmlRenderer(extraSettings: Settings.Rendering | undefined): HtmlRenderer { 74 | return new HtmlRenderer(this.getRenderingSettings(extraSettings)) 75 | } 76 | 77 | private getParsingSettings(changes: Settings.Parsing | undefined): NormalizedSettings.Parsing { 78 | return this.settings.withChanges({ parsing: changes ?? {} }).parsing 79 | } 80 | 81 | private getRenderingSettings(changes: Settings.Rendering | undefined): NormalizedSettings.Rendering { 82 | return this.settings.withChanges({ rendering: changes ?? {} }).rendering 83 | } 84 | } 85 | 86 | 87 | export interface DocumentAndTableOfContentsHtml { 88 | documentHtml: string 89 | tableOfContentsHtml: string 90 | } 91 | -------------------------------------------------------------------------------- /src/Test/Parsing/Overlapping/LinkifiedConventions.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../../Main' 3 | import { insideDocumentAndParagraph } from '../Helpers' 4 | 5 | 6 | describe('Emphasis overlapping a linkified revealable convention', () => { 7 | it('splits the emphasis node', () => { 8 | expect(Up.parse('After you beat the Elite Four, *only [SPOILER: you* fight Gary] (http://example.com/finalbattle).')).to.deep.equal( 9 | insideDocumentAndParagraph([ 10 | new Up.Text('After you beat the Elite Four, '), 11 | new Up.Emphasis([ 12 | new Up.Text('only ') 13 | ]), 14 | new Up.InlineRevealable([ 15 | new Up.Link([ 16 | new Up.Emphasis([ 17 | new Up.Text('you') 18 | ]), 19 | new Up.Text(' fight Gary') 20 | ], 'http://example.com/finalbattle') 21 | ]), 22 | new Up.Text('.') 23 | ])) 24 | }) 25 | }) 26 | 27 | 28 | describe('A linkified revealable convention overlapping highlighted text', () => { 29 | it('splits the highlight node', () => { 30 | expect(Up.parse('After you beat the Elite Four, [SPOILER: you fight Gary ==Oak][http://example.com/finalbattle] and then the credits roll==.')).to.deep.equal( 31 | insideDocumentAndParagraph([ 32 | new Up.Text('After you beat the Elite Four, '), 33 | new Up.InlineRevealable([ 34 | new Up.Link([ 35 | new Up.Text('you fight Gary '), 36 | new Up.Highlight([ 37 | new Up.Text('Oak') 38 | ]) 39 | ], 'http://example.com/finalbattle') 40 | ]), 41 | new Up.Highlight([ 42 | new Up.Text(' and then the credits roll') 43 | ]), 44 | new Up.Text('.') 45 | ])) 46 | }) 47 | }) 48 | 49 | 50 | describe('A footnote that overlaps a linified revealable convention', () => { 51 | it('splits the revealable node and its inner link node, not the footnote node', () => { 52 | const markup = 'Eventually, I will think of one (^reasonable [NSFL: and realistic) example of a] [example.com] footnote that overlaps an inline revealable convention.' 53 | 54 | const footnote = 55 | new Up.Footnote([ 56 | new Up.Text('reasonable '), 57 | new Up.InlineRevealable([ 58 | new Up.Link([ 59 | new Up.Text('and realistic') 60 | ], 'https://example.com') 61 | ]) 62 | ], { referenceNumber: 1 }) 63 | 64 | expect(Up.parse(markup)).to.deep.equal( 65 | new Up.Document([ 66 | new Up.Paragraph([ 67 | new Up.Text('Eventually, I will think of one'), 68 | footnote, 69 | new Up.InlineRevealable([ 70 | new Up.Link([ 71 | new Up.Text(' example of a') 72 | ], 'https://example.com') 73 | ]), 74 | new Up.Text(' footnote that overlaps an inline revealable convention.') 75 | ]), 76 | new Up.FootnoteBlock([footnote]) 77 | ])) 78 | }) 79 | }) 80 | 81 | 82 | describe('A linified revealable convention that overlaps a footnote', () => { 83 | it('splits the revealable node and its inner link node, not the footnote node', () => { 84 | const markup = '[NSFL: Gary loses to Ash (^Ketchum] (example.com) is his last name)' 85 | 86 | const footnote = 87 | new Up.Footnote([ 88 | new Up.InlineRevealable([ 89 | new Up.Link([ 90 | new Up.Text('Ketchum') 91 | ], 'https://example.com') 92 | ]), 93 | new Up.Text(' is his last name') 94 | ], { referenceNumber: 1 }) 95 | 96 | expect(Up.parse(markup)).to.deep.equal( 97 | new Up.Document([ 98 | new Up.Paragraph([ 99 | new Up.InlineRevealable([ 100 | new Up.Link([ 101 | new Up.Text('Gary loses to Ash') 102 | ], 'https://example.com') 103 | ]), 104 | footnote 105 | ]), 106 | new Up.FootnoteBlock([footnote]) 107 | ])) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /src/Test/Parsing/PlusMinusSign.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | context('A plus sign followed by a hyphen normally produces a plus-minus sign', () => { 7 | context('This applies within regular text:', () => { 8 | specify('Between words', () => { 9 | expect(Up.parse('Yeah, it uses base HP+-4.')).to.deep.equal( 10 | insideDocumentAndParagraph([ 11 | new Up.Text('Yeah, it uses base HP±4.') 12 | ])) 13 | }) 14 | 15 | specify('Following a word', () => { 16 | expect(Up.parse('I have 10+- ...')).to.deep.equal( 17 | insideDocumentAndParagraph([ 18 | new Up.Text('I have 10± …') 19 | ])) 20 | }) 21 | 22 | specify('Preceding a word', () => { 23 | expect(Up.parse('I have three homes, +-two.')).to.deep.equal( 24 | insideDocumentAndParagraph([ 25 | new Up.Text('I have three homes, ±two.') 26 | ])) 27 | }) 28 | 29 | specify('Surrounded by whitespace', () => { 30 | expect(Up.parse('Well, +- a million.')).to.deep.equal( 31 | insideDocumentAndParagraph([ 32 | new Up.Text('Well, ± a million.') 33 | ])) 34 | }) 35 | }) 36 | 37 | 38 | context('This does not apply within:', () => { 39 | specify('Link URLs', () => { 40 | expect(Up.parse('[American flag emoji] (https://example.com/empojis/US+-flag?info)')).to.deep.equal( 41 | insideDocumentAndParagraph([ 42 | new Up.Link([ 43 | new Up.Text('American flag emoji') 44 | ], 'https://example.com/empojis/US+-flag?info') 45 | ])) 46 | }) 47 | 48 | specify('Media URLs', () => { 49 | expect(Up.parse('[video: ghosts eating luggage] (http://example.com/polter+-geists.webm)')).to.deep.equal( 50 | new Up.Document([ 51 | new Up.Video('ghosts eating luggage', 'http://example.com/polter+-geists.webm') 52 | ])) 53 | }) 54 | 55 | specify('Linkified media URLs', () => { 56 | expect(Up.parse('[image: you fight Gary] (https://example.com/fight.svg) (http://example.com/final+-battle)')).to.deep.equal( 57 | new Up.Document([ 58 | new Up.Link([ 59 | new Up.Image('you fight Gary', 'https://example.com/fight.svg') 60 | ], 'http://example.com/final+-battle') 61 | ])) 62 | }) 63 | 64 | specify('Linkified URLs for non-media conventions', () => { 65 | expect(Up.parse('[SPOILER: you fight Gary] (http://example.com/final+-battle)')).to.deep.equal( 66 | insideDocumentAndParagraph([ 67 | new Up.InlineRevealable([ 68 | new Up.Link([ 69 | new Up.Text('you fight Gary') 70 | ], 'http://example.com/final+-battle') 71 | ]) 72 | ])) 73 | }) 74 | 75 | specify('Inline code', () => { 76 | expect(Up.parse('`x+-y`')).to.deep.equal( 77 | insideDocumentAndParagraph([ 78 | new Up.InlineCode('x+-y') 79 | ])) 80 | }) 81 | 82 | specify('Code blocks', () => { 83 | const markup = ` 84 | \`\`\` 85 | for (let i = items.length - 1; i >= 0; i = i+-1) { } 86 | \`\`\`` 87 | 88 | expect(Up.parse(markup)).to.deep.equal( 89 | new Up.Document([ 90 | new Up.CodeBlock( 91 | 'for (let i = items.length - 1; i >= 0; i = i+-1) { }') 92 | ])) 93 | }) 94 | }) 95 | }) 96 | 97 | 98 | describe('When either of the hyphens are escaped, no en dash is produced:', () => { 99 | specify('First dash:', () => { 100 | expect(Up.parse("Okay\\--I'll eat the tarantula.")).to.deep.equal( 101 | insideDocumentAndParagraph([ 102 | new Up.Text("Okay--I'll eat the tarantula.") 103 | ])) 104 | }) 105 | 106 | specify('Second hyphen:', () => { 107 | expect(Up.parse("Okay-\\-I'll eat the tarantula.")).to.deep.equal( 108 | insideDocumentAndParagraph([ 109 | new Up.Text("Okay--I'll eat the tarantula.") 110 | ])) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/Test/Parsing/EnDash.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import * as Up from '../../Main' 3 | import { insideDocumentAndParagraph } from './Helpers' 4 | 5 | 6 | context('2 consecutive hyphens normally produce an en dash.', () => { 7 | context('This applies within regular text:', () => { 8 | specify('Between words', () => { 9 | expect(Up.parse("Okay--I'll eat the tarantula.")).to.deep.equal( 10 | insideDocumentAndParagraph([ 11 | new Up.Text("Okay–I'll eat the tarantula.") 12 | ])) 13 | }) 14 | 15 | specify('Following a word', () => { 16 | expect(Up.parse("Okay-- I'll eat the tarantula.")).to.deep.equal( 17 | insideDocumentAndParagraph([ 18 | new Up.Text("Okay– I'll eat the tarantula.") 19 | ])) 20 | }) 21 | 22 | specify('Preceding a word', () => { 23 | expect(Up.parse('"I like StarCraft" --Mark Twain')).to.deep.equal( 24 | insideDocumentAndParagraph([ 25 | new Up.InlineQuote([ 26 | new Up.Text('I like StarCraft') 27 | ]), 28 | new Up.Text(' –Mark Twain') 29 | ])) 30 | }) 31 | 32 | specify('Surrounded by whitespace', () => { 33 | expect(Up.parse("Okay -- I'll eat the tarantula.")).to.deep.equal( 34 | insideDocumentAndParagraph([ 35 | new Up.Text("Okay – I'll eat the tarantula.") 36 | ])) 37 | }) 38 | }) 39 | 40 | 41 | context('This does not apply within:', () => { 42 | specify('Link URLs', () => { 43 | expect(Up.parse('[American flag emoji] (https://example.com/empojis/US--flag?info)')).to.deep.equal( 44 | insideDocumentAndParagraph([ 45 | new Up.Link([ 46 | new Up.Text('American flag emoji') 47 | ], 'https://example.com/empojis/US--flag?info') 48 | ])) 49 | }) 50 | 51 | specify('Media URLs', () => { 52 | expect(Up.parse('[video: ghosts eating luggage] (http://example.com/polter--geists.webm)')).to.deep.equal( 53 | new Up.Document([ 54 | new Up.Video('ghosts eating luggage', 'http://example.com/polter--geists.webm') 55 | ])) 56 | }) 57 | 58 | specify('Linkified media URLs', () => { 59 | expect(Up.parse('[image: you fight Gary] (https://example.com/fight.svg) (http://example.com/final--battle)')).to.deep.equal( 60 | new Up.Document([ 61 | new Up.Link([ 62 | new Up.Image('you fight Gary', 'https://example.com/fight.svg') 63 | ], 'http://example.com/final--battle') 64 | ])) 65 | }) 66 | 67 | specify('Linkified URLs for non-media conventions', () => { 68 | expect(Up.parse('[SPOILER: you fight Gary] (http://example.com/final--battle)')).to.deep.equal( 69 | insideDocumentAndParagraph([ 70 | new Up.InlineRevealable([ 71 | new Up.Link([ 72 | new Up.Text('you fight Gary') 73 | ], 'http://example.com/final--battle') 74 | ]) 75 | ])) 76 | }) 77 | 78 | specify('Inline code', () => { 79 | expect(Up.parse('`i--;`')).to.deep.equal( 80 | insideDocumentAndParagraph([ 81 | new Up.InlineCode('i--;') 82 | ])) 83 | }) 84 | 85 | specify('Code blocks', () => { 86 | const markup = ` 87 | \`\`\` 88 | for (let i = items.length - 1; i >= 0; i--) { } 89 | \`\`\`` 90 | 91 | expect(Up.parse(markup)).to.deep.equal( 92 | new Up.Document([ 93 | new Up.CodeBlock( 94 | 'for (let i = items.length - 1; i >= 0; i--) { }') 95 | ])) 96 | }) 97 | }) 98 | }) 99 | 100 | 101 | describe('When either of the hyphens are escaped, no en dash is produced:', () => { 102 | specify('First dash:', () => { 103 | expect(Up.parse("Okay\\--I'll eat the tarantula.")).to.deep.equal( 104 | insideDocumentAndParagraph([ 105 | new Up.Text("Okay--I'll eat the tarantula.") 106 | ])) 107 | }) 108 | 109 | specify('Second hyphen:', () => { 110 | expect(Up.parse("Okay-\\-I'll eat the tarantula.")).to.deep.equal( 111 | insideDocumentAndParagraph([ 112 | new Up.Text("Okay--I'll eat the tarantula.") 113 | ])) 114 | }) 115 | }) 116 | --------------------------------------------------------------------------------