kgar / ts-markdown

An extensible TypeScript markdown generator that takes JSON and creates a markdown document
https://kgar.github.io/ts-markdown/
MIT License
9 stars 4 forks source link

Markdown Entry Helper Library #6

Closed kgar closed 2 years ago

kgar commented 2 years ago

As a ts-markdown user, I would like helper functions for generating MarkdownEntries. These helper functions enforce TypeScript typings and make it easier for someone with a code editor like VS Code to quickly drum up the elements of their markdown data in a TS node project.

Let's set some standards

This is not set in stone, but what if we had functions like:

h1(
    content: InlineTypes, 
    options: {
        underline?: boolean, 
        id?: string, 
        append?: string
    }
): H1Entry;

codeblock(
    content: string | string[], 
    options: {
        fenced?: boolean | '`' | '~', 
        language?: string, 
        append?: string
    }
): CodeblockEntry;

p(
    content: InlineTypes, 
    options: { append?: string }
): ParagraphyEntry;

Then we could create entries like:

let entries = [
    h1('Hello, world!', {underline: true}),
    p('Behold, the code!'),
    codeblock(
        "Some code here!"
    )
];

It might seem a little redundant, but I have found that putting JSON objects directly into a markdown entry array ends up widening the possible type to all unioned types and/or marked interfaces. To regain TypeScript support for individual entry types, I currently have to cast:

const entries: MarkdownEntry[] = [
  <H1Entry>{ h1: 'Hello, world!' },
  <BlockquoteEntry>{
    blockquote:
      'This is a document which contains cool stuff such as the following:',
  },
  <TransclusionEntry>{
    transclusion: {
      path: 'Path/To/My/Transcluded/Content',
    },
    html: true,
  },
  <TextEntry>{
    text: 'Oh hai block-level transclusion 👆',
  },
];

Not sure if this is the case, but it seems like it'd be more ergonomic to get typing support through calling a function to create the particular markdown entry.

As an experiment, I will try this out in a branch and see if I can update my unit tests to use helpers instead of typecast objects.

kgar commented 2 years ago

Consider this code where I use direct casting to get some TypeScript enforcement on my markdown entries:

const data: MarkdownEntry[] = [
  <H1Entry>{
    h1: 'Testing the Code',
  },
  <HorizontalRuleEntry>{
    hr: true,
  },
  <ParagraphEntry>{
    p: 'It is important to write tests for both initial development and for longterm maintenance.',
  },
  <TableEntry>{
    table: {
      columns: ['Action', 'Reason'],
      rows: [
        [
          'Add new tests for new features',
          'Ensures the library is doing what we expect',
        ],
        [
          'Ensure all previous tests run and pass',
          'Ensures new features do not break existing features',
        ],
        [
          'Etc.',
          {
            text: [
              {
                link: {
                  href: 'https://www.google.com',
                  text: 'Time to get back to testing',
                  title:
                    "Don't worry, search engines are a vital part of development",
                },
              },
              ' ',
              {
                img: {
                  source: 'https://via.placeholder.com/25',
                  alt: 'A 25x25 placeholder image',
                  title: 'Here is a handy placeholder image',
                },
              },
            ],
          },
        ],
      ],
    },
  },
  <H2Entry>{
    h2: 'Sample Test Suite with One Test',
  },
  <CodeBlockEntry>{
    codeblock: `describe('given some common aspect to these tests', () => {
  describe('with this particular input', () => {
    const data = 'some data here';

    test('results in this exepected output', () => {
      expect(runMyCode(data)).toBe('my expected result')
    });
  });
});`,
    fenced: true,
    language: 'ts',
  },
];

With some fairly basic helpers, it looks like this:

