From be856151e1074aff34ae205cb0bbde46c7094c55 Mon Sep 17 00:00:00 2001 From: Tero Piirainen Date: Tue, 24 Sep 2024 11:57:43 +0300 Subject: [PATCH 001/103] Glow: support for rendering an array of lines --- packages/glow/src/glow.js | 25 ++++++++++++++----------- packages/glow/test/glow.test.js | 32 +++++++++----------------------- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/packages/glow/src/glow.js b/packages/glow/src/glow.js index a845b33f..cbfdf62e 100644 --- a/packages/glow/src/glow.js +++ b/packages/glow/src/glow.js @@ -221,19 +221,19 @@ export function renderRow(row, lang) { // comment start & end const COMMENT = [/(\/\*|^ *{# ||'''|=end)$/] -export function parseSyntax(str, lang) { +export function parseSyntax(lines, lang) { const [comm_start, comm_end] = COMMENT - const lines = [] + const html = [] // multi-line comment let comment function endComment() { - lines.push({ comment }) + html.push({ comment }) comment = null } - str.split(/\r\n|\r|\n/).forEach((line, i) => { + lines.forEach((line, i) => { if (!comment) { if (comm_start.test(line)) { comment = [line] @@ -248,7 +248,7 @@ export function parseSyntax(str, lang) { // escape character if (c == '\\') line = line.slice(1) - lines.push({ line, wrap }) + html.push({ line, wrap }) } } else { @@ -258,24 +258,27 @@ export function parseSyntax(str, lang) { }) - return lines + return html } // code, { language: 'js', numbered: true } export function glow(str, opts = {}) { if (typeof opts == 'string') opts = { language: opts } + const lines = Array.isArray(str) ? str : str.trim().split(/\r\n|\r|\n/) + + if (!lines[0]) return '' // language let lang = opts.language - if (!lang && str.trim()[0] == '<') lang = 'html' - const lines = [] + if (!lang && lines[0][0] == '<') lang = 'html' + const html = [] function push(line) { - lines.push(opts.numbered ? elem('span', line) : line) + html.push(opts.numbered ? elem('span', line) : line) } - parseSyntax(str.trim(), lang).forEach(function(block) { + parseSyntax(lines, lang).forEach(function(block) { let { line, comment, wrap } = block // EOL comment @@ -290,5 +293,5 @@ export function glow(str, opts = {}) { push(line) }) - return `${lines.join(NL)}` + return `${html.join(NL)}` } diff --git a/packages/glow/test/glow.test.js b/packages/glow/test/glow.test.js index def80d5a..5278392f 100644 --- a/packages/glow/test/glow.test.js +++ b/packages/glow/test/glow.test.js @@ -27,28 +27,14 @@ test('Emphasis', () => { /* multiline comments */ -const HTML_COMMENT = ` -
- -
` - -const JS_COMMENT = ` -/* First */ -function() { - /* - Second - */ -} -` - -test('extract comments', () => { - let blocks = parseSyntax(HTML_COMMENT.trim()) - expect(blocks[1].comment[0]).toBe(' ', '']) + expect(blocks[1].comment[0]).toBe(' { name, attr, data } +*/ +export function parseTag(input) { + const { str, getValue } = valueGetter(input) + const [specs, ...attribs] = str.split(/\s+/) + const self = { ...parseSpecs(specs), data: {} } + + + function set(key, val) { + const ctx = ATTR.includes(key) || key.startsWith('data-') ? 'attr' : 'data' + self[ctx][key] = val + } + + for (const el of attribs) { + const [key, val] = el.split('=') + + // data: key="value" + if (val) set(key, parseValue((getValue(val) || val))) + + // key only + else if (!/\W/.test(key)) set(key, true) + + // default key + else set('_', getValue(key) || key) + } + + return self +} + + + +function parseValue(val) { + return val == "false" ? false : val == "true" ? true : val == "0" ? 0 : (1 * val || val) +} + +/* + foo="bar" baz="hey dude" --> + { str: foo=:1: bar=:2: } + getValue(':1:') => 'bar' +*/ +const RE = { single_quote: /'([^']+)'/g, double_quote: /"([^"]+)"/g } + +export function valueGetter(input) { + const strings = [] + const push = (_, el) => `:${strings.push(el)}:` + const str = input.replace(RE.single_quote, push).replace(RE.double_quote, push) + + function getValue(key) { + if (key[0] == ':' && key.slice(-1) == ':') { + return strings[1 * key.slice(1, -1) -1] + } + } + + return { str, getValue } +} + +// tabs.foo#bar.baz -> { name: 'tabs', class: ['foo', 'bar'], id: 'bar' } +export function parseSpecs(str) { + const self = { name: str, attr: {} } + const i = str.search(/[\#\.]/) + + if (i >= 0) { + self.name = str.slice(0, i) || null + self.attr = parseAttr(str.slice(i)) + } + + return self +} + +export function parseAttr(str) { + const attr = {} + + // classes + const classes = [] + str.replace(/\.([\w\-]+)/g, (_, el) => classes.push(el)) + if (classes[0]) attr.class = classes.join(' ') + + // id + str.replace(/#([\w\-]+)/, (_, el) => attr.id = el) + + return attr +} + + + diff --git a/packages/nuemark2/src/render-blocks.js b/packages/nuemark2/src/render-blocks.js new file mode 100644 index 00000000..afb50003 --- /dev/null +++ b/packages/nuemark2/src/render-blocks.js @@ -0,0 +1,73 @@ + +import { renderInline, renderTokens } from './render-inline.js' +import { parseLinkTitle } from './parse-inline.js' +import { renderTag, wrap } from './render-tag.js' +import { elem } from './document.js' +import { glow } from 'glow' + +export function renderBlocks(blocks, opts={}) { + opts.reflinks = parseReflinks({ ...blocks.reflinks, ...opts?.data?.links }) + return blocks.map(b => renderBlock(b, opts)).join('\n') +} + +function renderBlock(block, opts) { + const fn = opts?.beforerender + if (fn) fn(block) + + return block.is_content ? renderContent(block.content, opts) : + block.is_heading ? renderHeading(block, opts) : + block.is_quote ? elem('blockquote', renderBlocks(block.blocks, opts)) : + block.is_tag ? renderTag(block, opts) : + block.is_table ? renderTable(block, opts) : + block.is_list ? renderList(block, opts) : + block.code ? renderCode(block) : + block.is_newline ? '' : + block.is_hr ? '
' : + console.error('Unknown block', block) +} + + +// recursive +function renderList({ items, numbered }, opts) { + const html = items.map(blocks => elem('li', renderBlocks(blocks, opts))) + return elem(numbered ? 'ol' : 'ul', html.join('\n')) +} + +function parseReflinks(links) { + for (const key in links) { + links[key] = parseLinkTitle(links[key]) + } + return links +} + +export function renderHeading(h, opts={}) { + const ids = opts.heading_ids + const a = ids ? elem('a', { href: `#${ h.attr.id }`, title: h.text }) : '' + if (!ids) delete h.attr.id + + return elem('h' + h.level, h.attr, a + renderTokens(h.tokens, opts)) +} + +export function renderContent(lines, opts) { + const html = lines.map(line => renderInline(line, opts)).join('\n') + return elem('p', html) +} + +function renderCode({ name, code, attr, data }) { + const { numbered } = data + const klass = attr.class + delete attr.class + const pre = elem('pre', attr, glow(code, { language: name, numbered})) + return wrap(klass, pre) +} + +export function renderTable({ rows, attr, head=true }, opts) { + + const html = rows.map((row, i) => { + const cells = row.map(td => elem(head && !i ? 'th' : 'td', renderInline(td, opts))) + return elem('tr', cells.join('')) + }) + + return elem('table', attr, html.join('\n')) +} + diff --git a/packages/nuemark2/src/render-inline.js b/packages/nuemark2/src/render-inline.js new file mode 100644 index 00000000..cfdfdc26 --- /dev/null +++ b/packages/nuemark2/src/render-inline.js @@ -0,0 +1,37 @@ + +import { parseInline } from './parse-inline.js' +import { renderTag } from './render-tag.js' +import { elem } from './document.js' + +export function renderToken(token, opts={}) { + const { text, href, label, title } = token + const { reflinks={}, data={} } = opts + + return text ? text : + token.is_format ? formatText(token, opts) : + token.is_image ? elem('img', { src: href, alt: label, title, loading: 'lazy' }) : + href ? renderLink(renderInline(label, opts), reflinks[href] || token) : + token.is_var ? (data[token.name] || '') : + token.is_tag ? renderTag(token, opts) : + '' +} + +function formatText({tag, body }, opts) { + const html = tag == 'code' ? body : renderInline(body, opts) + return elem(tag, html) +} + +function renderLink(label, link) { + const { href, title } = link + return elem('a', { href, title }, label) +} + +export function renderTokens(tokens, opts) { + return tokens.map(token => renderToken(token, opts)).join('').trim() +} + +export function renderInline(str, opts) { + return renderTokens(parseInline(str), opts) +} + + diff --git a/packages/nuemark2/src/render-tag.js b/packages/nuemark2/src/render-tag.js new file mode 100644 index 00000000..6a93ef54 --- /dev/null +++ b/packages/nuemark2/src/render-tag.js @@ -0,0 +1,193 @@ + +import { renderBlocks, renderTable, renderContent } from './render-blocks.js' +import { renderInline } from './render-inline.js' +import { categorize, elem } from './document.js' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + + +// built-in tags +const TAGS = { + + accordion() { + const html = this.sections?.map(blocks => { + const summary = elem('summary', this.render([blocks[0]])) + return summary + this.render(blocks.slice(1)) + }) + if (html) { + const acc = elem('details', this.attr, html.join('\n')) + return wrap(this.data.wrapper, acc) + } + }, + + button() { + const { attr, data } = this + const label = this.renderInline(data.label || data._) || this.innerHTML || '' + return elem('a', { ...attr, href: data.href, role: 'button' }, label) + }, + + image() { + const { attr, data } = this + const { caption, href, loading='lazy' } = data + const src = data.src || data._ || data.large + const alt = data.alt || caption + + // img tag + const img_attr = { alt, loading, src, ...parseSize(data) } + let img = data.small ? createPicture(img_attr, data) : elem('img', img_attr) + + // wrap image inside a link + if (href) img = elem('a', { href }, img) + + // figcaption + const figcaption = caption ? this.renderInline(caption) : this.innerHTML + if (figcaption) img += elem('figcaption', figcaption) + + // always wrapped inside a figure + return elem('figure', attr, img) + }, + + list() { + const items = this.sections || getListItems(this.blocks) + if (!items) return '' + + const item_attr = { class: this.data.items } + const html = items.map(blocks => elem('li', item_attr, this.render(blocks))) + const ul = elem('ul', this.attr, html.join('\n')) + return wrap(this.data.wrapper, ul) + }, + + svg() { + const src = data.src || data._ + const path = join('.', src) + + try { + return src?.endsWith('.svg') ? readFileSync(path, 'utf-8') : '' + } catch (e) { + console.error('svg not found', path) + } + }, + + table() { + const { attr, data } = this + const table = renderTable({ attr, ...data }, this.opts) + return wrap(data.wrapper, table) + }, + + video() { + const { data } = this + const src = data.src || data._ + const type = getMimeType(src) + const attr = { ...this.attr, src, type, ...getVideoAttrs(data) } + return elem('video', attr, this.innerHTML) + }, + + // shortcut + '!': function() { + const tag = getMimeType(this.data._).startsWith('video') ? TAGS.video : TAGS.image + return tag.call(this) + } +} + +export function renderTag(tag, opts={}) { + const tags = opts.tags = { ...TAGS, ...opts?.tags } + const { name, attr } = tag + const { data={} } = opts + const fn = tags[name] + + // anonymous tag + if (!name) return elem('div', attr, renderBlocks(tag.blocks, opts)) + + if (!fn) { + console.error(`No "${name}" tag found from: [${Object.keys(tags)}]`) + return '' + } + + const api = { + ...tag, + data: extractColonVars({ ...data, ...tag.data }), // place after ..tag + get innerHTML() { return getInnerHTML(this.blocks, opts) }, + render(blocks) { return renderBlocks(blocks, opts) }, + renderInline(str) { return renderInline(str, opts) }, + sections: categorize(tag.blocks), + opts, + tags, + } + + return fn.call(api, api) +} + + + +/******* utilities *******/ + +const MIME = { + jpg: 'image/jpeg', + svg: 'image/svg+xml', + mov: 'video/mov', + webm: 'video/webm', + mp4: 'video/mp4', +} + +function getMimeType(path='') { + const type = path.slice(path.lastIndexOf('.') + 1) + return MIME[type] || `image/${type}` +} + +export function createPicture(img_attr, data) { + const { small, offset=750 } = data + + const sources = [small, img_attr.src].map(src => { + const prefix = src == small ? 'max' : 'min' + const media = `(${prefix}-width: ${parseInt(offset)}px)` + return elem('source', { srcset: src, media, type: getMimeType(src) }) + }) + + sources.push(elem('img', img_attr)) + return elem('picture', sources.join('\n')) +} + +function getListItems(arr) { + if (arr && arr[0]) return arr[0].items || arr.map(el => [el]) +} + +export function parseSize(data) { + const { size='' } = data + const [ w, h ] = size.trim().split(/\s*\D\s*/) + return { width: w || data.width, height: h || data.height } +} + + +// :rows="pricing" --> rows -> data.pricing +function extractColonVars(data) { + for (const key in data) { + if (key.startsWith(':')) { + data[key.slice(1)] = data[data[key]] + delete data[key] + } + } + return data +} + +function getVideoAttrs(data) { + const keys = 'autoplay controls loop muted poster preload src width'.split(' ') + const attr = {} + for (const key of keys) { + const val = data[key] + if (val) attr[key] = val + } + return attr +} + +export function wrap(name, html) { + return name ? elem('div', { class: name }, html) : html +} + + +function getInnerHTML(blocks, opts) { + const [ first, second ] = blocks + if (!first) return '' + const { content } = first + return content && !second ? renderInline(content.join(' '), opts) : renderBlocks(blocks, opts) +} + diff --git a/packages/nuemark2/test/block.test.js b/packages/nuemark2/test/block.test.js new file mode 100644 index 00000000..e90bac18 --- /dev/null +++ b/packages/nuemark2/test/block.test.js @@ -0,0 +1,201 @@ + +import { parseBlocks, isHR, parseHeading } from '../src/parse-blocks.js' +import { renderTable, renderHeading } from '../src/render-blocks.js' +import { nuemark, renderLines } from '..' + + +test('parse hr', () => { + const hrs = ['***', '___', '- - -', '*** --- ***'] + for (const str of hrs) { + expect(isHR(str)).toBe(true) + } + expect(isHR('*** yo')).toBeUndefined() +}) + +test('render HR', () => { + expect(renderLines(['hello', '***'])).toBe('

hello

\n
') +}) + +test('parse heading', () => { + const h = parseHeading('# Hello') + expect(h).toMatchObject({ attr: { id: "hello" }, text: 'Hello', level: 1 }) +}) + +test('heading class & id', () => { + const h = parseHeading('# Hello, *World!* { #foo.bar }') + expect(h.text).toBe('Hello, World!') + expect(h.attr).toEqual({ class: "bar", id: "foo" }) + + const html = renderHeading(h, { heading_ids: true }) + expect(html).toStartWith('

World!') +}) + +test('heading class name', () => { + const html = nuemark('# Hello { .boss }') + expect(html).toBe('

Hello

') +}) + +test('render heading', () => { + expect(nuemark('# Hello')).toBe('

Hello

') + expect(nuemark('##Hello')).toBe('

Hello

') + expect(nuemark('### Hello, *world*')).toBe('

Hello, world

') +}) + +test('heading ids', () => { + const html = nuemark('# Hello', { heading_ids: true }) + expect(html).toBe('

Hello

') +}) + +test('heading block count', () => { + const blocks = parseBlocks(['# Yo', 'rap', '## Bruh', 'bat', '## Bro']) + expect(blocks.length).toBe(5) +}) + + +test('numbered list', () => { + const [ list ] = parseBlocks(['1. Yo', '10. Bruh', '* Bro']) + expect(list.numbered).toBeTrue() + expect(list.entries).toEqual([[ "Yo" ], [ "Bruh" ], [ "Bro" ]]) +}) + +test('render simple list', () => { + const html = renderLines(['1. ## Hey', ' dude', '2. ## Yo']) + expect(html).toStartWith('
  1. Hey

    \n

    dude

    ') +}) + +test('nested lists', () => { + const html = renderLines(['* ## Hey', ' 1. dude']) + expect(html).toStartWith('
    • Hey

      ') + expect(html).toEndWith('
      1. dude

    ') +}) + +test('render blockquote', () => { + const html = renderLines(['> ## Hey', '> 1. dude']) + expect(html).toStartWith('

    Hey

    \n
    1. dude') +}) + +test('render fenced code', () => { + const html = renderLines(['``` css.pink numbered', 'em {}', '```']) + expect(html).toStartWith('

      ')
      +})
      +
      +test('multi-line list entries', () => {
      +  const [ list ] = parseBlocks(['* foo', '  boy', '* bar'])
      +  expect(list.entries).toEqual([ [ "foo", "boy" ], [ "bar" ] ])
      +})
      +
      +test('list object model', () => {
      +  const [ { items } ] = parseBlocks(['* > foo', '  1. boy', '  2. bar'])
      +  const [ [ quote, nested ] ] = items
      +
      +  expect(quote.is_quote).toBeTrue()
      +  expect(nested.is_list).toBeTrue()
      +})
      +
      +test('blockquote', () => {
      +  const [ quote ] = parseBlocks(['> foo', '> boy'])
      +  expect(quote.is_quote).toBeTrue()
      +  expect(quote.content).toEqual([ "foo", "boy" ])
      +})
      +
      +test('fenced code blocks', () => {
      +  const [ code ] = parseBlocks(['``` css.foo numbered', 'func()', '```'])
      +
      +  expect(code.name).toBe('css')
      +  expect(code.attr).toEqual({ class: 'foo' })
      +  expect(code.code).toEqual([ "func()" ])
      +  expect(code.data.numbered).toBeTrue()
      +})
      +
      +test('tables', () => {
      +  const [ table ] = parseBlocks([
      +    '| Month    | Amount  |',
      +    '| -------- | ------- |',
      +    '| January  | $250    |',
      +    '| February | $80     |',
      +  ])
      +
      +  expect(table.rows[1]).toEqual([ "January", "$250" ])
      +  expect(table.rows.length).toBe(3)
      +  expect(table.head).toBeTrue()
      +
      +  const html = renderTable(table)
      +  expect(html).toStartWith('')
      +  expect(html).toEndWith('
      MonthAmountFebruary$80
      ') +}) + +test('block mix', () => { + const blocks = parseBlocks([ + '#Hello, world!', + '- small', '- list', + 'paragraph', '', + '```', '## code', '```', + '[accordion]', ' multiple: false', + '> blockquote', + ]) + + expect(blocks.length).toBe(7) + expect(blocks[1].entries.length).toBe(2) + expect(blocks[4].code).toEqual([ "## code" ]) + expect(blocks[5].name).toBe('accordion') +}) + +test('parse reflinks', () => { + const { reflinks } = parseBlocks([ + '[.hero]', + ' # Hello, World', + ' [foo]: //website.com', + '[1]: //another.net "something"' + ]) + + expect(reflinks).toEqual({ + 1: '//another.net "something"', + foo: '//website.com', + }) +}) + +test('render reflinks', () => { + const links = { external: 'https://bar.com/zappa "External link"' } + const html = renderLines([ + '[Hey *dude*][local]', + 'Inlined [Second][external]', + '[local]: /blog/something.html "Local link"' + ], { data: { links }}) + + expect(html).toInclude('Hey dude') + expect(html).toInclude('Inlined Second') +}) + +test('nested tag data', () => { + const [ comp ] = parseBlocks(['[hello#foo.bar world size="10"]', ' foo: bar']) + expect(comp.attr).toEqual({ class: "bar", id: "foo", }) + expect(comp.data).toEqual({ world: true, size: 10, foo: "bar", }) +}) + +test('escaping', () => { + const html = renderLines([ + '\\[code]', '', + '\\> blockquote', '', + '\\## title', + ]) + + expect(html).toInclude('

      [code]

      ') + expect(html).toInclude('

      > blockquote

      ') + expect(html).toInclude('

      ## title

      ') +}) + +test('before render callback', () => { + function beforerender(block) { + if (block.is_heading) block.level = 2 + if (block.is_content) block.content = ['World'] + } + + const html = renderLines(['# Hello', 'Bar'], { beforerender }) + expect(html).toBe('

      Hello

      \n

      World

      ') +}) + + + + + diff --git a/packages/nuemark2/test/document.test.js b/packages/nuemark2/test/document.test.js new file mode 100644 index 00000000..29a4016a --- /dev/null +++ b/packages/nuemark2/test/document.test.js @@ -0,0 +1,66 @@ + +import { parseDocument, stripMeta } from '../src/document.js' + + +test('front matter', () => { + const lines = ['---', 'foo: 10', 'bar: 20', '---', '# Hello'] + const meta = stripMeta(lines) + expect(meta).toEqual({ foo: 10, bar: 20 }) + expect(lines).toEqual([ "# Hello" ]) +}) + + +test('empty meta', () => { + const lines = ['# Hello'] + const meta = stripMeta(lines) + expect(meta).toEqual({}) + expect(lines).toEqual([ "# Hello" ]) +}) + + +test('document title', () => { + const doc = parseDocument(['# Hello']) + expect(doc.title).toBe('Hello') +}) + +test('title inside hero', () => { + const doc = parseDocument(['[.hero]', ' # Hello']) + expect(doc.title).toBe('Hello') +}) + +test('description', () => { + const doc = parseDocument(['# Hello', 'This is bruh', '', 'Yo']) + expect(doc.description).toBe('This is bruh') +}) + + +test('sections', () => { + const doc = parseDocument([ + '# Hello', 'World', + '## Foo', 'Bar', + '## Baz', 'Bruh', + ]) + expect(doc.sections.length).toBe(3) + const html = doc.render({ data: { sections: ['hero'] }}) + expect(html).toStartWith('

      Hello

      \n

      World') + expect(html).toEndWith('

      Baz

      \n

      Bruh

      ') +}) + +test('table of contents', () => { + const doc = parseDocument([ + '# Hello', 'World', + '## Foo', 'Bar', + '## Baz', 'Bruh', + ]) + + const toc = doc.renderTOC() + expect(toc).toStartWith('
      ') + expect(toc).toInclude('') + expect(toc).toInclude('') +}) + + +test('render doc', () => { + const doc = parseDocument(['# Hello']) + expect(doc.render()).toBe('

      Hello

      ') +}) diff --git a/packages/nuemark2/test/inline.test.js b/packages/nuemark2/test/inline.test.js new file mode 100644 index 00000000..38f39fe0 --- /dev/null +++ b/packages/nuemark2/test/inline.test.js @@ -0,0 +1,156 @@ + +import { renderTokens, renderToken, renderInline } from '../src/render-inline.js' +import { parseInline, parseLink } from '../src//parse-inline.js' + + +test('plain text', () => { + const tests = [ + 'Hello, World!', + 'Unclosed "quote', + 'Unclosed ****format', + 'Unopened italics__ too', + 'Mega # weir%$ ¶{}€ C! char *s', + 'A very long string \n with odd \t spacing', + ] + + for (const test of tests) { + const [{ text }] = parseInline(test) + expect(test).toBe(text) + } +}) + + +test('formatting', () => { + const tests = [ + ['_', '*yo*', 'em'], + ['*', 'yo 90', 'em'], + ['__', 'Ö#(/&', 'strong'], + ['**', 'go _ open', 'strong'], + ['~', 'striked', 's'], + ['/', 'italic', 'i'], + ['•', 'bold', 'b'], + ] + + for (test of tests) { + const [ chars, body, tag ] = test + const ret = parseInline(`A ${chars + body + chars} here`) + expect(ret[1].tag).toBe(tag) + expect(ret[1].body).toBe(body) + expect(ret.length).toBe(3) + } +}) + +test('inline render basics', () => { + const tests = [ + { text: 'hey', html: 'hey' }, + { is_format: true, tag: 'b', body: 'hey', html: 'hey' }, + { is_format: true, tag: 'b', body: '*hey*', html: 'hey' }, + { href: '/', label: 'hey', html: 'hey' }, + { href: '/', label: '*hey*', html: 'hey' }, + { href: '/', label: 'hey', title: 'yo', html: 'hey' }, + ] + + for (test of tests) { + const html = renderToken(test) + expect(html).toBe(test.html) + } +}) + + +test('parse simple link', () => { + const [text, link] = parseInline('Goto [label](/url/)') + expect(link.label).toBe('label') + expect(link.href).toBe('/url/') +}) + +// image +test('render image', () => { + const html = renderInline('![foo](/bar.png) post') + expect(html).toBe('foo post') +}) + +test('inline code', () => { + const html = renderInline('Hey `[zoo] *boo*`') + expect(html).toBe('Hey [zoo] *boo*') +}) + +test('escaping', () => { + expect(renderInline('Hey \\*bold* dude')).toBe('Hey *bold* dude') + expect(renderInline('Hey \\{ var }')).toBe('Hey { var }') + expect(renderInline('Hey \\[tag]')).toBe('Hey [tag]') +}) + + +test('parse link', () => { + const link = parseLink('[Hello](/world "today")') + expect(link).toMatchObject({ href: '/world', title: 'today', label: 'Hello' }) +}) + +test('parse reflink', () => { + const link = parseLink('[Hello][world "now"]', true) + expect(link).toMatchObject({ href: 'world', title: 'now', label: 'Hello' }) +}) + +test('parse reflink', () => { + const [text, link] = parseInline('Baz [foo][bar]') + expect(link.label).toBe('foo') + expect(link.href).toBe('bar') +}) + +test('parse complex link', () => { + const [text, link, rest] = parseInline('Goto [label](/url/(master)) plan') + expect(link.href).toBe('/url/(master)') +}) + + +test('render reflinks', () => { + const foo = { href: '/', title: 'Bruh' } + const html = renderToken({ href: 'foo', label: 'Foobar' }, { reflinks: { foo } }) + expect(html).toBe('Foobar') +}) + + +test('parse subject link', () => { + const [text, link] = parseInline('Goto [label](/url/ "the subject")') + expect(link.title).toBe('the subject') + expect(link.label).toBe('label') + expect(link.href).toBe('/url/') +}) + +test('parse simple image', () => { + const [text, img] = parseInline('Image ![](yo.svg)') + expect(img.is_image).toBeTrue() + expect(img.href).toBe('yo.svg') +}) + + +// parse tags and args +test('inline tag', () => { + const [el] = parseInline('[version]') + expect(el.name).toBe('version') +}) + +test('tag args', () => { + const [ text, comp, rest] = parseInline('Hey [print foo] thing') + expect(comp.name).toBe('print') + expect(comp.data.foo).toBeTrue() +}) + +test('{ variables }', () => { + const tokens = parseInline('v{ version } ({ date })') + expect(tokens.length).toBe(5) + expect(tokens[1]).toEqual({ is_var: true, name: "version" }) + + const data = { version: '1.0.1', date: '2025-01-01' } + const text = renderTokens(tokens, { data }) + expect(text).toBe('v1.0.1 (2025-01-01)') +}) + +test('{ #foo.bar }', () => { + const tokens = parseInline('Hey { #foo.bar }') + expect(tokens[1].attr).toEqual({ class: "bar", id: "foo" }) + + const text = renderTokens(tokens) + expect(text.trim()).toEqual('Hey') +}) + diff --git a/packages/nuemark2/test/performance.js b/packages/nuemark2/test/performance.js new file mode 100644 index 00000000..4d1be6c6 --- /dev/null +++ b/packages/nuemark2/test/performance.js @@ -0,0 +1,102 @@ + +import { marked } from 'marked' +import { markedSmartypants } from "marked-smartypants"; +import hljs from 'highlight.js'; + + +import { render } from '..' + +// Not working +// import { markedHighlight } from "marked-highlight"; +// import customHeadingId from "marked-custom-heading-id"; + +const renderer = { + code(code, language) { + return hljs.highlight(code, { language }).value; + }, +} + + + +const SIMPLE = ` +# Hello, World +This is a "paragraph" + +- first +- second + +> First +> Second +> [Third][bar] + +Paragraph here +And another + +Stop. + +[bar]: /something "subject" +` + +const COMPLEX = ` +# Hello, World { #hello } +This is a paragraph *with* __formatting__ + +- here is a __[link](//github.io/demo/)__ +- list of the [foo][bar] +- future + +___ + +> "Blockquote" +> Coming here + +| this | is | table | +| ---- | ---- | ---- | +| foor | baar | bazz | + +\`\`\`javascript +export function parseAttr(str) { + const attr = {} + + // id + str.replace(/#([\w\-]+)/, (_, el) => attr.id = el) + + return attr +} + +\`\`\` + +[bar]: /something "subject" + +` + + +function perftest(name, fn) { + console.time(name) + // for (let i = 0; i < 1000; i++) fn(SIMPLE) + for (let i = 0; i < 1000; i++) fn(COMPLEX) + console.timeEnd(name) +} + +// marked.use(extendedTables()) +// marked.use(markedSmartypants()) +marked.use({ renderer }) + + +// marked.use(customHeadingId()); + +if (false) { + // perftest('marked', marked.parse) + perftest('marked', marked.parse) + perftest('nue', render) + // perftest('nue', render) + +} else { + // console.info(marked.parse(COMPLEX)) + console.info('------------------') + console.info(render(SIMPLE)) +} + + + + diff --git a/packages/nuemark2/test/tag.test.js b/packages/nuemark2/test/tag.test.js new file mode 100644 index 00000000..d0b9d527 --- /dev/null +++ b/packages/nuemark2/test/tag.test.js @@ -0,0 +1,175 @@ + +import { parseTag, valueGetter, parseAttr, parseSpecs } from '../src/parse-tag.js' +import { renderLines, elem } from '..' + + +// parsing +test('valueGetter', () => { + const { str, getValue } = valueGetter(`foo="yo" bar="hey dude"`) + expect(str).toBe('foo=:1: bar=:2:') + expect(getValue(':1:')).toBe('yo') + expect(getValue(':2:')).toBe('hey dude') +}) + +test('parseAttr', () => { + expect(parseAttr('.bar#foo')).toEqual({ id: 'foo', class: 'bar' }) + expect(parseAttr('.bar#foo.baz')).toEqual({ id: 'foo', class: 'bar baz' }) +}) + +test('parseSpecs', () => { + expect(parseSpecs('tabs')).toEqual({ name: 'tabs', attr: {} }) + expect(parseSpecs('tabs.#foo.bar')).toEqual({ name: 'tabs', attr: { id: 'foo', class: 'bar' } }) +}) + +test('parse plain args', () => { + const { name, data }= parseTag('video src="/a.mp4" loop muted') + expect(name).toBe('video') + expect(data.loop).toBe(true) + expect(data.muted).toBe(true) +}) + +test('parse attrs', () => { + expect(parseTag('#foo.bar').attr).toEqual({ id: "foo", class: "bar" }) + expect(parseTag('list.tweets').attr).toEqual({ class: "tweets" }) +}) + +test('parse all', () => { + const { name, attr, data } = parseTag('tip#foo.bar "Hey there" size="40" grayed hidden') + expect(data).toEqual({ _: "Hey there", size: 40, grayed: true }) + expect(attr).toEqual({ class: "bar", id: "foo", hidden: true }) + expect(name).toBe('tip') +}) + + +// custom tags +const tags = { + print({ data }) { + return `${ data?.value }` + }, +} + +test('inline tag', () => { + const html = renderLines(['Value: [print value="110"]'], { tags }) + expect(html).toBe('

      Value: 110

      ') + +}) + +test('block tag', () => { + const html = renderLines(['[print]', ' value: 110'], { tags }) + expect(html).toBe('110') +}) + +// accordion +test('[accordion] tag', () => { + const content = [ + '[accordion.tabbed name="tabs"]', + ' ## Something', + ' Described here', + + ' ## Another', + ' Described here', + ] + + const html = renderLines(content) + expect(html).toStartWith('

      ') + expect(html).toInclude('

      Another

      ') +}) + +// list sections +test('[list] sections', () => { + const content = [ + '[list.features items="card"]', + ' ## Something', + ' Described here', + + ' ## Another', + ' Described here', + ] + + const html = renderLines(content) + expect(html).toStartWith('

      • ') +}) + +// list items +test('[list] items', () => { + const content = ['[list]', ' * foo', ' * bar' ] + const html = renderLines(content) + expect(html).toStartWith('
        • foo

        • ') + expect(html).toEndWith('
        • bar

        ') +}) + + +// list code blocks +test('[list] blocks', () => { + const content = ['[list]', ' ``` .foo', ' ```', ' ``` .bar', ' ```'] + const html = renderLines(content) + expect(html).toStartWith('
        • ') + expect(html).toInclude('
        • ') +}) + +// list code blocks +test('[list] wrapper', () => { + const html = renderLines(['[list wrapper="pink"]', ' para']) + expect(html).toStartWith('
          • para

            ') +}) + + +test('anonymous tags', () => { + const html = renderLines(['[.hello]', ' ## Hello', ' world']) + expect(html).toBe('

            Hello

            \n

            world

            ') +}) + + +test('[table] tag', () => { + const foo = [ ['Foo', 'Buzz'], ['hey', 'girl']] + const opts = { data: { foo } } + + const html = renderLines(['[table :rows="foo"]'], opts) + expect(html).toStartWith('') + + const html2 = renderLines(['[table :rows="foo" head=false]'], opts) + expect(html2).toStartWith('
            FooBuzz
            ') + + // table wrapper + const html3 = renderLines(['[table wrapper="pink" :rows="foo"]'], opts) + expect(html3).toStartWith('
            FooBuzz
            ') + +}) + +test('[button] inline label', () => { + const html = renderLines(['[button href="/" "Hey, *world*"]']) + expect(html).toBe('Hey, world') +}) + +test('[button] nested label', () => { + const html = renderLines(['[button]', ' ![](/joku.png)']) + expect(html).toStartWith(' { + const html = renderLines(['[image /meow.png]']) + expect(html).toBe('
            ') +}) + +test('picture', () => { + const html = renderLines([ + '[image caption="Hello"]', + ' href: /', + ' small: small.png', + ' large: large.png', + ]) + + expect(html).toStartWith('
            Hello
            ') +}) + +test('[video] tag', () => { + const html = renderLines(['[video /meow.mp4 autoplay]', ' ### Hey']) + expect(html).toStartWith('') +}) + +test('! shortcut', () => { + const html = renderLines(['[! /meow.mp4 autoplay]']) + expect(html).toStartWith('Hey') - await write('index.md', '[foo]') +test('basig page generation', async () => { + await write('index.md', '# Hello\nWorld') + const kit = await getKit() + + // page data + const data = await kit.getPageData('index.md') + expect(data.title).toBe('Hello') + expect(data.description).toBe('World') + + // generated HTML + const html = await kit.gen('index.md') + expect(html).toInclude('

            Hello

            ') + expect(html).toInclude('

            World

            ') +}) + + +test('custom nuemark tags', async () => { + await write('layout.html', 'Hey') + await write('index.md', '[test]') const kit = await getKit() const html = await kit.gen('index.md') expect(html).toInclude('Hey') }) + test('layout components', async () => { const site = await getSite() @@ -265,13 +279,6 @@ test('inline CSS', async () => { expect(html).toInclude('margin:') }) -test('page data', async () => { - const kit = await getKit() - await write('index.md', createFront('Hello') + '\n\nWorld') - const data = await kit.getPageData('index.md') - expect(data.title).toBe('Hello') - expect(data.page.meta.title).toBe('Hello') -}) test('line endings', async () => { const kit = await getKit() @@ -311,17 +318,6 @@ test('single-page app index', async () => { expect(html).toInclude('is="test"') }) -test('index.md', async () => { - await write('index.md', '# Hey { .yo }\n\n## Foo { .foo#bar.baz }') - const kit = await getKit() - await kit.gen('index.md') - const html = await readDist(kit.dist, 'index.html') - expect(html).toInclude('hotreload.js') - expect(html).toInclude('Hey') - expect(html).toInclude('

            Hey

            ') - expect(html).toInclude('

            Foo

            ') -}) - test('bundle', async () => { await write('a.ts', 'export const foo = 30') diff --git a/packages/nuemark2/package.json b/packages/nuemark2/package.json new file mode 100644 index 00000000..f926283b --- /dev/null +++ b/packages/nuemark2/package.json @@ -0,0 +1,27 @@ +{ + "name": "nuemark2", + "version": "2.0.0-beta", + "description": "Markdown parser for UX developers", + "homepage": "https://nuejs.org", + "license": "MIT", + "type": "module", + "main": "index.js", + "repository": { + "url": "https://github.com/nuejs/nue", + "directory": "packages/nuemark2", + "type": "git" + }, + "scripts": { + "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand" + }, + "dependencies": { + "js-yaml": "^4.1.0", + "nue-glow": "*" + }, + "jest": { + "setupFilesAfterEnv": [ + "jest-extended/all", + "/../../setup-jest.js" + ] + } +} diff --git a/packages/nuemark2/src/document.js b/packages/nuemark2/src/document.js index 4b3373d5..3f080e5d 100644 --- a/packages/nuemark2/src/document.js +++ b/packages/nuemark2/src/document.js @@ -5,28 +5,34 @@ import { parseBlocks } from './parse-blocks.js' import { load as parseYAML } from 'js-yaml' export function parseDocument(lines) { - const meta = stripMeta(lines) + const user_meta = stripMeta(lines) const blocks = parseBlocks(lines) const { reflinks } = blocks - const self = { + const meta = { - // title get title() { const tag = blocks.find(el => el.is_tag) return findTitle(blocks) || tag && findTitle(tag.blocks) || '' }, - // description get description() { const block = blocks.find(el => el.is_content) return block?.content[0] }, + ...user_meta + } + + const api = { + get sections() { return blocks && categorize(blocks) }, + get codeblocks() { + return blocks.filter(el => el.is_code) + }, addReflink(label, href) { reflinks.push({ label, ...parseLinkTitle(href) }) @@ -35,26 +41,25 @@ export function parseDocument(lines) { renderSections(classes, opts) { const html = [] - self.sections.forEach((blocks, i) => { + api.sections.forEach((blocks, i) => { html.push(elem('section', { class: classes[i] }, renderBlocks(blocks, opts))) }) - return html.join('\n') + return html.join('\n\n') }, - renderTOC() { - const navs = self.sections.map(renderNav).join('\n').trim() - return elem('div', { class: 'toc' }, navs) + renderTOC(attr={}) { + const navs = api.sections.map(renderNav).join('\n').trim() + return elem('div', { 'aria-label': 'Table of contents', ...attr }, navs) }, render(opts={}) { let { sections } = opts.data || {} if (sections && !Array.isArray(sections)) sections = [] - return sections ? self.renderSections(sections, opts) : renderBlocks(blocks, opts) + return sections ? api.renderSections(sections, opts) : renderBlocks(blocks, opts) }, - } - return { meta, reflinks, ...self } + return { meta, reflinks, ...api } } @@ -62,9 +67,15 @@ export function categorize(blocks=[], max_level=2) { const arr = [] let section - blocks.forEach(el => { - if (el.level <= max_level) arr.push(section = []) - section?.push(el) + // no sections if no separators + const el = blocks.find(el => el.is_separator || el.level <= max_level) + if (!el) return + + blocks.forEach((el, i) => { + const sep = el.is_separator + if (!section || el.level <= max_level || sep) arr.push(section = []) + if (!sep) section.push(el) + }) return arr[0] && arr } diff --git a/packages/nuemark2/src/parse-blocks.js b/packages/nuemark2/src/parse-blocks.js index a41ef1c8..0410f19b 100644 --- a/packages/nuemark2/src/parse-blocks.js +++ b/packages/nuemark2/src/parse-blocks.js @@ -13,16 +13,16 @@ export function parseBlocks(lines) { lines.forEach(line => { const c = line[0] const trimmed = line.trim() - const indent = line.length - line.trimStart().length + const indent = trimmed && line.length - line.trimStart().length if (!spaces) spaces = indent // fenced code if (c == '`' && line.startsWith('```')) { // new code block - if (!block?.code) { + if (!block?.is_code) { const specs = line.slice(line.lastIndexOf('`') + 1).trim() - block = { ...parseTag(specs), code: [] } + block = { is_code: true, ...parseTag(specs), code: [] } return blocks.push(block) // end of code @@ -32,18 +32,32 @@ export function parseBlocks(lines) { } // fenced code lines first - if (block?.code) return block.code.push(line) + if (block?.is_code) return block.code.push(line) // skip HTML and line comments if (c == '<' || trimmed.startsWith('//')) return + // empty line + if (!trimmed) { + if (!block) return + if (block.is_tag) return block.body.push(line) + if (block.is_list) return addListEntry(block, line) + if (block.is_content) return block = null + } - // list - const list = getListStart(line) - if (list) { - const { line, numbered } = list + // heading (must be before the last two if clauses) + if (c == '#') { + blocks.push(parseHeading(line)) + return block = null + } + + // list item + const item = getListItem(line) + + if (item) { + const { line, numbered } = item // new list if (!block?.is_list) { @@ -81,8 +95,6 @@ export function parseBlocks(lines) { return blocks.push(block) } - - // table if (c == '|' && trimmed.endsWith('|')) { @@ -97,53 +109,49 @@ export function parseBlocks(lines) { block.head = true : block.rows.push(row) } - // thematic break (HR) - if (isHR(line)) return blocks.push({ is_hr: true }) - - + // thematic break + const hr = getBreak(line) + if (hr) { + blocks.push(hr) + return block = null + } + // nested content or data if (indent) { - const { body, entries } = block line = line.slice(spaces) - if (body) { - body.push(line) - - } else { - const last = entries[entries.length -1] - last.push(line) - } - - // heading (must be before the last two if clauses) - } else if (c == '#') { - blocks.push(parseHeading(line)) - block = null + if (block?.is_tag) block.body.push(line) + else if (block?.is_list) addListEntry(block, line) - } else if (!trimmed) { - blocks.push({ is_newline: true }) - block = null + // blockquotes + } else if (block?.is_quote && c) { + if (c) block.content.push(line) - } else if (block?.content) { + // content (append) + } else if (block?.is_content) { block.content.push(line) - // content / paragraphs + // new content block } else { - block = { is_content: true, content: [line] } + block = c ? { is_content: true, content: [line] } : { is_newline: true } blocks.push(block) } }) /* tokenize lists and quotes. parse component data */ - blocks.forEach(postProcess) + blocks.forEach(processNestedBlocks) return blocks } -/*** utils ***/ -function postProcess(block) { + +/******* UTILS ********/ + +// recursive processing of nested blocks +function processNestedBlocks(block) { if (block.is_list) { block.items = block.entries.map(parseBlocks) @@ -155,7 +163,7 @@ function postProcess(block) { const body = block.body.join('\n') try { - if (body && block.name != '.' && isYAML(body)) { + if (body && block.name != '.' && isYAML(body.trim())) { Object.assign(block.data, parseYAML(body)) block.has_data = true } @@ -194,16 +202,19 @@ function parseReflink(str) { } } -function getListStart(line) { +function getListItem(line) { if (line[1] == ' ' && '-*'.includes(line[0])) return { line: line.slice(1).trim() } const num = /^\d+\. /.exec(line) if (num) return { line: line.slice(num[0].length).trim(), numbered: true } } -export function isHR(str) { - const HR = ['***', '___', '- - -'] +export function getBreak(str) { + const HR = ['---', '***', '___', '- - -'] + for (const hr of HR) { - if (str.startsWith(hr) && !/[^\*\-\_ ]/.test(str)) return true + if (str.startsWith(hr) && !/[^\*\-\_ ]/.test(str)) { + return { is_break: true, is_separator: hr == '---' } + } } } @@ -217,3 +228,18 @@ function parseTableRow(line) { return line.slice(1, -2).split('|').map(el => el.trim()) } +function addListEntry({ entries }, line) { + const last = entries[entries.length - 1] + last.push(line) +} + + +/* get next empty line +function getNext(lines, i) { + while (lines[i]) { + const line = lines[i] + if (line && line.trim()) return line + i++ + } +} +*/ diff --git a/packages/nuemark2/src/render-blocks.js b/packages/nuemark2/src/render-blocks.js index 67a32aa2..3bed3f4d 100644 --- a/packages/nuemark2/src/render-blocks.js +++ b/packages/nuemark2/src/render-blocks.js @@ -20,15 +20,16 @@ function renderBlock(block, opts) { const fn = opts?.beforerender if (fn) fn(block) + return block.is_content ? renderContent(block.content, opts) : block.is_heading ? renderHeading(block, opts) : block.is_quote ? elem('blockquote', renderBlocks(block.blocks, opts)) : block.is_tag ? renderTag(block, opts) : block.is_table ? renderTable(block, opts) : block.is_list ? renderList(block, opts) : - block.code ? renderCode(block) : + block.is_code ? renderCode(block) : block.is_newline ? '' : - block.is_hr ? '
            ' : + block.is_break ? '
            ' : console.error('Unknown block', block) } @@ -55,7 +56,7 @@ export function renderHeading(h, opts={}) { } export function renderContent(lines, opts) { - const html = lines.map(line => renderInline(line, opts)).join('\n') + const html = lines.map(line => renderInline(line, opts)).join(' ') return elem('p', html) } diff --git a/packages/nuemark2/src/render-tag.js b/packages/nuemark2/src/render-tag.js index 6a93ef54..5c220a14 100644 --- a/packages/nuemark2/src/render-tag.js +++ b/packages/nuemark2/src/render-tag.js @@ -49,8 +49,6 @@ const TAGS = { list() { const items = this.sections || getListItems(this.blocks) - if (!items) return '' - const item_attr = { class: this.data.items } const html = items.map(blocks => elem('li', item_attr, this.render(blocks))) const ul = elem('ul', this.attr, html.join('\n')) @@ -91,30 +89,35 @@ const TAGS = { export function renderTag(tag, opts={}) { const tags = opts.tags = { ...TAGS, ...opts?.tags } - const { name, attr } = tag - const { data={} } = opts + const { name, attr, blocks } = tag const fn = tags[name] // anonymous tag - if (!name) return elem('div', attr, renderBlocks(tag.blocks, opts)) + if (!name) { + const cats = categorize(blocks) + + const inner = !cats || !cats[1] ? renderBlocks(blocks, opts) : + cats.map(blocks => elem('div', renderBlocks(blocks, opts))).join('\n') - if (!fn) { - console.error(`No "${name}" tag found from: [${Object.keys(tags)}]`) - return '' + return elem('div', attr, inner) } + if (!fn) return renderIsland(tag) + + const data = extractColonVars({ ...opts.data, ...tag.data }) + const api = { ...tag, - data: extractColonVars({ ...data, ...tag.data }), // place after ..tag get innerHTML() { return getInnerHTML(this.blocks, opts) }, render(blocks) { return renderBlocks(blocks, opts) }, renderInline(str) { return renderInline(str, opts) }, sections: categorize(tag.blocks), + data, opts, tags, } - return fn.call(api, api) + return fn.call(api, data) } @@ -191,3 +194,10 @@ function getInnerHTML(blocks, opts) { return content && !second ? renderInline(content.join(' '), opts) : renderBlocks(blocks, opts) } +export function renderIsland({ name, attr, data }) { + const json = !Object.keys(data)[0] ? '' : + elem('script', { type: 'application/json' }, JSON.stringify(data)) + ; + return elem(name, { 'custom': true, ...attr }, json) +} + diff --git a/packages/nuemark2/test/block.test.js b/packages/nuemark2/test/block.test.js index f449b996..890a57a2 100644 --- a/packages/nuemark2/test/block.test.js +++ b/packages/nuemark2/test/block.test.js @@ -1,18 +1,78 @@ -import { renderTable, renderHeading, renderLines } from '../src/render-blocks.js' -import { parseBlocks, isHR, parseHeading } from '../src/parse-blocks.js' +import { renderBlocks, renderTable, renderHeading, renderLines } from '../src/render-blocks.js' +import { parseBlocks, getBreak, parseHeading } from '../src/parse-blocks.js' import { nuemark } from '..' -test('parse hr', () => { +test('paragraphs', () => { + const blocks = parseBlocks([ 'a', 'b', '', '', 'c' ]) + expect(blocks.length).toBe(2) + + const html = renderBlocks(blocks) + expect(html).toStartWith('

            a b

            ') + expect(html).toEndWith('

            c

            ') +}) + +test('list items', () => { + const blocks = parseBlocks(['- a', '', ' a1', '- b', '', '', '- c']) + expect(blocks.length).toBe(1) + expect(blocks[0].entries).toEqual([[ "a", "", "a1" ], [ "b", "", "" ], [ "c" ]]) +}) + + +test('nested lists', () => { + const blocks = parseBlocks(['- item', '', ' - nested 1', '', '', ' - nested 2']) + expect(blocks.length).toBe(1) + expect(blocks[0].entries[0]).toEqual([ "item", "", "- nested 1", "", "", "- nested 2" ]) + const html = renderBlocks(blocks) + expect(html).toEndWith('
          • nested 2

          • ') +}) + + +test('nested tag data', () => { + const [ comp ] = parseBlocks(['[hello]', '', '', ' foo: bar', '', ' bro: 10']) + expect(comp.data).toEqual({ foo: "bar", bro: 10 }) +}) + +test('nested tag content', () => { + const blocks = parseBlocks(['[.stack]', '', '', ' line1', '', ' line2']) + expect(blocks.length).toBe(1) + expect(blocks[0].blocks.length).toBe(2) + + const html = renderBlocks(blocks) + expect(html).toStartWith('

            line1

            ') +}) + +test('subsequent blockquotes', () => { + const blocks = parseBlocks(['> hey', '> boy', '', '> another']) + expect(blocks.length).toBe(3) + const html = renderBlocks(blocks) + expect(html).toStartWith('

            hey boy

            ') +}) + + +test('numbered items', () => { + const [ list ] = parseBlocks(['1. Yo', '10. Bruh', '* Bro']) + expect(list.numbered).toBeTrue() + expect(list.entries).toEqual([[ "Yo" ], [ "Bruh" ], [ "Bro" ]]) +}) + + +test('multiple thematic breaks', () => { + const blocks = parseBlocks(['A', '---', 'B', '---', 'C' ]) + expect(blocks.length).toBe(5) +}) + + +test('parse thematic break', () => { const hrs = ['***', '___', '- - -', '*** --- ***'] for (const str of hrs) { - expect(isHR(str)).toBe(true) + expect(getBreak(str)).toBeDefined() } - expect(isHR('*** yo')).toBeUndefined() + expect(getBreak('*** yo')).toBeUndefined() }) -test('render HR', () => { +test('render thematic break', () => { expect(renderLines(['hello', '***'])).toBe('

            hello

            \n
            ') }) @@ -53,23 +113,6 @@ test('heading block count', () => { }) -test('numbered list', () => { - const [ list ] = parseBlocks(['1. Yo', '10. Bruh', '* Bro']) - expect(list.numbered).toBeTrue() - expect(list.entries).toEqual([[ "Yo" ], [ "Bruh" ], [ "Bro" ]]) -}) - -test('render simple list', () => { - const html = renderLines(['1. ## Hey', ' dude', '2. ## Yo']) - expect(html).toStartWith('
            1. Hey

              \n

              dude

              ') -}) - -test('nested lists', () => { - const html = renderLines(['* ## Hey', ' 1. dude']) - expect(html).toStartWith('
              • Hey

                ') - expect(html).toEndWith('
                1. dude

              ') -}) - test('render blockquote', () => { const html = renderLines(['> ## Hey', '> 1. dude']) expect(html).toStartWith('

              Hey

              \n
              1. dude') @@ -85,7 +128,7 @@ test('multi-line list entries', () => { expect(list.entries).toEqual([ [ "foo", "boy" ], [ "bar" ] ]) }) -test('list object model', () => { +test('nested list', () => { const [ { items } ] = parseBlocks(['* > foo', ' 1. boy', ' 2. bar']) const [ [ quote, nested ] ] = items @@ -125,22 +168,6 @@ test('tables', () => { expect(html).toEndWith('

            February$80
            ') }) -test('block mix', () => { - const blocks = parseBlocks([ - '#Hello, world!', - '- small', '- list', - 'paragraph', '', - '```', '## code', '```', - '[accordion]', ' multiple: false', - '> blockquote', - ]) - - expect(blocks.length).toBe(7) - expect(blocks[1].entries.length).toBe(2) - expect(blocks[4].code).toEqual([ "## code" ]) - expect(blocks[5].name).toBe('accordion') -}) - test('parse reflinks', () => { const { reflinks } = parseBlocks([ '[.hero]', @@ -167,7 +194,7 @@ test('render reflinks', () => { expect(html).toInclude('Inlined Second') }) -test('nested tag data', () => { +test('complex tag data', () => { const [ comp ] = parseBlocks(['[hello#foo.bar world size="10"]', ' foo: bar']) expect(comp.attr).toEqual({ class: "bar", id: "foo", }) expect(comp.data).toEqual({ world: true, size: 10, foo: "bar", }) diff --git a/packages/nuemark2/test/document.test.js b/packages/nuemark2/test/document.test.js index 29a4016a..54041121 100644 --- a/packages/nuemark2/test/document.test.js +++ b/packages/nuemark2/test/document.test.js @@ -1,7 +1,6 @@ import { parseDocument, stripMeta } from '../src/document.js' - test('front matter', () => { const lines = ['---', 'foo: 10', 'bar: 20', '---', '# Hello'] const meta = stripMeta(lines) @@ -19,18 +18,24 @@ test('empty meta', () => { test('document title', () => { - const doc = parseDocument(['# Hello']) - expect(doc.title).toBe('Hello') + const { meta } = parseDocument(['# Hello']) + expect(meta.title).toBe('Hello') }) test('title inside hero', () => { - const doc = parseDocument(['[.hero]', ' # Hello']) - expect(doc.title).toBe('Hello') + const { meta } = parseDocument(['[.hero]', ' # Hello']) + expect(meta.title).toBe('Hello') }) test('description', () => { - const doc = parseDocument(['# Hello', 'This is bruh', '', 'Yo']) - expect(doc.description).toBe('This is bruh') + const { meta } = parseDocument(['# Hello', 'This is bruh', '', 'Yo']) + expect(meta.description).toBe('This is bruh') +}) + + +test('render method', () => { + const doc = parseDocument(['# Hello']) + expect(doc.render()).toBe('

            Hello

            ') }) @@ -38,12 +43,13 @@ test('sections', () => { const doc = parseDocument([ '# Hello', 'World', '## Foo', 'Bar', - '## Baz', 'Bruh', + '---', 'Bruh', '***', ]) + expect(doc.sections.length).toBe(3) const html = doc.render({ data: { sections: ['hero'] }}) - expect(html).toStartWith('

            Hello

            \n

            World') - expect(html).toEndWith('

            Baz

            \n

            Bruh

            ') + expect(html).toStartWith('

            Hello

            ') + expect(html).toEndWith('
            ') }) test('table of contents', () => { @@ -54,13 +60,9 @@ test('table of contents', () => { ]) const toc = doc.renderTOC() - expect(toc).toStartWith('
            ') + expect(toc).toStartWith('
            ') expect(toc).toInclude('') expect(toc).toInclude('') }) -test('render doc', () => { - const doc = parseDocument(['# Hello']) - expect(doc.render()).toBe('

            Hello

            ') -}) diff --git a/packages/nuemark2/test/performance.js b/packages/nuemark2/test/performance.js index 4d1be6c6..45d96d1b 100644 --- a/packages/nuemark2/test/performance.js +++ b/packages/nuemark2/test/performance.js @@ -4,7 +4,7 @@ import { markedSmartypants } from "marked-smartypants"; import hljs from 'highlight.js'; -import { render } from '..' +import { nuemark } from '..' // Not working // import { markedHighlight } from "marked-highlight"; @@ -16,6 +16,11 @@ const renderer = { }, } +const UNIT = ` +> Block 1 + +> Block 2 +` const SIMPLE = ` @@ -88,13 +93,13 @@ marked.use({ renderer }) if (false) { // perftest('marked', marked.parse) perftest('marked', marked.parse) - perftest('nue', render) - // perftest('nue', render) + perftest('nue', nuemark) + // perftest('nue', nuemark) } else { - // console.info(marked.parse(COMPLEX)) + console.info(marked.parse(UNIT)) console.info('------------------') - console.info(render(SIMPLE)) + console.info(nuemark(UNIT)) } diff --git a/packages/nuemark2/test/tag.test.js b/packages/nuemark2/test/tag.test.js index 7f4a6150..b3462c41 100644 --- a/packages/nuemark2/test/tag.test.js +++ b/packages/nuemark2/test/tag.test.js @@ -43,7 +43,7 @@ test('parse all', () => { // custom tags const tags = { - print({ data }) { + print(data) { return `${ data?.value }` }, } @@ -87,7 +87,7 @@ test('[list] sections', () => { ] const html = renderLines(content) - expect(html).toStartWith('
            • ') + expect(html).toStartWith('
              • Something

                \n

                Described') }) // list items @@ -114,9 +114,35 @@ test('[list] wrapper', () => { }) -test('anonymous tags', () => { - const html = renderLines(['[.hello]', ' ## Hello', ' world']) - expect(html).toBe('

                Hello

                \n

                world

                ') +// anonymous tag +test('.note', () => { + const html = renderLines(['[.note]', ' ## Note', ' Hello']) + expect(html).toBe('

                Note

                \n

                Hello

                ') +}) + +// anonymous .stack +test('.stack', () => { + const html = renderLines(['[.stack]', ' Hey', ' ---', ' Girl']) + expect(html).toStartWith('

                Hey

                ') +}) + +test('client-side island', () => { + const html = renderLines(['[contact-me]', ' cta: Submit']) + expect(html).toStartWith(' { + const html = renderLines([ + '[.stack]', + ' # Contact me', + ' Available for hire', + ' [contact-me]', + '', + ' ---', + ' [! /img/profile.jpg size=""460 x 460"]' + ]) + + console.info(html) }) From 792bfa15e2b2ea7a7e83f6b61e7e7c37d1b58994 Mon Sep 17 00:00:00 2001 From: Tero Piirainen Date: Thu, 26 Sep 2024 15:30:53 +0300 Subject: [PATCH 005/103] Nuemark 2: Fix reflinks --- packages/nuemark2/src/parse-blocks.js | 52 ++++++++++++--------------- packages/nuemark2/src/parse-inline.js | 17 ++++++--- packages/nuemark2/test/inline.test.js | 7 ++++ packages/nuemark2/test/performance.js | 9 ++--- packages/nuemark2/test/tag.test.js | 14 -------- 5 files changed, 47 insertions(+), 52 deletions(-) diff --git a/packages/nuemark2/src/parse-blocks.js b/packages/nuemark2/src/parse-blocks.js index 0410f19b..b0e55e97 100644 --- a/packages/nuemark2/src/parse-blocks.js +++ b/packages/nuemark2/src/parse-blocks.js @@ -3,12 +3,9 @@ import { load as parseYAML } from 'js-yaml' import { parseInline } from './parse-inline.js' import { parseTag } from './parse-tag.js' -export function parseBlocks(lines) { +export function parseBlocks(lines, reflinks={}) { let spaces, block - const blocks = [] - blocks.reflinks = {} - lines.forEach(line => { const c = line[0] @@ -53,6 +50,15 @@ export function parseBlocks(lines) { return block = null } + + // thematic break (before list) + const hr = getBreak(line) + if (hr) { + blocks.push(hr) + return block = null + } + + // list item const item = getListItem(line) @@ -85,7 +91,7 @@ export function parseBlocks(lines) { // reflink (can be nested on any level) const ref = parseReflink(trimmed) - if (ref) return blocks.reflinks[ref.key] = ref.link + if (ref) return reflinks[ref.key] = ref.link // tag @@ -109,12 +115,6 @@ export function parseBlocks(lines) { block.head = true : block.rows.push(row) } - // thematic break - const hr = getBreak(line) - if (hr) { - blocks.push(hr) - return block = null - } // nested content or data if (indent) { @@ -139,24 +139,23 @@ export function parseBlocks(lines) { }) + /* tokenize lists and quotes. parse component data */ - blocks.forEach(processNestedBlocks) + blocks.forEach(block => processNestedBlocks(block, reflinks)) + blocks.reflinks = reflinks return blocks } - -/******* UTILS ********/ - // recursive processing of nested blocks -function processNestedBlocks(block) { +function processNestedBlocks(block, reflinks) { if (block.is_list) { - block.items = block.entries.map(parseBlocks) + block.items = block.entries.map(blocks => parseBlocks(blocks, reflinks)) } else if (block.is_quote) { - block.blocks = parseBlocks(block.content) + block.blocks = parseBlocks(block.content, reflinks) } else if (block.is_tag) { @@ -171,11 +170,15 @@ function processNestedBlocks(block) { console.error('YAML parse error', body, e) } - if (!block.has_data) block.blocks = parseBlocks(block.body) + if (!block.has_data) block.blocks = parseBlocks(block.body, reflinks) delete block.body } } + +/******* UTILS ********/ + + export function parseHeading(str) { const level = str.search(/[^#]/) const tokens = parseInline(str.slice(level).trim()) @@ -209,7 +212,7 @@ function getListItem(line) { } export function getBreak(str) { - const HR = ['---', '***', '___', '- - -'] + const HR = ['---', '***', '___', '- - -', '* * *'] for (const hr of HR) { if (str.startsWith(hr) && !/[^\*\-\_ ]/.test(str)) { @@ -234,12 +237,3 @@ function addListEntry({ entries }, line) { } -/* get next empty line -function getNext(lines, i) { - while (lines[i]) { - const line = lines[i] - if (line && line.trim()) return line - i++ - } -} -*/ diff --git a/packages/nuemark2/src/parse-inline.js b/packages/nuemark2/src/parse-inline.js index cf790627..40cb2875 100644 --- a/packages/nuemark2/src/parse-inline.js +++ b/packages/nuemark2/src/parse-inline.js @@ -49,8 +49,11 @@ const PARSERS = [ (str, char0) => { if (char0 == '!' && str[1] == '[') { const img = parseLink(str.slice(1)) - img.end++ - return img && { is_image: true, ...img } + + if (img) { + img.end++ + return img && { is_image: true, ...img } + } } }, @@ -114,11 +117,15 @@ export function parseInline(str) { export function parseLink(str, is_reflink) { const [open, close] = is_reflink ? '[]' : '()' - const i = str.indexOf(']' + open, 1) - let j = i > 0 ? str.indexOf(close, 2 + i) : 0 + const i = str.indexOf(']', 1) + const next = str[i + 1] + + if (next != open) return + + let j = i > 0 ? str.indexOf(close, 3 + i) : 0 // not a link - if (j <= 0) return + if (j <= 0 || str[i] == ' ') return // links with closing bracket (ie. Wikipedia) if (str[j + 1] == ')') j++ diff --git a/packages/nuemark2/test/inline.test.js b/packages/nuemark2/test/inline.test.js index 38f39fe0..e89dc0f3 100644 --- a/packages/nuemark2/test/inline.test.js +++ b/packages/nuemark2/test/inline.test.js @@ -91,6 +91,7 @@ test('parse reflink', () => { expect(link).toMatchObject({ href: 'world', title: 'now', label: 'Hello' }) }) + test('parse reflink', () => { const [text, link] = parseInline('Baz [foo][bar]') expect(link.label).toBe('foo') @@ -130,6 +131,12 @@ test('inline tag', () => { expect(el.name).toBe('version') }) +test('inline tag', () => { + const [ tag, and, link] = parseInline('[tip] and [link][foo]') + expect(tag.is_tag).toBeTrue() + expect(link.is_reflink).toBeTrue() +}) + test('tag args', () => { const [ text, comp, rest] = parseInline('Hey [print foo] thing') expect(comp.name).toBe('print') diff --git a/packages/nuemark2/test/performance.js b/packages/nuemark2/test/performance.js index 45d96d1b..731898ff 100644 --- a/packages/nuemark2/test/performance.js +++ b/packages/nuemark2/test/performance.js @@ -17,9 +17,10 @@ const renderer = { } const UNIT = ` -> Block 1 -> Block 2 +[.fist] + [image /doom.png alt="iso"] + asdlfkjas ` @@ -97,8 +98,8 @@ if (false) { // perftest('nue', nuemark) } else { - console.info(marked.parse(UNIT)) - console.info('------------------') + // console.info(marked.parse(UNIT)) + // console.info('------------------') console.info(nuemark(UNIT)) } diff --git a/packages/nuemark2/test/tag.test.js b/packages/nuemark2/test/tag.test.js index b3462c41..c869e96f 100644 --- a/packages/nuemark2/test/tag.test.js +++ b/packages/nuemark2/test/tag.test.js @@ -131,20 +131,6 @@ test('client-side island', () => { expect(html).toStartWith(' { - const html = renderLines([ - '[.stack]', - ' # Contact me', - ' Available for hire', - ' [contact-me]', - '', - ' ---', - ' [! /img/profile.jpg size=""460 x 460"]' - ]) - - console.info(html) -}) - test('[table] tag', () => { const foo = [ ['Foo', 'Buzz'], ['hey', 'girl']] From 1b580150dd1b7be39baf56bdf182f5c4cdabcd2c Mon Sep 17 00:00:00 2001 From: nobkd <44443899+nobkd@users.noreply.github.com> Date: Thu, 26 Sep 2024 16:31:02 +0200 Subject: [PATCH 006/103] fix: line splitting, import, undefined blocks --- packages/nuemark2/index.js | 4 ++-- packages/nuemark2/src/render-blocks.js | 2 +- packages/nuemark2/src/render-tag.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/nuemark2/index.js b/packages/nuemark2/index.js index dabd0244..fd1ba6be 100644 --- a/packages/nuemark2/index.js +++ b/packages/nuemark2/index.js @@ -1,8 +1,8 @@ import { renderLines } from './src/render-blocks.js' import { parseDocument } from './src/document.js' -import { EOL } from 'node:os' +const EOL = /\r\n|\r|\n/ export function nuemark(str, opts) { return renderLines(str.split(EOL), opts) @@ -15,4 +15,4 @@ export function nuedoc(str) { /* utilities */ export { renderInline } from './src/render-inline.js' export { parseSize } from './src/render-tag.js' -export { elem } from './src/document.js' \ No newline at end of file +export { elem } from './src/document.js' diff --git a/packages/nuemark2/src/render-blocks.js b/packages/nuemark2/src/render-blocks.js index 3bed3f4d..185258dd 100644 --- a/packages/nuemark2/src/render-blocks.js +++ b/packages/nuemark2/src/render-blocks.js @@ -4,7 +4,7 @@ import { parseLinkTitle } from './parse-inline.js' import { parseBlocks } from './parse-blocks.js' import { renderTag, wrap } from './render-tag.js' import { elem } from './document.js' -import { glow } from 'glow' +import { glow } from 'nue-glow' export function renderLines(lines, opts) { diff --git a/packages/nuemark2/src/render-tag.js b/packages/nuemark2/src/render-tag.js index 5c220a14..e497da52 100644 --- a/packages/nuemark2/src/render-tag.js +++ b/packages/nuemark2/src/render-tag.js @@ -187,7 +187,7 @@ export function wrap(name, html) { } -function getInnerHTML(blocks, opts) { +function getInnerHTML(blocks = [], opts) { const [ first, second ] = blocks if (!first) return '' const { content } = first From a83e8fb12ccfec8a6e3a897917299fbb8343fd6f Mon Sep 17 00:00:00 2001 From: Tero Piirainen Date: Fri, 27 Sep 2024 06:51:17 +0300 Subject: [PATCH 007/103] Make Simple Blog work with the new Nuemark version --- packages/nuekit/src/browser/mount.js | 4 +-- packages/nuekit/src/init.js | 2 +- packages/nuekit/src/layout/gallery.js | 2 -- packages/nuemark2/src/document.js | 35 ++++++++++++++++++------- packages/nuemark2/src/render-tag.js | 18 ++++++------- packages/nuemark2/test/document.test.js | 30 +++++++++++++++++++-- packages/nuemark2/test/performance.js | 21 +++++++++++---- 7 files changed, 81 insertions(+), 31 deletions(-) diff --git a/packages/nuekit/src/browser/mount.js b/packages/nuekit/src/browser/mount.js index 800f19d1..52f71957 100644 --- a/packages/nuekit/src/browser/mount.js +++ b/packages/nuekit/src/browser/mount.js @@ -26,14 +26,14 @@ async function importAll(hmr_path) { export async function mountAll(hmr_path) { - const els = document.querySelectorAll('[is]') + const els = document.querySelectorAll('[custom]') const lib = els[0] ? await importAll(hmr_path) : [] if (!lib[0]) return const { createApp } = await import('./nue.js') for (const node of [...els]) { - const name = node.getAttribute('is') + const name = node.tagName.toLowerCase() const next = node.nextElementSibling const data = next?.type == 'application/json' ? JSON.parse(next.textContent) : {} const comp = lib.find(a => a.name == name) diff --git a/packages/nuekit/src/init.js b/packages/nuekit/src/init.js index 58c2a00b..6560acdc 100644 --- a/packages/nuekit/src/init.js +++ b/packages/nuekit/src/init.js @@ -15,7 +15,7 @@ export async function initNueDir({ dist, is_dev, esbuild, force }) { const outdir = join(cwd, dist, '@nue') // has all latest? - const latest = join(outdir, '.beta-2') + const latest = join(outdir, '.beta-3') if (force || !existsSync(latest)) { await fs.rm(outdir, { recursive: true, force: true }) diff --git a/packages/nuekit/src/layout/gallery.js b/packages/nuekit/src/layout/gallery.js index 16b18282..6d050538 100644 --- a/packages/nuekit/src/layout/gallery.js +++ b/packages/nuekit/src/layout/gallery.js @@ -51,8 +51,6 @@ export function renderGallery(data) { const key = data.collection_name || data.content_collection const items = key ? data[key] : data.itmes || data - console.info(data) - if (!items?.length) { console.error('Gallery tag: no data or content collection defined') return '' diff --git a/packages/nuemark2/src/document.js b/packages/nuemark2/src/document.js index 3f080e5d..a7b435b8 100644 --- a/packages/nuemark2/src/document.js +++ b/packages/nuemark2/src/document.js @@ -26,8 +26,10 @@ export function parseDocument(lines) { const api = { + blocks, + get sections() { - return blocks && categorize(blocks) + return sectionize(blocks) || [ blocks ] }, get codeblocks() { @@ -52,10 +54,10 @@ export function parseDocument(lines) { return elem('div', { 'aria-label': 'Table of contents', ...attr }, navs) }, - render(opts={}) { - let { sections } = opts.data || {} - if (sections && !Array.isArray(sections)) sections = [] - return sections ? api.renderSections(sections, opts) : renderBlocks(blocks, opts) + render(opts={ data: meta }) { + let classes = opts.data.sections + if (classes && !Array.isArray(classes)) classes = [] + return classes ? api.renderSections(classes, opts) : renderBlocks(blocks, opts) }, } @@ -63,17 +65,30 @@ export function parseDocument(lines) { } -export function categorize(blocks=[], max_level=2) { +export function sectionize(blocks=[]) { const arr = [] let section - // no sections if no separators - const el = blocks.find(el => el.is_separator || el.level <= max_level) - if (!el) return + // first (sub)heading + const heading = blocks.find(el => el.level > 1) + + // no heading nor separator -> no sections + if (!heading && !blocks.find(el => el.is_separator)) return blocks.forEach((el, i) => { const sep = el.is_separator - if (!section || el.level <= max_level || sep) arr.push(section = []) + + // add new section + if (!section || el.level <= heading?.level || sep) { + const prev = blocks[i-1] + + // heading followed by separator --> skip + if (!(el.level && prev?.is_separator)) { + arr.push(section = []) + } + } + + // add content to section if (!sep) section.push(el) }) diff --git a/packages/nuemark2/src/render-tag.js b/packages/nuemark2/src/render-tag.js index 5c220a14..130f3a64 100644 --- a/packages/nuemark2/src/render-tag.js +++ b/packages/nuemark2/src/render-tag.js @@ -1,7 +1,7 @@ import { renderBlocks, renderTable, renderContent } from './render-blocks.js' import { renderInline } from './render-inline.js' -import { categorize, elem } from './document.js' +import { sectionize, elem } from './document.js' import { readFileSync } from 'node:fs' import { join } from 'node:path' @@ -14,16 +14,16 @@ const TAGS = { const summary = elem('summary', this.render([blocks[0]])) return summary + this.render(blocks.slice(1)) }) + if (html) { const acc = elem('details', this.attr, html.join('\n')) return wrap(this.data.wrapper, acc) } }, - button() { - const { attr, data } = this + button(data) { const label = this.renderInline(data.label || data._) || this.innerHTML || '' - return elem('a', { ...attr, href: data.href, role: 'button' }, label) + return elem('a', { ...this.attr, href: data.href, role: 'button' }, label) }, image() { @@ -94,12 +94,12 @@ export function renderTag(tag, opts={}) { // anonymous tag if (!name) { - const cats = categorize(blocks) + const divs = sectionize(blocks) - const inner = !cats || !cats[1] ? renderBlocks(blocks, opts) : - cats.map(blocks => elem('div', renderBlocks(blocks, opts))).join('\n') + const html = !divs || !divs[1] ? renderBlocks(blocks, opts) : + divs.map(blocks => elem('div', renderBlocks(blocks, opts))).join('\n') - return elem('div', attr, inner) + return elem('div', attr, html) } if (!fn) return renderIsland(tag) @@ -111,7 +111,7 @@ export function renderTag(tag, opts={}) { get innerHTML() { return getInnerHTML(this.blocks, opts) }, render(blocks) { return renderBlocks(blocks, opts) }, renderInline(str) { return renderInline(str, opts) }, - sections: categorize(tag.blocks), + sections: sectionize(tag.blocks), data, opts, tags, diff --git a/packages/nuemark2/test/document.test.js b/packages/nuemark2/test/document.test.js index 54041121..8325e32f 100644 --- a/packages/nuemark2/test/document.test.js +++ b/packages/nuemark2/test/document.test.js @@ -1,5 +1,6 @@ -import { parseDocument, stripMeta } from '../src/document.js' +import { parseDocument, stripMeta, sectionize } from '../src/document.js' +import { parseBlocks } from '../src/parse-blocks.js' test('front matter', () => { const lines = ['---', 'foo: 10', 'bar: 20', '---', '# Hello'] @@ -39,7 +40,31 @@ test('render method', () => { }) -test('sections', () => { +test('sectionize', () => { + const tests = [ + ['### h3', 'para', '### h3', 'para', '#### h4', 'para'], + ['# h1', 'para', '## h2', 'para', '### h3', 'para'], + ['## lol', '---', '## bol'], + ['lol', '---', 'bol'], + ] + + for (const blocks of tests) { + const headings = parseBlocks(blocks) + expect(sectionize(headings).length).toBe(2) + } +}) + +test('non section', () => { + const paragraphs = parseBlocks(['hello', 'world']) + expect(sectionize(paragraphs)).toBeUndefined() +}) + +test('single section', () => { + const { sections } = parseDocument(['Hello']) + expect(sections.length).toBe(1) +}) + +test('multiple sections', () => { const doc = parseDocument([ '# Hello', 'World', '## Foo', 'Bar', @@ -52,6 +77,7 @@ test('sections', () => { expect(html).toEndWith('

            ') }) + test('table of contents', () => { const doc = parseDocument([ '# Hello', 'World', diff --git a/packages/nuemark2/test/performance.js b/packages/nuemark2/test/performance.js index 731898ff..7cff3a52 100644 --- a/packages/nuemark2/test/performance.js +++ b/packages/nuemark2/test/performance.js @@ -4,7 +4,7 @@ import { markedSmartypants } from "marked-smartypants"; import hljs from 'highlight.js'; -import { nuemark } from '..' +import { nuemark, nuedoc } from '..' // Not working // import { markedHighlight } from "marked-highlight"; @@ -17,10 +17,20 @@ const renderer = { } const UNIT = ` +--- +title: kamis +sections: true +--- + +[.stack] + # Contact me + I'm currently available for hire + + [contact-me] + + --- + [! /img/profile.jpg size="460 x 460"] -[.fist] - [image /doom.png alt="iso"] - asdlfkjas ` @@ -100,7 +110,8 @@ if (false) { } else { // console.info(marked.parse(UNIT)) // console.info('------------------') - console.info(nuemark(UNIT)) + const doc = nuedoc(UNIT) + console.info(doc.render()) } From c6a02fa707950e3be496d1f185e52d5d2f7bff67 Mon Sep 17 00:00:00 2001 From: Tero Piirainen Date: Fri, 27 Sep 2024 08:59:29 +0300 Subject: [PATCH 008/103] Header id sanity --- packages/nuejs.org/home/css/features.css | 6 ++--- packages/nuejs.org/index.md | 17 ++----------- packages/nuejs.org/site.yaml | 1 + packages/nuemark2/src/document.js | 9 +++++-- packages/nuemark2/src/parse-blocks.js | 10 +------- packages/nuemark2/src/render-blocks.js | 19 ++++++++++++--- packages/nuemark2/src/render-tag.js | 1 + packages/nuemark2/test/block.test.js | 31 ++++++++++++------------ packages/nuemark2/test/tag.test.js | 6 +++++ 9 files changed, 51 insertions(+), 49 deletions(-) diff --git a/packages/nuejs.org/home/css/features.css b/packages/nuejs.org/home/css/features.css index 7ce17ff9..91fa412d 100644 --- a/packages/nuejs.org/home/css/features.css +++ b/packages/nuejs.org/home/css/features.css @@ -83,7 +83,7 @@ } -/* initial state for all content elements */ +/* initial state for all content elements .feature-area > * { transform: translateY(1.5rem); transition: .5s; @@ -91,10 +91,8 @@ &:nth-child(2) { transition-delay: .2s } &:nth-child(3) { transition-delay: .4s } - /* &:nth-child(4) { transition-delay: .7s }*/ - /* &:nth-child(5) { transition-delay: .8s }*/ } - +*/ /* trigger animation when in viewport */ .in-viewport > * { diff --git a/packages/nuejs.org/index.md b/packages/nuejs.org/index.md index 27cd9dff..c55bd950 100644 --- a/packages/nuejs.org/index.md +++ b/packages/nuejs.org/index.md @@ -1,6 +1,5 @@ --- -section_classes: [hero, feature-area, project-status] -section_component: scroll-transition +sections: [hero, feature-area, project-status] include: [button, form] inline_css: true appdir: home @@ -19,14 +18,10 @@ Build the slickest websites in the world and wonder why you ever did them any ot size: 1100 × 712 -======== - - ## Web developer's dream What used to take a separate designer, React engineer, and an absurd amount of JavaScript can now be done by a UX developer and a small amount of CSS - [.features] ### Rapid UX development No JavaScript ecosystem in your way @@ -35,7 +30,6 @@ What used to take a separate designer, React engineer, and an absurd amount of J [Learn more](/docs/) - --- ### Easy customer handoff All content is editable by non-technical people @@ -45,7 +39,6 @@ What used to take a separate designer, React engineer, and an absurd amount of J [Learn more](/docs/content.html) - --- ### Advanced reactivity Go beyond the JavaScript component model @@ -53,8 +46,6 @@ What used to take a separate designer, React engineer, and an absurd amount of J [Learn more](/docs/reactivity.html) - --- - ### Leaner, cleaner, and faster Work closer to metal and web standards @@ -63,8 +54,6 @@ What used to take a separate designer, React engineer, and an absurd amount of J [Learn more](/docs/performance-optimization.html) -======== - ## Roadmap and status { #roadmap } Ultimately Nue will be a ridiculously simpler alternative to **Next.js**, **Gatsby**, and **Astro** @@ -80,13 +69,11 @@ Be the first to know when the official 1.0 release, templates, and the cloud stu [join-list] -======== - - ## Developer reactions [.heroquote] [! /home/img/elliot-jay-stocks.jpg] + --- At some point in the last decade, popular frameworks and platforms have eschewed semantic markup, and, as a result, the web has become way more bloated than it ever needed to be. Stripping away presentational markup and unreadable CSS is something all web developers once believed in. I'm glad Nue is bringing back that power. diff --git a/packages/nuejs.org/site.yaml b/packages/nuejs.org/site.yaml index 641f86a8..7650b0b8 100644 --- a/packages/nuejs.org/site.yaml +++ b/packages/nuejs.org/site.yaml @@ -7,6 +7,7 @@ og: /img/og-blue-big.png view_transitions: true native_css_nesting: true origin: https://nuejs.org +heading_ids: true # Content settings title: Web Framework For UX Developers diff --git a/packages/nuemark2/src/document.js b/packages/nuemark2/src/document.js index a7b435b8..55d4f67c 100644 --- a/packages/nuemark2/src/document.js +++ b/packages/nuemark2/src/document.js @@ -1,6 +1,6 @@ +import { renderBlocks, createHeadingId } from './render-blocks.js' import { parseLinkTitle } from './parse-inline.js' -import { renderBlocks } from './render-blocks.js' import { parseBlocks } from './parse-blocks.js' import { load as parseYAML } from 'js-yaml' @@ -98,7 +98,12 @@ export function sectionize(blocks=[]) { function renderNav(blocks) { const headings = blocks.filter(b => [2, 3].includes(b.level)) - const links = headings.map(h => elem('a', { href: `#${ h.attr.id }` }, h.text)) + + const links = headings.map(h => { + const id = h.attr.id ||createHeadingId(h.text) + return elem('a', { href: `#${ id }` }, h.text) + }) + return links[0] ? elem('nav', links.join('\n')) : '' } diff --git a/packages/nuemark2/src/parse-blocks.js b/packages/nuemark2/src/parse-blocks.js index b0e55e97..ecab8af1 100644 --- a/packages/nuemark2/src/parse-blocks.js +++ b/packages/nuemark2/src/parse-blocks.js @@ -184,18 +184,10 @@ export function parseHeading(str) { const tokens = parseInline(str.slice(level).trim()) const text = tokens.map(el => el.text || el.body || '').join('').trim() const specs = tokens.find(el => el.is_attr) - const attr = specs?.attr || { id: createId(text) } + const attr = specs?.attr || {} return { is_heading: true, level, tokens, text, attr } } -function createId(text) { - let hash = text.slice(0, 32).replace(/'/g, '').replace(/[\W_]/g, '-').replace(/-+/g, '-').toLowerCase() - if (hash[0] == '-') hash = hash.slice(1) - if (hash.endsWith('-')) hash = hash.slice(0, -1) - return hash -} - - function parseReflink(str) { if (str[0] == '[') { const i = str.indexOf(']:') diff --git a/packages/nuemark2/src/render-blocks.js b/packages/nuemark2/src/render-blocks.js index 185258dd..4dd3313f 100644 --- a/packages/nuemark2/src/render-blocks.js +++ b/packages/nuemark2/src/render-blocks.js @@ -48,11 +48,22 @@ function parseReflinks(links) { } export function renderHeading(h, opts={}) { - const ids = opts.heading_ids - const a = ids ? elem('a', { href: `#${ h.attr.id }`, title: h.text }) : '' - if (!ids) delete h.attr.id + const attr = { ...h.attr } + const show_id = opts.data?.heading_ids + if (show_id && !attr.id) attr.id = createHeadingId(h.text) - return elem('h' + h.level, h.attr, a + renderTokens(h.tokens, opts)) + // anchor + const a = show_id ? elem('a', { href: `#${ attr.id }`, title: h.text }) : '' + + return elem('h' + h.level, attr, a + renderTokens(h.tokens, opts)) +} + + +export function createHeadingId(text) { + let hash = text.slice(0, 32).replace(/'/g, '').replace(/[\W_]/g, '-').replace(/-+/g, '-').toLowerCase() + if (hash[0] == '-') hash = hash.slice(1) + if (hash.endsWith('-')) hash = hash.slice(0, -1) + return hash } export function renderContent(lines, opts) { diff --git a/packages/nuemark2/src/render-tag.js b/packages/nuemark2/src/render-tag.js index 052c3ec3..3a0b4874 100644 --- a/packages/nuemark2/src/render-tag.js +++ b/packages/nuemark2/src/render-tag.js @@ -187,6 +187,7 @@ export function wrap(name, html) { } + function getInnerHTML(blocks = [], opts) { const [ first, second ] = blocks if (!first) return '' diff --git a/packages/nuemark2/test/block.test.js b/packages/nuemark2/test/block.test.js index 890a57a2..0d2f3a5c 100644 --- a/packages/nuemark2/test/block.test.js +++ b/packages/nuemark2/test/block.test.js @@ -78,17 +78,13 @@ test('render thematic break', () => { test('parse heading', () => { const h = parseHeading('# Hello') - expect(h).toMatchObject({ attr: { id: "hello" }, text: 'Hello', level: 1 }) + expect(h).toMatchObject({ attr: {}, text: 'Hello', level: 1 }) }) -test('heading class & id', () => { - const h = parseHeading('# Hello, *World!* { #foo.bar }') - expect(h.text).toBe('Hello, World!') - expect(h.attr).toEqual({ class: "bar", id: "foo" }) - - const html = renderHeading(h, { heading_ids: true }) - expect(html).toStartWith('

            World!') +test('render heading', () => { + expect(nuemark('# Hello')).toBe('

            Hello

            ') + expect(nuemark('##Hello')).toBe('

            Hello

            ') + expect(nuemark('### Hello, *world*')).toBe('

            Hello, world

            ') }) test('heading class name', () => { @@ -96,14 +92,19 @@ test('heading class name', () => { expect(html).toBe('

            Hello

            ') }) -test('render heading', () => { - expect(nuemark('# Hello')).toBe('

            Hello

            ') - expect(nuemark('##Hello')).toBe('

            Hello

            ') - expect(nuemark('### Hello, *world*')).toBe('

            Hello, world

            ') +test('heading attr', () => { + const h = parseHeading('# Hey { #foo.bar }') + expect(h.text).toBe('Hey') + expect(h.attr).toEqual({ class: "bar", id: "foo" }) + + expect(renderHeading(h)).toBe('

            Hey

            ') + + const html = renderHeading(h, { data: { heading_ids: true } }) + expect(html).toInclude('
            ') }) -test('heading ids', () => { - const html = nuemark('# Hello', { heading_ids: true }) +test('generated heading id', () => { + const html = nuemark('# Hello', { data: { heading_ids: true } }) expect(html).toBe('

            Hello

            ') }) diff --git a/packages/nuemark2/test/tag.test.js b/packages/nuemark2/test/tag.test.js index c869e96f..817929a0 100644 --- a/packages/nuemark2/test/tag.test.js +++ b/packages/nuemark2/test/tag.test.js @@ -163,6 +163,11 @@ test('[image] tag', () => { expect(html).toBe('
            ') }) +test('[image] nested arg', () => { + const html = renderLines(['[image]', ' src: img.png']) + expect(html).toBe('
            ') +}) + test('picture', () => { const html = renderLines([ '[image caption="Hello"]', @@ -175,6 +180,7 @@ test('picture', () => { expect(html).toEndWith('
            Hello
            ') }) + test('[video] tag', () => { const html = renderLines(['[video /meow.mp4 autoplay]', ' ### Hey']) expect(html).toStartWith('