const data: MarkdownEntry[] = [
  h1('Testing the Code'),
  hr(),
  p(
    'It is important to write tests for both initial development and for longterm maintenance.'
  ),
  table({
    columns: ['Action', 'Reason'],
    rows: [
      [
        'Add new tests for new features',
        'Ensures the library is doing what we expect',
      ],
      [
        'Ensure all previous tests run and pass',
        'Ensures new features do not break existing features',
      ],
      [
        'Etc.',

        text([
          link({
            href: 'https://www.google.com',
            text: 'Time to get back to testing',
            title:
              "Don't worry, search engines are a vital part of development",
          }),
          ' ',
          img({
            source: 'https://via.placeholder.com/25',
            alt: 'A 25x25 placeholder image',
            title: 'Here is a handy placeholder image',
          }),
        ]),
      ],
    ],
  }),
  h2('Sample Test Suite with One Test'),
  codeblock(
    `describe('given some common aspect to these tests', () => {
  describe('with this particular input', () => {
    const data = 'some data here';

    test('results in this exepected output', () => {
      expect(runMyCode(data)).toBe('my expected result')
    });
  });
});`,
    { fenced: true, language: 'ts' }
  ),
];

This particular helper design is based on the idea that there's a good chance we may not use the additional options and instead just provide content.

For example, the h1() function signature demonstrates separating the one required property from the rest and spreading the rest back in if they are provided:

export function h1(
  content: H1Entry['h1'],
  options?: Omit<H1Entry, 'h1'>
): H1Entry {
  return {
    h1: content,
    ...options,
  };
}

The result is a razor-sharp invocation:

h1('This The Big Text')

And, if more settings are needed at the entry level, they can be provided:

h1('Underline This For The Markdown Readers Out There', { underline: true })

The rest of the options are in an object together, because more options could be added later, and it's best if we make that options object open-ended so more properties can be added whenever the time comes.

Note the TypeScript typing automatically leaves room for this by taking everything else other than the content: options?: Omit<H1Entry, 'h1'>

Tables are still fairly sizable endeavors, but it's still less code overall to use a helper, so I'm good with that.

Best of all, weird ones like HorizontalRuleEntry make easy sense with this approach: e.g., hr()

I think it's worth pursuing further and offering as an alternative to casting, creating individual typed variables, or just not even leveraging the typings at all.

kgar commented 2 years ago

Currently, the thought is to include these helper functions in the same file where the types and the renderer reside. So, for the hr element, you would have all this content together:

import { MarkdownRenderer, RenderOptions } from '../rendering.types';
import { MarkdownEntry } from '../shared.types';

/**
 * A markdown entry for generating hr elements.
 */
export interface HorizontalRuleEntry extends MarkdownEntry {
  /**
   * The hr contents and identifying property for the renderer.
   */
  hr: any;

  /**
   * Option determining which indicator to use for an hr.
   * Default: '-'
   */
  indicator?: '*' | '-' | '_';

  /**
   * Option which will arbitrarily append a string immediately below the hr, ignoring block-level settings.
   */
  append?: string;
}

/**
 * The renderer for hr entries.
 *
 * @param entry The hr entry.
 * @param options Document-level render options.
 * @returns Block-level hr markdown content.
 */
export const hrRenderer: MarkdownRenderer = (
  entry: HorizontalRuleEntry,
  options: RenderOptions
) => {
  if ('hr' in entry) {
    let indicator = entry.indicator ?? '-';

    return {
      markdown: `${indicator}${indicator}${indicator}`,
      blockLevel: true,
    };
  }

  throw new Error('Entry is not an hr entry. Unable to render.');
};

// 👇👇👇👇👇👇👇 THE NEW STUFF 👇👇👇👇👇👇👇👇

/**
 * Helper which creates a horizontal rule entry.
 * 
 * @param options Entry-level options for this element.
 * @returns a horizontal rule entry
 */
export function hr(
  options?: Omit<HorizontalRuleEntry, 'hr'>
): HorizontalRuleEntry {
  return {
    hr: true,
    ...options,
  };
}
kgar commented 2 years ago

I'll have to make sure and update the documentation to show both ways of making markdown entry arrays, but I get the feeling I'll be more a fan of the helpers in common usage when using it in other projects.