"use strict"; // TODO implement remaining methods class RendererMarkdown { static async pInit () { const settings = await StorageUtil.pGet("bookViewSettingsMarkdown") || Object.entries(RendererMarkdown._CONFIG).mergeMap(([k, v]) => ({[k]: v.default})); Object.assign(RendererMarkdown, settings); RendererMarkdown._isInit = true; } static checkInit () { if (!RendererMarkdown._isInit) throw new Error(`RendererMarkdown has not been initialised!`); } getLineBreak () { return "\n"; } constructor () { // FIXME this is awful const renderer = new Renderer(); this.__super = {}; for (const k in renderer) { if (this[k] === undefined) { if (typeof renderer[k] === "function") this[k] = renderer[k].bind(this); else this[k] = MiscUtil.copy(renderer[k]); } else { if (typeof renderer[k] === "function") this.__super[k] = renderer[k].bind(this); else this.__super[k] = MiscUtil.copy(renderer[k]); } } this._isSkipStylingItemLinks = false; } set isSkipStylingItemLinks (val) { this._isSkipStylingItemLinks = val; } static get () { RendererMarkdown.checkInit(); return new RendererMarkdown().setFnPostProcess(RendererMarkdown._fnPostProcess); } static _fnPostProcess (str) { return str .trim() .replace(/\n\n+/g, "\n\n") .replace(/(>\n>\n)+/g, ">\n"); } static _getNextPrefix (options, prefix) { return options.prefix === ">" || options.prefix === ">>" ? `${options.prefix}${prefix || ""}` : prefix || ""; } // region recursive /* _renderEntries (entry, textStack, meta, options) { // (Use base implementation) } */ _renderEntriesSubtypes (entry, textStack, meta, options, incDepth) { const isInlineTitle = meta.depth >= 2; const nextDepth = incDepth && meta.depth < 2 ? meta.depth + 1 : meta.depth; const nxtPrefix = RendererMarkdown._getNextPrefix(options); if (entry.name) { if (isInlineTitle) { textStack[0] += `${nxtPrefix}***${Renderer.stripTags(entry.name)}.*** `; } else { const hashCount = meta._typeStack.length === 1 && meta.depth === -1 ? 1 : Math.min(6, meta.depth + 3); textStack[0] += `\n${nxtPrefix}${"#".repeat(hashCount)} ${Renderer.stripTags(entry.name)}\n\n`; } } if (entry.entries) { this._renderEntriesSubtypes_renderPreReqText(entry, textStack, meta); const cacheDepth = meta.depth; const len = entry.entries.length; for (let i = 0; i < len; ++i) { meta.depth = nextDepth; const isFirstInline = i === 0 && entry.name && isInlineTitle; const suffix = meta.isStatblockInlineMonster ? ` \n` : `\n\n`; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: isFirstInline ? "" : RendererMarkdown._getNextPrefix(options), suffix}); } if (meta.isStatblockInlineMonster) textStack[0] += "\n"; meta.depth = cacheDepth; } } _renderEntriesSubtypes_renderPreReqText (entry, textStack, meta) { if (entry.prerequisite) { textStack[0] += `*Prerequisite: `; this._recursiveRender({type: "inline", entries: [entry.prerequisite]}, textStack, meta); textStack[0] += `*\n\n`; } } /* _renderOptions (entry, textStack, meta, options) { // (Use base implementation) } */ _renderList (entry, textStack, meta, options) { if (!entry.items) return; if (textStack[0] && textStack[0].slice(-1) !== "\n") textStack[0] += "\n"; const listDepth = Math.max(meta._typeStack.filter(it => it === "list").length - 1, 0); if (entry.name) textStack[0] += `##### ${entry.name}`; const indentSpaces = " ".repeat(listDepth); const len = entry.items.length; // Special formatting for spellcasting lists (data attrib added by main renderer spellcasting -> entries) if (entry.data && entry.data.isSpellList) { textStack[0] += `${RendererMarkdown._getNextPrefix(options)}\n`; for (let i = 0; i < len; ++i) { textStack[0] += `${RendererMarkdown._getNextPrefix(options)}${indentSpaces}`; const cacheDepth = this._adjustDepth(meta, 1); this._recursiveRender(entry.items[i], textStack, meta, {suffix: "\n"}); meta.depth = cacheDepth; } } else { for (let i = 0; i < len; ++i) { const item = entry.items[i]; // Special case for child lists -- avoid double-hyphen-prefixing textStack[0] += `${RendererMarkdown._getNextPrefix(options)}${indentSpaces}${item.type === "list" ? "" : `- `}`; const cacheDepth = this._adjustDepth(meta, 1); this._recursiveRender(entry.items[i], textStack, meta, {suffix: "\n"}); if (textStack[0].slice(-2) === "\n\n") textStack[0] = textStack[0].slice(0, -1); meta.depth = cacheDepth; } } textStack[0] += "\n"; } _renderTable (entry, textStack, meta, options) { if (entry.intro) for (const ent of entry.intro) this._recursiveRender(ent, textStack, meta); textStack[0] += "\n"; if (entry.caption) textStack[0] += `##### ${entry.caption}\n`; const headerRowMetas = Renderer.table.getHeaderRowMetas(entry); const hasLabels = headerRowMetas != null; // If there's no data, render a stub table. if (!hasLabels && (!entry.rows || !entry.rows.length)) { textStack[0] += `| |\n`; textStack[0] += `|---|\n`; textStack[0] += `| |\n`; return; } // Labels are required for Markdown tables let labelRows = MiscUtil.copyFast(headerRowMetas || []); if (!hasLabels) { const numCells = Math.max(...entry.rows.map(r => r.length)); labelRows = [ [...new Array(numCells)].map(() => ""), ]; } // Pad labels to style width if (entry.colStyles) { labelRows .filter(labelRow => labelRow.length < entry.colStyles.length) .forEach(labelRow => { labelRow.push( ...[...new Array(entry.colStyles.length - labelRow.length)].map(() => ""), ); }); } // region Prepare styles let styles = null; if (entry.colStyles) { styles = [...entry.colStyles]; // Pad styles to label width labelRows .forEach(labelRow => { if (labelRow.length > styles.length) { styles = styles.concat([...new Array(labelRow.length - styles.length)].map(() => "")); } }); } // endregion const mdHeaderRows = labelRows.map(labelRow => labelRow.map(label => ` ${Renderer.stripTags(label)} `)); // Get per-cell max width const widths = [ ...new Array( Math.max(...mdHeaderRows.map(mdHeaderRow => mdHeaderRow.length)), ), ] .map((_, i) => { return Math.max( ...mdHeaderRows.map(mdHeaderRow => (mdHeaderRow[i] || "").length), RendererMarkdown._md_getPaddedStyleText({style: (styles || [])[i] || ""}).length, ); }); // region Build 2d array of table cells const mdTable = []; const numRows = entry.rows.length; for (let ixRow = 0; ixRow < numRows; ++ixRow) { const row = entry.rows[ixRow]; const rowRender = row.type === "row" ? row.row : row; const numCells = rowRender.length; for (let ixCell = 0; ixCell < numCells; ++ixCell) { const cell = rowRender[ixCell]; let toRenderCell; if (cell.type === "cell") { if (cell.roll) { if (cell.roll.entry) toRenderCell = cell.roll.entry; else if (cell.roll.exact != null) toRenderCell = cell.roll.pad ? StrUtil.padNumber(cell.roll.exact, 2, "0") : cell.roll.exact; else { toRenderCell = cell.roll.pad ? `${StrUtil.padNumber(cell.roll.min, 2, "0")}-${StrUtil.padNumber(cell.roll.max, 2, "0")}` : `${cell.roll.min}-${cell.roll.max}`; } } else if (cell.entry) { toRenderCell = cell.entry; } } else { toRenderCell = cell; } const textStackCell = [""]; const cacheDepth = this._adjustDepth(meta, 1); this._recursiveRender(toRenderCell, textStackCell, meta); meta.depth = cacheDepth; const mdCell = ` ${textStackCell.join("").trim()} ` // Markdown tables can't handle multi-line cells, so HTML linebreaks must be used .split(/\n+/) .join("
"); widths[ixCell] = Math.max(widths[ixCell] || 0, mdCell.length); (mdTable[ixRow] = mdTable[ixRow] || [])[ixCell] = mdCell; } } // endregion const mdHeaderRowsPadded = mdHeaderRows .map(mdHeaderRow => { return mdHeaderRow .map((header, ixCell) => RendererMarkdown._md_getPaddedTableText({text: header, width: widths[ixCell], ixCell, styles})); }); // region Build style headers const mdStyles = []; if (styles) { styles.forEach((style, i) => { mdStyles.push(RendererMarkdown._md_getPaddedStyleText({style, width: widths[i]})); }); } // endregion // region Assemble the table for (const mdHeaderRowPadded of mdHeaderRowsPadded) { textStack[0] += `|${mdHeaderRowPadded.join("|")}|\n`; } if (mdStyles.length) textStack[0] += `|${mdStyles.join("|")}|\n`; for (const mdRow of mdTable) { textStack[0] += "|"; const numCells = mdRow.length; for (let ixCell = 0; ixCell < numCells; ++ixCell) { textStack[0] += RendererMarkdown._md_getPaddedTableText({text: mdRow[ixCell], width: widths[ixCell], ixCell, styles}); textStack[0] += "|"; } textStack[0] += "\n"; } // endregion if (entry.footnotes) { for (const ent of entry.footnotes) { const cacheDepth = this._adjustDepth(meta, 1); this._recursiveRender(ent, textStack, meta); meta.depth = cacheDepth; } } if (entry.outro) for (const ent of entry.outro) this._recursiveRender(ent, textStack, meta); if (!entry.rows) { textStack[0] += `||\n`; return; } textStack[0] += "\n"; } static _md_getPaddedTableText ({text, width, ixCell, styles}) { if (text.length >= width) return text; if (styles?.[ixCell]?.includes("text-center")) return text.padStart(Math.ceil((width - text.length) / 2) + text.length, " ").padEnd(width, " "); if (styles?.[ixCell]?.includes("text-right")) return text.padStart(width, " "); return text.padEnd(width, " "); } static _md_getPaddedStyleText ({style, width = null}) { width = width ?? 0; // If there is no specific width requirement, minimize style widths if (style.includes("text-center")) return `:${"-".repeat(Math.max(width - 2, 3))}:`; if (style.includes("text-right")) return `${"-".repeat(Math.max(width - 1, 3))}:`; return "-".repeat(Math.max(width, 3)); } /* _renderTableGroup (entry, textStack, meta, options) { // (Use base implementation) } */ _renderInset (entry, textStack, meta, options) { textStack[0] += "\n"; if (entry.name != null) textStack[0] += `> ##### ${entry.name}\n>\n`; if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: ">", suffix: "\n>\n"}); meta.depth = cacheDepth; } } textStack[0] += `\n`; } _renderInsetReadaloud (entry, textStack, meta, options) { textStack[0] += "\n"; if (entry.name != null) textStack[0] += `>> ##### ${entry.name}\n>>\n`; if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: ">>", suffix: "\n>>\n"}); meta.depth = cacheDepth; } } textStack[0] += `\n`; } _renderVariant (entry, textStack, meta, options) { textStack[0] += "\n"; if (entry.name != null) textStack[0] += `> ##### Variant: ${entry.name}\n>\n`; if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: ">", suffix: "\n>\n"}); meta.depth = cacheDepth; } } if (entry.source) textStack[0] += `>${RendererMarkdown.utils.getPageText({source: entry.source, page: entry.page})}\n`; textStack[0] += "\n"; } _renderVariantSub (entry, textStack, meta, options) { if (entry.name) textStack[0] += `*${entry.name}.* `; if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { this._recursiveRender(entry.entries[i], textStack, meta, {prefix: RendererMarkdown._getNextPrefix(options), suffix: "\n>\n"}); } } } _renderSpellcasting (entry, textStack, meta, options) { const toRender = this._renderSpellcasting_getEntries(entry); if (!toRender?.[0].entries?.length) return; this._recursiveRender({type: "entries", entries: toRender}, textStack, meta, {prefix: RendererMarkdown._getNextPrefix(options), suffix: "\n"}); } _renderQuote (entry, textStack, meta, options) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { this._recursiveRender(entry.entries[i], textStack, meta, {prefix: RendererMarkdown._getNextPrefix(options, "*"), suffix: "*"}); if (i !== entry.entries.length - 1) textStack[0] += `\n\n`; } const byArr = this._renderQuote_getBy(entry); if (byArr) { const tempStack = [""]; for (let i = 0, len = byArr.length; i < len; ++i) { const by = byArr[i]; this._recursiveRender(by, tempStack, meta); if (i < len - 1) tempStack[0] += "\n"; } textStack[0] += `\u2014 ${tempStack.join("")}${entry.from ? `, *${this.render(entry.from)}*` : ""}`; } } /* _renderOptfeature (entry, textStack, meta, options) { // (Use base implementation) } _renderPatron (entry, textStack, meta, options) { // (Use base implementation) } // endregion */ // region block _renderAbilityDc (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `**${entry.name} save DC** = 8 + your proficiency bonus + your ${Parser.attrChooseToFull(entry.attributes)}`; this._renderSuffix(entry, textStack, meta, options); } _renderAbilityAttackMod (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `**${entry.name} attack modifier** = your proficiency bonus + your ${Parser.attrChooseToFull(entry.attributes)}`; this._renderSuffix(entry, textStack, meta, options); } _renderAbilityGeneric (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `${entry.name ? `**${entry.name}** = ` : ""}${entry.text}${entry.attributes ? ` ${Parser.attrChooseToFull(entry.attributes)}` : ""}`; this._renderSuffix(entry, textStack, meta, options); } // endregion /* // region inline _renderInline (entry, textStack, meta, options) { // (Use base implementation) } _renderInlineBlock (entry, textStack, meta, options) { // (Use base implementation) } _renderBonus (entry, textStack, meta, options) { // (Use base implementation) } _renderBonusSpeed (entry, textStack, meta, options) { // (Use base implementation) } */ _renderDice (entry, textStack, meta, options) { textStack[0] += Renderer.getEntryDiceDisplayText(entry, entry.name); } _renderLink (entry, textStack, meta, options) { const href = this._renderLink_getHref(entry); textStack[0] += `[${href}](${this.render(entry.text)})`; } _renderActions (entry, textStack, meta, options) { const cachedDepth = meta.depth; meta.depth = 2; this._renderEntriesSubtypes( { ...entry, type: "entries", }, textStack, meta, options, ); meta.depth = cachedDepth; } _renderAttack (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `*${Parser.attackTypeToFull(entry.attackType)}:* `; const len = entry.attackEntries.length; for (let i = 0; i < len; ++i) this._recursiveRender(entry.attackEntries[i], textStack, meta); textStack[0] += ` *Hit:* `; const len2 = entry.hitEntries.length; for (let i = 0; i < len2; ++i) this._recursiveRender(entry.hitEntries[i], textStack, meta); this._renderSuffix(entry, textStack, meta, options); } /* _renderIngredient (entry, textStack, meta, options) { // (Use base implementation) } // endregion */ // region list items _renderItem (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `**${this.render(entry.name)}${this._renderItem_isAddPeriod(entry) ? "." : ""}** `; let addedNewline = false; if (entry.entry) this._recursiveRender(entry.entry, textStack, meta); else if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const nxtPrefix = RendererMarkdown._getNextPrefix(options, i > 0 ? " " : ""); this._recursiveRender(entry.entries[i], textStack, meta, {prefix: nxtPrefix, suffix: "\n"}); } addedNewline = true; } if (!addedNewline) textStack[0] += "\n"; this._renderSuffix(entry, textStack, meta, options); } _renderItemSub (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); const nxtPrefix = RendererMarkdown._getNextPrefix(options, `*${this.render(entry.name)}* `); this._recursiveRender(entry.entry, textStack, meta, {prefix: nxtPrefix, suffix: "\n"}); this._renderSuffix(entry, textStack, meta, options); } _renderItemSpell (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); this._recursiveRender(entry.entry, textStack, meta, {prefix: RendererMarkdown._getNextPrefix(options, `${entry.name} `), suffix: " \n"}); this._renderSuffix(entry, textStack, meta, options); } // endregion // region embedded entities /** * Note that unlike the HTML equivalent, this cannot async defer rendering by leveraging the DOM. Therefore, any * inline data passed to this method is assumed to be complete, i.e., require no further loading or mutating. */ _renderStatblockInline (entry, textStack, meta, options) { const fnGetRenderCompact = RendererMarkdown.hover.getFnRenderCompact(entry.dataType); if (!fnGetRenderCompact) { this._renderPrefix(entry, textStack, meta, options); textStack[0] += `**Cannot render "${entry.type}"\u2014unknown type "${entry.dataType}"!**\n`; this._renderSuffix(entry, textStack, meta, options); return; } this._renderPrefix(entry, textStack, meta, options); // Pass `entry` here to allow e.g. `legendaryGroup` to be included when rendering creatures textStack[0] += fnGetRenderCompact(entry.data, {...entry, meta}); this._renderSuffix(entry, textStack, meta, options); } /* _renderStatblock (entry, textStack, meta, options) { // TODO assume the entity is pre-cached, and sync fetch it from the cache? } */ // endregion // region images _renderImage (entry, textStack, meta, options) { this._renderPrefix(entry, textStack, meta, options); const href = this._renderImage_getUrl(entry); textStack[0] += `![${entry.title || ""}](${href})`; this._renderSuffix(entry, textStack, meta, options); } _renderGallery (entry, textStack, meta, options) { if (entry.name) textStack[0] += `##### ${entry.name}\n`; const len = entry.images.length; for (let i = 0; i < len; ++i) { const img = MiscUtil.copyFast(entry.images[i]); this._recursiveRender(img, textStack, meta); } } // endregion // region flowchart _renderFlowchart (entry, textStack, meta, options) { const len = entry.blocks.length; for (let i = 0; i < len; ++i) { this._recursiveRender(entry.blocks[i], textStack, meta, options); } } _renderFlowBlock (entry, textStack, meta, options) { textStack[0] += "\n"; if (entry.name != null) textStack[0] += `> ##### ${entry.name}\n>\n`; if (entry.entries) { const len = entry.entries.length; for (let i = 0; i < len; ++i) { const cacheDepth = meta.depth; meta.depth = 2; this._recursiveRender(entry.entries[i], textStack, meta, {prefix: ">", suffix: "\n>\n"}); meta.depth = cacheDepth; } } textStack[0] += `\n`; } // endregion // region homebrew _renderHomebrew (entry, textStack, meta, options) { if (entry.oldEntries) { let markerText; if (entry.movedTo) { markerText = "*Homebrew:* The following content has been moved:"; } else if (entry.entries) { markerText = "*Homebrew:* The following content has been replaced:"; } else { markerText = "*Homebrew:* The following content has been removed:"; } textStack[0] += `##### ${markerText}\n`; this._recursiveRender({type: "entries", entries: entry.oldEntries}, textStack, meta, {suffix: "\n"}); } if (entry.entries) { const len = entry.entries.length; if (entry.oldEntries) textStack[0] += `*The replacement is as follows:*\n`; for (let i = 0; i < len; ++i) this._recursiveRender(entry.entries[i], textStack, meta, {suffix: "\n"}); } else if (entry.movedTo) { textStack[0] += `*This content has been moved to ${entry.movedTo}.*\n`; } else { textStack[0] += "*This content has been deleted.*\n"; } } // endregion // region misc _renderCode (entry, textStack, meta, options) { textStack[0] += "\n```\n"; textStack[0] += entry.preformatted; textStack[0] += "\n```\n"; } _renderHr (entry, textStack, meta, options) { textStack[0] += `\n---\n`; } // endregion // region primitives _renderString (entry, textStack, meta, options) { switch (RendererMarkdown._tagRenderMode || 0) { // render tags where possible case 0: { this._renderString_renderMode0(entry, textStack, meta, options); break; } // leave tags as-is case 1: { textStack[0] += entry; break; } // strip tags case 2: { textStack[0] += Renderer.stripTags(entry); break; } } } _renderString_renderMode0 (entry, textStack, meta, options) { const tagSplit = Renderer.splitByTags(entry); const len = tagSplit.length; for (let i = 0; i < len; ++i) { const s = tagSplit[i]; if (!s) continue; if (s.startsWith("{@")) { const [tag, text] = Renderer.splitFirstSpace(s.slice(1, -1)); this._renderString_renderTag(textStack, meta, options, tag, text); } else textStack[0] += s; } } _renderString_renderTag (textStack, meta, options, tag, text) { switch (tag) { // BASIC STYLES/TEXT /////////////////////////////////////////////////////////////////////////////// case "@b": case "@bold": textStack[0] += `**`; this._recursiveRender(text, textStack, meta); textStack[0] += `**`; break; case "@i": case "@italic": textStack[0] += `*`; this._recursiveRender(text, textStack, meta); textStack[0] += `*`; break; case "@s": case "@strike": textStack[0] += `~~`; this._recursiveRender(text, textStack, meta); textStack[0] += `~~`; break; case "@s2": case "@strikeDouble": textStack[0] += `~~`; this._recursiveRender(text, textStack, meta); textStack[0] += `~~`; break; case "@note": textStack[0] += "*"; this._recursiveRender(text, textStack, meta); textStack[0] += "*"; break; case "@atk": textStack[0] += `*${Renderer.attackTagToFull(text)}*`; break; case "@h": textStack[0] += `*Hit:* `; break; // DCs ///////////////////////////////////////////////////////////////////////////////////////////// case "@dc": { const [dcText, displayText] = Renderer.splitTagByPipe(text); textStack[0] += `DC ${displayText || dcText}`; break; } // DICE //////////////////////////////////////////////////////////////////////////////////////////// case "@dice": case "@damage": case "@hit": case "@d20": case "@chance": case "@recharge": case "@coinflip": textStack[0] += Renderer.stripTags(`{${tag} ${text}}`); break; // SCALE DICE ////////////////////////////////////////////////////////////////////////////////////// case "@scaledice": case "@scaledamage": textStack[0] += Renderer.stripTags(`{${tag} ${text}}`); break; // LINKS /////////////////////////////////////////////////////////////////////////////////////////// case "@filter": textStack[0] += Renderer.stripTags(`{${tag} ${text}}`); break; case "@link": case "@5etools": this.__super._renderString_renderTag(textStack, meta, options, tag, text); break; // OTHER HOVERABLES //////////////////////////////////////////////////////////////////////////////// case "@footnote": case "@homebrew": case "@skill": case "@sense": case "@area": case "@cite": textStack[0] += Renderer.stripTags(`{${tag} ${text}}`); break; // HOMEBREW LOADING //////////////////////////////////////////////////////////////////////////////// case "@loader": { // FIXME this does not respect the user's homebrew base URL setting const {name, path} = this._renderString_getLoaderTagMeta(text, {isDefaultUrl: true}); textStack[0] += `[${name}](${path})`; break; } // CONTENT TAGS //////////////////////////////////////////////////////////////////////////////////// case "@book": case "@adventure": textStack[0] += `*${Renderer.stripTags(`{${tag} ${text}}`)}*`; break; case "@deity": textStack[0] += `**${Renderer.stripTags(`{${tag} ${text}}`)}**`; break; default: { switch (tag) { case "@item": { if (this._isSkipStylingItemLinks) textStack[0] += `${Renderer.stripTags(`{${tag} ${text}}`)}`; else textStack[0] += `*${Renderer.stripTags(`{${tag} ${text}}`)}*`; break; } case "@spell": case "@psionic": textStack[0] += `*${Renderer.stripTags(`{${tag} ${text}}`)}*`; break; case "@creature": textStack[0] += `**${Renderer.stripTags(`{${tag} ${text}}`)}**`; break; default: textStack[0] += Renderer.stripTags(`{${tag} ${text}}`); break; } } } } _renderPrimitive (entry, textStack, meta, options) { textStack[0] += `${entry}`; } // endregion // region Static options static async pShowSettingsModal () { RendererMarkdown.checkInit(); const {$modalInner} = UiUtil.getShowModal({ title: "Markdown Settings", cbClose: () => RendererMarkdown.__$wrpSettings.detach(), }); if (!RendererMarkdown.__$wrpSettings) { const _compMarkdownSettings = BaseComponent.fromObject({ _tagRenderMode: RendererMarkdown._tagRenderMode, _isAddColumnBreaks: RendererMarkdown._isAddColumnBreaks, }); const compMarkdownSettings = _compMarkdownSettings.getPod(); const saveMarkdownSettingsDebounced = MiscUtil.debounce(() => StorageUtil.pSet("bookViewSettingsMarkdown", _compMarkdownSettings.toObject()), 100); compMarkdownSettings.addHookAll(() => { Object.assign(RendererMarkdown, compMarkdownSettings.getState()); saveMarkdownSettingsDebounced(); }); const $rows = Object.entries(RendererMarkdown._CONFIG) .map(([k, v]) => { let $ipt; switch (v.type) { case "boolean": { $ipt = ComponentUiUtil.$getCbBool(_compMarkdownSettings, k).addClass("mr-1"); break; } case "enum": { $ipt = ComponentUiUtil.$getSelEnum(_compMarkdownSettings, k, {values: v.values, fnDisplay: v.fnDisplay}); break; } default: throw new Error(`Unhandled input type!`); } return $$`
`; }); RendererMarkdown.__$wrpSettings = $$`
${$rows}
`; } RendererMarkdown.__$wrpSettings.appendTo($modalInner); } // endregion static getSetting (key) { return this[`_${key}`]; } } RendererMarkdown._isInit = false; RendererMarkdown.CHARS_PER_PAGE = 5500; RendererMarkdown.__$wrpSettings = null; RendererMarkdown._TAG_RENDER_MODES = ["Convert to Markdown", "Leave As-Is", "Convert to Text"]; RendererMarkdown._CONFIG = { _tagRenderMode: {default: 0, name: "Tag Handling (@tag)", fnDisplay: ix => RendererMarkdown._TAG_RENDER_MODES[ix], type: "enum", values: [0, 1, 2]}, _isAddColumnBreaks: {default: false, name: "Add GM Binder Column Breaks (\\columnbreak)", type: "boolean"}, _isAddPageBreaks: {default: false, name: "Add GM Binder Page Breaks (\\pagebreak)", type: "boolean"}, }; if (typeof window !== "undefined") window.addEventListener("load", () => RendererMarkdown.pInit()); RendererMarkdown.utils = class { static getPageText (it) { const sourceSub = Renderer.utils.getSourceSubText(it); const baseText = Renderer.utils.isDisplayPage(it.page) ? `**Source:** *${Parser.sourceJsonToAbv(it.source)}${sourceSub}*, page ${it.page}` : ""; const addSourceText = this._getPageText_getAltSourceText(it, "additionalSources", "Additional information from"); const otherSourceText = this._getPageText_getAltSourceText(it, "otherSources", "Also found in"); const externalSourceText = this._getPageText_getAltSourceText(it, "externalSources", "External sources:"); return `${[baseText, addSourceText, otherSourceText, externalSourceText].filter(it => it).join(". ")}${baseText && (addSourceText || otherSourceText || externalSourceText) ? "." : ""}`; } static _getPageText_getAltSourceText (it, prop, introText) { if (!it[prop] || !it[prop].length) return ""; return `${introText} ${it[prop].map(as => { if (as.entry) return Renderer.get().render(as.entry); else return `*${Parser.sourceJsonToAbv(as.source)}*${Renderer.utils.isDisplayPage(as.page) ? `, page ${as.page}` : ""}`; }).join("; ")}`; } /* -------------------------------------------- */ static compact = class { // TODO(Future) nicely pad widths (render as table?) static getRenderedAbilityScores (ent, {prefix = ""} = "") { return `${prefix}|${Parser.ABIL_ABVS.map(it => `${it.toUpperCase()}|`).join("")} ${prefix}|:---:|:---:|:---:|:---:|:---:|:---:| ${prefix}|${Parser.ABIL_ABVS.map(ab => ent[ab] == null ? `\u2014|` : `${ent[ab]} (${Parser.getAbilityModifier(ent[ab])})|`).join("")}`; } }; /* -------------------------------------------- */ static withMetaDepth (depth, opts, fn) { opts.meta ||= {}; const depthCached = opts.meta.depth; opts.meta.depth = depth; const out = fn(); opts.meta.depth = depthCached; return out; } static getNormalizedNewlines (str) { return str.replace(/\n\n+/g, "\n\n"); } }; RendererMarkdown.monster = class { static getCompactRenderedString (mon, opts = {}) { const legendaryGroup = opts.legendaryGroup; const meta = opts.meta || {}; let addedStatblockInline; if (!meta.isStatblockInlineMonster) { meta.isStatblockInlineMonster = true; addedStatblockInline = true; } const monTypes = Parser.monTypeToFullObj(mon.type); RendererMarkdown.get().isSkipStylingItemLinks = true; const acPart = mon.ac == null ? "\u2014" : Parser.acToFull(mon.ac, RendererMarkdown.get()); RendererMarkdown.get().isSkipStylingItemLinks = false; const resourcePart = mon.resource?.length ? mon.resource .map(res => `\n>- **${res.name}** ${Renderer.monster.getRenderedResource(res, true)}`) .join("") : ""; const abilityScorePart = RendererMarkdown.utils.compact.getRenderedAbilityScores(mon, {prefix: ">"}); const savePart = mon.save ? `\n>- **Saving Throws** ${Object.keys(mon.save).sort(SortUtil.ascSortAtts).map(it => RendererMarkdown.monster.getSave(it, mon.save[it])).join(", ")}` : ""; const skillPart = mon.skill ? `\n>- **Skills** ${RendererMarkdown.monster.getSkillsString(mon)}` : ""; const damVulnPart = mon.vulnerable ? `\n>- **Damage Vulnerabilities** ${Parser.getFullImmRes(mon.vulnerable, {isPlainText: true})}` : ""; const damResPart = mon.resist ? `\n>- **Damage Resistances** ${Parser.getFullImmRes(mon.resist, {isPlainText: true})}` : ""; const damImmPart = mon.immune ? `\n>- **Damage Immunities** ${Parser.getFullImmRes(mon.immune, {isPlainText: true})}` : ""; const condImmPart = mon.conditionImmune ? `\n>- **Condition Immunities** ${Parser.getFullCondImm(mon.conditionImmune, {isPlainText: true})}` : ""; const sensePart = !opts.isHideSenses ? `\n>- **Senses** ${mon.senses ? `${Renderer.utils.getRenderedSenses(mon.senses, true)}, ` : ""}passive Perception ${mon.passive || "\u2014"}` : ""; const languagePart = !opts.isHideLanguages ? `\n>- **Languages** ${Renderer.monster.getRenderedLanguages(mon.languages)}` : ""; const fnGetSpellTraits = RendererMarkdown.monster.getSpellcastingRenderedTraits.bind(RendererMarkdown.monster, meta); const traitArray = Renderer.monster.getOrderedTraits(mon, {fnGetSpellTraits}); const actionArray = Renderer.monster.getOrderedActions(mon, {fnGetSpellTraits}); const bonusActionArray = Renderer.monster.getOrderedBonusActions(mon, {fnGetSpellTraits}); const reactionArray = Renderer.monster.getOrderedReactions(mon, {fnGetSpellTraits}); const traitsPart = traitArray?.length ? `\n${RendererMarkdown.monster._getRenderedSection({prop: "trait", entries: traitArray, depth: 1, meta, prefix: ">"})}` : ""; const actionsPart = RendererMarkdown.monster.getRenderedSection({arr: actionArray, ent: mon, prop: "action", title: "Actions", meta, prefix: ">"}); const bonusActionsPart = RendererMarkdown.monster.getRenderedSection({arr: bonusActionArray, ent: mon, prop: "bonus", title: "Bonus Actions", meta, prefix: ">"}); const reactionsPart = RendererMarkdown.monster.getRenderedSection({arr: reactionArray, ent: mon, prop: "reaction", title: "Reactions", meta, prefix: ">"}); const legendaryActionsPart = mon.legendary ? `${RendererMarkdown.monster._getRenderedSectionHeader({mon, title: "Legendary Actions", prop: "legendary", prefix: ">"})}>${Renderer.monster.getLegendaryActionIntro(mon, {renderer: RendererMarkdown.get()})}\n>\n${RendererMarkdown.monster._getRenderedLegendarySection(mon.legendary, 1, meta)}` : ""; const mythicActionsPart = mon.mythic ? `${RendererMarkdown.monster._getRenderedSectionHeader({mon, title: "Mythic Actions", prop: "mythic", prefix: ">"})}>${Renderer.monster.getSectionIntro(mon, {renderer: RendererMarkdown.get(), prop: "mythic"})}\n>\n${RendererMarkdown.monster._getRenderedLegendarySection(mon.mythic, 1, meta)}` : ""; const legendaryGroupLairPart = legendaryGroup?.lairActions ? `\n>### Lair Actions\n${RendererMarkdown.monster._getRenderedSection({prop: "lairaction", entries: legendaryGroup.lairActions, depth: -1, meta, prefix: ">"})}` : ""; const legendaryGroupRegionalPart = legendaryGroup?.regionalEffects ? `\n>### Regional Effects\n${RendererMarkdown.monster._getRenderedSection({prop: "regionaleffect", entries: legendaryGroup.regionalEffects, depth: -1, meta, prefix: ">"})}` : ""; const footerPart = mon.footer ? `\n${RendererMarkdown.monster._getRenderedSectionEntries({sectionEntries: mon.footer, sectionDepth: 0, meta, prefix: ">"})}` : ""; const unbreakablePart = `___ >## ${mon._displayName || mon.name} >*${mon.level ? `${Parser.getOrdinalForm(mon.level)}-level ` : ""}${Renderer.utils.getRenderedSize(mon.size)} ${monTypes.asText}${mon.alignment ? `, ${mon.alignmentPrefix ? RendererMarkdown.get().render(mon.alignmentPrefix) : ""}${Parser.alignmentListToFull(mon.alignment)}` : ""}* >___ >- **Armor Class** ${acPart} >- **Hit Points** ${mon.hp == null ? "\u2014" : Renderer.monster.getRenderedHp(mon.hp, true)}${resourcePart} >- **Speed** ${Parser.getSpeedString(mon)} >___ ${abilityScorePart} >___${savePart}${skillPart}${damVulnPart}${damResPart}${damImmPart}${condImmPart}${sensePart}${languagePart} >- **Challenge** ${mon.cr ? Parser.monCrToFull(mon.cr, {isMythic: !!mon.mythic}) : "\u2014"} ${mon.pbNote || Parser.crToNumber(mon.cr) < VeCt.CR_CUSTOM ? `>- **Proficiency Bonus** ${mon.pbNote ?? UiUtil.intToBonus(Parser.crToPb(mon.cr), {isPretty: true})}` : ""} >___`; let breakablePart = `${traitsPart}${actionsPart}${bonusActionsPart}${reactionsPart}${legendaryActionsPart}${mythicActionsPart}${legendaryGroupLairPart}${legendaryGroupRegionalPart}${footerPart}`; if (RendererMarkdown.getSetting("isAddColumnBreaks")) { let charAllowanceFirstCol = 2200 - unbreakablePart.length; const breakableLines = breakablePart.split("\n"); for (let i = 0; i < breakableLines.length; ++i) { const l = breakableLines[i]; if ((charAllowanceFirstCol -= l.length) < 0) { breakableLines.splice(i, 0, ">", "> \\columnbreak", ">"); break; } } breakablePart = breakableLines.join("\n"); } const str = `${unbreakablePart}${breakablePart}`; const monRender = str.trim().split("\n").map(it => it.trim() ? it : `>`).join("\n"); const out = `\n${monRender}\n\n`; if (addedStatblockInline) delete meta.isStatblockInlineMonster; return out; } static getSave (attr, mod) { if (attr === "special") return Renderer.stripTags(mod); return `${attr.uppercaseFirst()} ${mod}`; } static getSkillsString (mon) { function doSortMapJoinSkillKeys (obj, keys, joinWithOr) { const toJoin = keys.sort(SortUtil.ascSort).map(s => `${s.toTitleCase()} ${obj[s]}`); return joinWithOr ? toJoin.joinConjunct(", ", " or ") : toJoin.join(", "); } const skills = doSortMapJoinSkillKeys(mon.skill, Object.keys(mon.skill).filter(k => k !== "other" && k !== "special")); if (mon.skill.other || mon.skill.special) { const others = mon.skill.other && mon.skill.other.map(it => { if (it.oneOf) { return `plus one of the following: ${doSortMapJoinSkillKeys(it.oneOf, Object.keys(it.oneOf), true)}`; } throw new Error(`Unhandled monster "other" skill properties!`); }); const special = mon.skill.special && Renderer.stripTags(mon.skill.special); return [skills, others, special].filter(Boolean).join(", "); } else return skills; } static getRenderedSection ({arr, ent, prop, title, meta, prefix = ""}) { if (!arr?.length) return ""; return `${RendererMarkdown.monster._getRenderedSectionHeader({mon: ent, title, prop, prefix})}${RendererMarkdown.monster._getRenderedSection({mon: ent, prop, entries: arr, depth: 1, meta, prefix})}`; } static _getRenderedSectionHeader ({mon, title, prop, prefix}) { const propNote = `${prop}Note`; const ptTitle = `\n${prefix}### ${title}`; if (!mon[propNote]) return `${ptTitle}\n`; return `${ptTitle} (${mon[propNote]})\n`; } static _getRenderedSection ({mon = null, prop, entries, depth = 1, meta, prefix}) { const ptHeader = mon ? Renderer.monster.getSectionIntro(mon, {renderer: RendererMarkdown.get(), prop}) : ""; return `${ptHeader ? `${prefix}${ptHeader}\n${prefix}\n` : ""}${this._getRenderedSectionEntries({sectionEntries: entries, sectionDepth: depth, meta, prefix})}`; } static _getRenderedSectionEntries ({sectionEntries, sectionDepth, meta, prefix}) { const renderer = RendererMarkdown.get(); const renderStack = [""]; sectionEntries.forEach(entry => { if (entry.rendered) renderStack[0] += entry.rendered; else { const cacheDepth = meta.depth; meta.depth = sectionDepth + 1; renderer._recursiveRender(entry, renderStack, meta, {prefix}); meta.depth = cacheDepth; } }); return renderStack.join(""); } static _getRenderedLegendarySection (sectionEntries, sectionLevel, meta) { const renderer = RendererMarkdown.get(); const renderStack = [""]; const cpy = MiscUtil.copyFast(sectionEntries).map(it => { if (it.name && it.entries) { it.name = `${it.name}.`; it.type = it.type || "item"; } return it; }); const toRender = {type: "list", style: "list-hang-notitle", items: cpy}; const cacheDepth = meta.depth; meta.depth = sectionLevel; renderer._recursiveRender(toRender, renderStack, meta, {prefix: ">"}); meta.depth = cacheDepth; return renderStack.join(""); } static getSpellcastingRenderedTraits (meta, mon, displayAsProp = "trait") { const renderer = RendererMarkdown.get(); const out = []; const cacheDepth = meta.depth; meta.depth = 2; (mon.spellcasting || []).filter(it => (it.displayAs || "trait") === displayAsProp).forEach(entry => { entry.type = entry.type || "spellcasting"; const renderStack = [""]; renderer._recursiveRender(entry, renderStack, meta, {prefix: ">"}); const rendered = renderStack.join(""); if (!rendered.length) return; out.push({name: entry.name, rendered}); }); meta.depth = cacheDepth; return out; } // region Exporting static async pGetMarkdownDoc (monsters) { const asEntries = (await Promise.all(monsters .map(async (mon, i) => { const monEntry = ({type: "statblockInline", dataType: "monster", data: mon}); const fluff = await Renderer.monster.pGetFluff(mon); const fluffEntries = (fluff || {}).entries || []; RendererMarkdown.get().setFirstSection(true); const fluffText = fluffEntries.map(ent => RendererMarkdown.get().render(ent)).join("\n\n"); const out = [monEntry]; const isAddPageBreaks = RendererMarkdown.getSetting("isAddPageBreaks"); if (fluffText) { // Insert a page break before every fluff section if (isAddPageBreaks) out.push("", "\\pagebreak", ""); out.push(`## ${mon.name}`); // Split into runs of { if ((charLimit -= l.length) < 0) { out.push(stack.join("\n")); if (isAddPageBreaks) out.push("", "\\pagebreak", ""); stack = []; charLimit = RendererMarkdown.CHARS_PER_PAGE - l.length; } stack.push(l); }); if (stack.length) out.push(stack.join("\n")); } // Insert a page break after every creature statblock or fluff section if (i !== monsters.length - 1 && isAddPageBreaks) out.push("", "\\pagebreak", ""); return out; }))) .flat(); return RendererMarkdown.get().render({entries: asEntries}); } // endregion }; RendererMarkdown.spell = class { static getCompactRenderedString (sp, opts = {}) { const meta = opts.meta || {}; const subStack = [""]; subStack[0] += `#### ${sp._displayName || sp.name} *${Parser.spLevelSchoolMetaToFull(sp.level, sp.school, sp.meta, sp.subschools)}* ___ - **Casting Time:** ${Parser.spTimeListToFull(sp.time)} - **Range:** ${Parser.spRangeToFull(sp.range)} - **Components:** ${Parser.spComponentsToFull(sp.components, sp.level, {isPlainText: true})} - **Duration:** ${Parser.spDurationToFull(sp.duration)} ---\n`; const cacheDepth = meta.depth; meta.depth = 2; RendererMarkdown.get().recursiveRender({entries: sp.entries}, subStack, meta, {suffix: "\n"}); if (sp.entriesHigherLevel) { RendererMarkdown.get().recursiveRender({entries: sp.entriesHigherLevel}, subStack, meta, {suffix: "\n"}); } meta.depth = cacheDepth; const spellRender = subStack.join("").trim(); return `\n${spellRender}\n\n`; } }; RendererMarkdown.item = class { static getCompactRenderedString (item, opts = {}) { const meta = opts.meta || {}; const subStack = [""]; const [damage, damageType, propertiesTxt] = Renderer.item.getDamageAndPropertiesText(item, {renderer: RendererMarkdown.get()}); const [typeRarityText, subTypeText, tierText] = RendererMarkdown.item.getTypeRarityAndAttunementText(item); const typeRarityTierValueWeight = [typeRarityText, subTypeText, tierText, Parser.itemValueToFullMultiCurrency(item), Parser.itemWeightToFull(item)].filter(Boolean).join(", ").uppercaseFirst(); const damageProperties = [damage, damageType, propertiesTxt].filter(Boolean).join(" ").uppercaseFirst(); const ptSubtitle = [typeRarityTierValueWeight, damageProperties].filter(Boolean).join("\n\n"); subStack[0] += `#### ${item._displayName || item.name}${ptSubtitle ? `\n\n${ptSubtitle}` : ""}\n\n${ptSubtitle ? `---\n\n` : ""}`; if (Renderer.item.hasEntries(item)) { const cacheDepth = meta.depth; if (item._fullEntries || (item.entries?.length)) { const entry = {type: "entries", entries: item._fullEntries || item.entries}; meta.depth = 1; RendererMarkdown.get().recursiveRender(entry, subStack, meta, {suffix: "\n"}); } if (item._fullAdditionalEntries || item.additionalEntries) { const additionEntries = {type: "entries", entries: item._fullAdditionalEntries || item.additionalEntries}; meta.depth = 1; RendererMarkdown.get().recursiveRender(additionEntries, subStack, meta, {suffix: "\n"}); } meta.depth = cacheDepth; } const itemRender = subStack.join("").trim(); return `\n${itemRender}\n\n`; } static getTypeRarityAndAttunementText (item) { const typeRarity = [ item._typeHtml === "other" ? "" : $(`
`).html(item._typeHtml).text(), (item.rarity && Renderer.item.doRenderRarity(item.rarity) ? item.rarity : ""), ].filter(Boolean).join(", "); return [ item.reqAttune ? `${typeRarity} ${item._attunement}` : typeRarity, item._subTypeHtml || "", item.tier ? `${item.tier} tier` : "", ]; } }; RendererMarkdown.legendaryGroup = class { static getCompactRenderedString (lg, opts = {}) { const meta = opts.meta || {}; const subEntry = Renderer.legendaryGroup.getSummaryEntry(lg); if (!subEntry) return ""; const subStack = [""]; subStack[0] += `## ${lg._displayName || lg.name}`; RendererMarkdown.get().recursiveRender(subEntry, subStack, meta, {suffix: "\n"}); const lgRender = subStack.join("").trim(); return `\n${lgRender}\n\n`; } }; RendererMarkdown.table = class { static getCompactRenderedString (tbl, opts = {}) { const meta = opts.meta || {}; const subStack = [""]; RendererMarkdown.get().recursiveRender(tbl, subStack, meta, {suffix: "\n"}); return `\n${subStack.join("").trim()}\n\n`; } }; RendererMarkdown.tableGroup = class { static getCompactRenderedString (tbl, opts = {}) { return RendererMarkdown.table.getCompactRenderedString(tbl, opts); } }; RendererMarkdown.cult = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ Renderer.cultboon.getCultRenderableEntriesMeta(ent)?.listGoalsCultistsSpells, ...ent.entries, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(2, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.boon = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ Renderer.cultboon.getBoonRenderableEntriesMeta(ent)?.listBenefits, ...ent.entries, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(1, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.charoption = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ RendererMarkdown.generic.getRenderedPrerequisite(ent), Renderer.charoption.getCharoptionRenderableEntriesMeta(ent)?.entryOptionType, ...ent.entries, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); } }; RendererMarkdown.action = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.generic.getCompactRenderedString(ent, opts); } }; RendererMarkdown.condition = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.generic.getCompactRenderedString(ent, opts); } }; RendererMarkdown.disease = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.generic.getCompactRenderedString(ent, opts); } }; RendererMarkdown.status = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.generic.getCompactRenderedString(ent, opts); } }; RendererMarkdown.race = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ { type: "list", style: "list-hang-notitle", items: [ { type: "item", name: "Ability Scores", entry: Renderer.getAbilityData(ent.ability).asText, }, { type: "item", name: "Size", entry: Renderer.race.getRenderedSize(ent), }, { type: "item", name: "Speed", entry: Parser.getSpeedString(ent), }, ], }, Renderer.race.getRaceRenderableEntriesMeta(ent)?.entryMain, ] .filter(Boolean); const entFull = { ...ent, entries, }; const ptHeightAndWeight = this._getHeightAndWeightPart(ent); return [ RendererMarkdown.utils.withMetaDepth(1, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }), ptHeightAndWeight ? `---\n\n${ptHeightAndWeight}` : null, ] .filter(Boolean) .join(""); } static _getHeightAndWeightPart (race) { if (!race.heightAndWeight) return null; if (race._isBaseRace) return null; return RendererMarkdown.get().render({entries: Renderer.race.getHeightAndWeightEntries(race, {isStatic: true})}); } }; RendererMarkdown.feat = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ Renderer.feat.getJoinedCategoryPrerequisites( ent.category, RendererMarkdown.generic.getRenderedPrerequisite(ent), ), Renderer.utils.getRepeatableEntry(ent), Renderer.feat.getFeatRendereableEntriesMeta(ent)?.entryMain, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(2, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.optionalfeature = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ RendererMarkdown.generic.getRenderedPrerequisite(ent), Renderer.optionalfeature.getCostEntry(ent), {entries: ent.entries}, Renderer.optionalfeature.getPreviouslyPrintedEntry(ent), Renderer.optionalfeature.getTypeEntry(ent), ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(1, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.background = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.generic.getCompactRenderedString(ent, opts); } }; RendererMarkdown.object = class { static getCompactRenderedString (ent, opts = {}) { const entriesMeta = Renderer.object.getObjectRenderableEntriesMeta(ent); const entries = [ entriesMeta.entrySize, ...Renderer.object.RENDERABLE_ENTRIES_PROP_ORDER__ATTRIBUTES .filter(prop => entriesMeta[prop]) .map(prop => entriesMeta[prop]), ent.entries ? {entries: ent.entries} : null, ent.actionEntries ? {entries: ent.actionEntries} : null, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(2, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.trap = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.traphazard.getCompactRenderedString(ent, opts); } }; RendererMarkdown.hazard = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.traphazard.getCompactRenderedString(ent, opts); } }; RendererMarkdown.traphazard = class { static getCompactRenderedString (ent, opts = {}) { const ptHead = RendererMarkdown.utils.withMetaDepth(2, opts, () => { const subtitle = Renderer.traphazard.getSubtitle(ent); const entries = [ subtitle ? `{@i ${subtitle}}` : null, {entries: ent.entries}, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); const ptAttributes = RendererMarkdown.utils.withMetaDepth(1, opts, () => { const entriesMeta = Renderer.trap.getTrapRenderableEntriesMeta(ent); return RendererMarkdown.generic.getRenderedSubEntry({type: "entries", entries: entriesMeta.entriesAttributes}, opts); }); return ptHead + ptAttributes; } }; RendererMarkdown.deity = class { static getCompactRenderedString (ent, opts = {}) { const entriesMeta = Renderer.deity.getDeityRenderableEntriesMeta(ent); const entries = [ ...entriesMeta.entriesAttributes, ent.entries ? {entries: ent.entries} : null, ] .filter(Boolean); const entFull = { ...ent, name: ent.title ? [ent.name, ent.title.toTitleCase()].join(", ") : ent.name, entries, }; return RendererMarkdown.utils.withMetaDepth(1, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.language = class { static getCompactRenderedString (ent, opts = {}) { const entriesMeta = Renderer.language.getLanguageRenderableEntriesMeta(ent); const entries = [ entriesMeta.entryType, entriesMeta.entryTypicalSpeakers, entriesMeta.entryScript, entriesMeta.entriesContent ? {entries: entriesMeta.entriesContent} : null, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); } }; RendererMarkdown.reward = class { static getCompactRenderedString (ent, opts = {}) { const entriesMeta = Renderer.reward.getRewardRenderableEntriesMeta(ent); const entries = [ {entries: entriesMeta.entriesContent}, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(1, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.psionic = class { static getCompactRenderedString (ent, opts = {}) { const entriesMeta = Renderer.psionic.getPsionicRenderableEntriesMeta(ent); const entries = [ entriesMeta.entryTypeOrder, entriesMeta.entryContent, entriesMeta.entryFocus, ...(entriesMeta.entriesModes || []), ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(2, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } }; RendererMarkdown.vehicle = class { static getCompactRenderedString (ent, opts = {}) { if (ent.upgradeType) return RendererMarkdown.vehicleUpgrade.getCompactRenderedString(ent, opts); ent.vehicleType ||= "SHIP"; switch (ent.vehicleType) { case "SHIP": return RendererMarkdown.vehicle._getRenderedString_ship(ent, opts); case "SPELLJAMMER": return RendererMarkdown.vehicle._getRenderedString_spelljammer(ent, opts); case "INFWAR": return RendererMarkdown.vehicle._getRenderedString_infwar(ent, opts); case "CREATURE": return RendererMarkdown.monster.getCompactRenderedString(ent, {...opts, isHideLanguages: true, isHideSenses: true, page: UrlUtil.PG_VEHICLES}); case "OBJECT": return RendererMarkdown.object.getCompactRenderedString(ent, {...opts, page: UrlUtil.PG_VEHICLES}); default: throw new Error(`Unhandled vehicle type "${ent.vehicleType}"`); } } static _getLinesRendered_traits ({ent, renderer}) { const traitArray = Renderer.monster.getOrderedTraits(ent); return [ ent.trait ? `### Traits` : null, ...(traitArray || []) .map(entry => renderer.render(entry, 2)), ]; } static ship = class { static getCrewCargoPaceSection_ (ent, {entriesMetaShip = null} = {}) { entriesMetaShip ||= Renderer.vehicle.ship.getVehicleShipRenderableEntriesMeta(ent); return Renderer.vehicle.ship.PROPS_RENDERABLE_ENTRIES_ATTRIBUTES .map(prop => RendererMarkdown.get().render(entriesMetaShip[prop]).trim()) .join("\n\n"); } static getControlSection_ ({entry}) { const renderer = RendererMarkdown.get(); const entriesMetaSection = Renderer.vehicle.ship.getSectionHpEntriesMeta_({entry}); return [ `### Control: ${entry.name}`, entriesMetaSection.entryArmorClass ? renderer.render(entriesMetaSection.entryArmorClass) : null, entriesMetaSection.entryHitPoints ? renderer.render(entriesMetaSection.entryHitPoints) : null, RendererMarkdown.get().render({entries: entry.entries}), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); } static getMovementSection_ ({entry}) { const renderer = RendererMarkdown.get(); const entriesMetaSection = Renderer.vehicle.ship.getSectionHpEntriesMeta_({entry}); return [ `### ${entry.isControl ? `Control and ` : ""}Movement: ${entry.name}`, entriesMetaSection.entryArmorClass ? renderer.render(entriesMetaSection.entryArmorClass) : null, entriesMetaSection.entryHitPoints ? renderer.render(entriesMetaSection.entryHitPoints) : null, ...(entry.locomotion || []) .map(entry => RendererMarkdown.get().render(Renderer.vehicle.ship.getLocomotionEntries(entry))), ...(entry.speed || []) .map(entry => RendererMarkdown.get().render(Renderer.vehicle.ship.getSpeedEntries(entry))), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); } static getWeaponSection_ ({entry}) { const renderer = RendererMarkdown.get(); const entriesMetaSection = Renderer.vehicle.ship.getSectionHpEntriesMeta_({entry, isEach: !!entry.count}); return [ `### Weapons: ${entry.name}${entry.count ? ` (${entry.count})` : ""}`, entriesMetaSection.entryArmorClass ? renderer.render(entriesMetaSection.entryArmorClass) : null, entriesMetaSection.entryHitPoints ? renderer.render(entriesMetaSection.entryHitPoints) : null, RendererMarkdown.get().render({entries: entry.entries}), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); } static getOtherSection_ ({entry}) { const renderer = RendererMarkdown.get(); const entriesMetaSection = Renderer.vehicle.ship.getSectionHpEntriesMeta_({entry}); return [ `### ${entry.name}`, entriesMetaSection.entryArmorClass ? renderer.render(entriesMetaSection.entryArmorClass) : null, entriesMetaSection.entryHitPoints ? renderer.render(entriesMetaSection.entryHitPoints) : null, RendererMarkdown.get().render({entries: entry.entries}), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); } }; static _getRenderedString_ship (ent, opts) { const renderer = RendererMarkdown.get(); const entriesMeta = Renderer.vehicle.getVehicleRenderableEntriesMeta(ent); const entriesMetaShip = Renderer.vehicle.ship.getVehicleShipRenderableEntriesMeta(ent); const entriesMetaSectionHull = ent.hull ? Renderer.vehicle.ship.getSectionHpEntriesMeta_({entry: ent.hull}) : null; const ptsJoined = [ `## ${ent.name}`, renderer.render(entriesMetaShip.entrySizeDimensions), RendererMarkdown.vehicle.ship.getCrewCargoPaceSection_(ent, {entriesMetaShip}), RendererMarkdown.utils.compact.getRenderedAbilityScores(ent), entriesMeta.entryDamageVulnerabilities ? renderer.render(entriesMeta.entryDamageVulnerabilities) : null, entriesMeta.entryDamageResistances ? renderer.render(entriesMeta.entryDamageResistances) : null, entriesMeta.entryDamageImmunities ? renderer.render(entriesMeta.entryDamageImmunities) : null, entriesMeta.entryConditionImmunities ? renderer.render(entriesMeta.entryConditionImmunities) : null, ent.action ? "### Actions" : null, ent.action ? renderer.render({entries: ent.action}) : null, ...(entriesMetaShip.entriesOtherActions || []) .map(entry => RendererMarkdown.vehicle.ship.getOtherSection_({entry})), ent.hull ? "### Hull" : null, entriesMetaSectionHull?.entryArmorClass ? renderer.render(entriesMetaSectionHull.entryArmorClass) : null, entriesMetaSectionHull?.entryHitPoints ? renderer.render(entriesMetaSectionHull.entryHitPoints) : null, ...this._getLinesRendered_traits({ent, renderer}), ...(ent.control || []) .map(entry => RendererMarkdown.vehicle.ship.getControlSection_({entry})), ...(ent.movement || []) .map(entry => RendererMarkdown.vehicle.ship.getMovementSection_({entry})), ...(ent.weapon || []) .map(entry => RendererMarkdown.vehicle.ship.getWeaponSection_({entry})), ...(entriesMetaShip.entriesOtherOthers || []) .map(entry => RendererMarkdown.vehicle.ship.getOtherSection_({entry})), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); return ptsJoined .trim(); } static spelljammer = class { static getWeaponSection_ ({entry}) { const renderer = RendererMarkdown.get(); const entriesMetaSectionWeapon = Renderer.vehicle.spelljammer.getSectionWeaponEntriesMeta(entry); const entriesMetaSectionHpCost = Renderer.vehicle.spelljammer.getSectionHpCostEntriesMeta(entry); return [ `### ${entriesMetaSectionWeapon.entryName}`, entriesMetaSectionHpCost.entryArmorClass ? renderer.render(entriesMetaSectionHpCost.entryArmorClass) : null, entriesMetaSectionHpCost.entryHitPoints ? renderer.render(entriesMetaSectionHpCost.entryHitPoints) : null, entriesMetaSectionHpCost.entryCost ? renderer.render(entriesMetaSectionHpCost.entryCost) : null, RendererMarkdown.get().render({entries: entry.entries}), ...(entry.action || []).map(act => renderer.render(act, 2)), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); } }; static _getRenderedString_spelljammer (ent, opts) { const renderer = RendererMarkdown.get(); const entriesMetaSpelljammer = Renderer.vehicle.spelljammer.getVehicleSpelljammerRenderableEntriesMeta(ent); const ptsJoined = [ `## ${ent.name}`, renderer.render(entriesMetaSpelljammer.entryTableSummary), ...(ent.weapon || []) .map(entry => RendererMarkdown.vehicle.spelljammer.getWeaponSection_({entry})), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); return ptsJoined .trim(); } static _getRenderedString_infwar (ent, opts) { opts.meta ||= {}; const renderer = RendererMarkdown.get(); const entriesMeta = Renderer.vehicle.getVehicleRenderableEntriesMeta(ent); const entriesMetaInfwar = Renderer.vehicle.infwar.getVehicleInfwarRenderableEntriesMeta(ent); const reactionArray = Renderer.monster.getOrderedReactions(ent); const ptsJoined = [ `## ${ent.name}`, renderer.render(entriesMetaInfwar.entrySizeWeight), ...Renderer.vehicle.infwar.PROPS_RENDERABLE_ENTRIES_ATTRIBUTES .map(prop => renderer.render(entriesMetaInfwar[prop])), renderer.render(entriesMetaInfwar.entrySpeedNote), RendererMarkdown.utils.compact.getRenderedAbilityScores(ent), entriesMeta.entryDamageVulnerabilities ? renderer.render(entriesMeta.entryDamageVulnerabilities) : null, entriesMeta.entryDamageResistances ? renderer.render(entriesMeta.entryDamageResistances) : null, entriesMeta.entryDamageImmunities ? renderer.render(entriesMeta.entryDamageImmunities) : null, entriesMeta.entryConditionImmunities ? renderer.render(entriesMeta.entryConditionImmunities) : null, ...this._getLinesRendered_traits({ent, renderer}), RendererMarkdown.monster.getRenderedSection({arr: ent.actionStation, ent, prop: "actionStation", title: "Action Stations", meta: opts.meta}), RendererMarkdown.monster.getRenderedSection({arr: reactionArray, ent, prop: "reaction", title: "Reactions", meta: opts.meta}), ] .map(it => it != null ? it.trim() : it) .filter(Boolean) .join("\n\n"); return ptsJoined .trim(); } }; RendererMarkdown.vehicleUpgrade = class { static getCompactRenderedString (ent, opts = {}) { const entries = [ RendererMarkdown.vehicleUpgrade.getUpgradeSummary(ent), {entries: ent.entries}, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.utils.withMetaDepth(1, opts, () => { return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); } static getUpgradeSummary (ent) { const out = [ ent.upgradeType ? ent.upgradeType.map(t => Parser.vehicleTypeToFull(t)) : null, ent.prerequisite ? Renderer.utils.prerequisite.getHtml(ent.prerequisite, {isTextOnly: true}) : null, ] .filter(Boolean) .join(", "); return out ? `{@i ${out}}` : null; } }; RendererMarkdown.recipe = class { static getCompactRenderedString (ent, opts = {}) { const entriesMeta = Renderer.recipe.getRecipeRenderableEntriesMeta(ent); const ptHead = RendererMarkdown.utils.withMetaDepth(0, opts, () => { const entries = [ ...(entriesMeta.entryMetasTime || []) .map(({entryName, entryContent}) => `${entryName} ${entryContent}`), entriesMeta.entryMakes, entriesMeta.entryServes, entriesMeta.entryIngredients, ] .filter(Boolean); const entFull = { ...ent, entries, }; return RendererMarkdown.generic.getCompactRenderedString(entFull, opts); }); const ptInstructions = RendererMarkdown.utils.withMetaDepth(2, opts, () => { return RendererMarkdown.generic.getRenderedSubEntry(entriesMeta.entryInstructions, opts); }); const out = [ ptHead, entriesMeta.entryEquipment ? RendererMarkdown.get().render(entriesMeta.entryEquipment) : null, entriesMeta.entryCooksNotes ? RendererMarkdown.get().render(entriesMeta.entryCooksNotes) : null, ptInstructions, ] .filter(Boolean) .join("\n\n"); return RendererMarkdown.utils.getNormalizedNewlines(out); } }; RendererMarkdown.variantrule = class { static getCompactRenderedString (ent, opts = {}) { return RendererMarkdown.generic.getCompactRenderedString(ent, opts); } }; RendererMarkdown.generic = class { static getCompactRenderedString (ent, opts = {}) { const subStack = [""]; subStack[0] += `## ${ent._displayName || ent.name}\n\n`; ent.entries.forEach(entry => { RendererMarkdown.generic.getRenderedSubEntry(entry, opts, {subStack}); subStack[0] += "\n\n"; }); return `\n${RendererMarkdown.utils.getNormalizedNewlines(subStack.join("").trim())}\n\n`; } static getRenderedSubEntry (entry, opts = {}, {subStack = null} = {}) { const meta = opts.meta || {}; subStack ||= [""]; RendererMarkdown.get() .recursiveRender(entry, subStack, meta, {suffix: "\n"}); return subStack.join(""); } static getRenderedPrerequisite (ent) { const out = Renderer.utils.prerequisite.getHtml(ent.prerequisite, {isTextOnly: true, isSkipPrefix: true}); return out ? `Prerequisite: ${out}` : ""; } }; RendererMarkdown.hover = class { static getFnRenderCompact (prop) { return RendererMarkdown[prop]?.getCompactRenderedString?.bind(RendererMarkdown[prop]); } }; class MarkdownConverter { static getEntries (mdStr) { mdStr = mdStr.trim(); if (!mdStr) return []; mdStr = this._getCleanGmBinder(mdStr); const buf = mdStr.split("\n").map(line => line.trimEnd()); this._coalesceCreatures(buf); this._convertCreatures(buf); this._coalesceInsetsReadalouds(buf); this._convertInsetsReadalouds(buf); this._coalesceTables(buf); this._convertTables(buf); this._coalesceLists(buf); this._convertLists(buf); this._coalesceHeaders(buf); this._convertInlineStyling(buf); this._cleanEmptyLines(buf); this._cleanEntries(buf); return buf; } static _getCleanGmBinder (mdStr) { // Replace any GMB-specific markers mdStr = mdStr.replace(/(^|\n)\s*\\(pagebreakNum|pagebreak|columnbreak)/gi, ""); // Scrub HTML try { const $jq = $(`
${mdStr}
`); $jq.find("*").remove(); mdStr = $jq.text(); } catch (e) { setTimeout(() => { throw e; }); } return mdStr; } static _coalesceCreatures (buf) { for (let i = 0; i < buf.length; ++i) { const line = buf[i].trim(); if (line === "___" || line === "---") { let j = 1; // Skip forwards until we run out of lines, or until we hit a line that isn't part of the block for (; i + j < buf.length; ++j) { const nxt = buf[i + j]; if (!nxt || !nxt.startsWith(">")) break; } const creatureLines = buf.slice(i, i + j); // Remove any creature markers with no following content if (creatureLines.length === 1) { buf.splice(i, 1); i--; } else buf.splice(i, j, {mdType: "creature", lines: creatureLines}); } } } static _convertCreatures (buf) { for (let i = 0; i < buf.length; ++i) { const line = buf[i]; if (typeof line === "string") continue; if (line.mdType === "creature") { buf[i] = { type: "inset", name: "(To convert creature stat blocks, please use the Text Converter utility)", entries: line.lines.slice(1).map(it => it.slice(1).trim()), }; } } } static _coalesce_getLastH5Index (line, i, curCaptionIx) { if (typeof line === "string") { if (line.trim()) { if (line.startsWith("##### ")) return i; else return -1; } } else return -1; return curCaptionIx; } /** * Apply an array-modifying function recursively. * @param obj The object to apply the function to. * @param fn The function to apply. Note that it must modify the array in-place. */ static _coalesceConvert_doRecurse (obj, fn) { if (typeof obj !== "object") throw new TypeError(`Non-object ${obj} passed to object handler!`); if (obj instanceof Array) { fn(obj); obj.forEach(it => { if (typeof it !== "object") return; this._coalesceConvert_doRecurse(it, fn); }); } else { if (obj.type) { const childMeta = Renderer.ENTRIES_WITH_CHILDREN.find(it => it.type === obj.type && obj[it.key]); if (childMeta) { this._coalesceConvert_doRecurse(obj[childMeta.key], fn); } } } } static _coalesceTables (buf) { let lastCaptionIx = -1; for (let i = 0; i < buf.length; ++i) { // Track the last caption position, so we can hoover it up later if (i > 0) { const lPrev = buf[i - 1]; lastCaptionIx = this._coalesce_getLastH5Index(lPrev, i - 1, lastCaptionIx); } let l1 = buf[i]; let l2 = buf[i + 1]; // If we find valid table headers, start scanning in rows until we find something not table-like. // This can be a `#` header line; a `>` inset line; or a line that doesn't contain a pipe. // Additionally, if we find a pre-processed object (e.g. a creature), we're done. if (typeof l1 === "string" && typeof l2 === "string" && l1.includes("|") && l2.includes("|") && l2.includes("---") && /^[ |:-]+$/gi.exec(l2) ) { l1 = l1.trim(); l2 = l2.trim(); let j = 2; for (; j < buf.length; ++j) { const lNxt = buf[i + j]; if (!lNxt || !this._coalesceTables_isTableLine(lNxt)) break; } if (lastCaptionIx != null && ~lastCaptionIx) { const lines = buf.slice(lastCaptionIx, i + j); buf.splice( lastCaptionIx, j + (i - lastCaptionIx), {mdType: "table", caption: lines[0].replace("##### ", ""), lines: lines.slice(1)}, ); } else { const lines = buf.slice(i, i + j); buf.splice(i, j, {mdType: "table", lines}); } } } } static _convertTables (buf) { for (let i = 0; i < buf.length; ++i) { const line = buf[i]; if (typeof line === "string") continue; if (!line.mdType) { this._coalesceConvert_doRecurse(line, this._convertTables.bind(this)); } else { if (line.mdType !== "table") continue; buf[i] = this.getConvertedTable(line.lines, line.caption); } } } static _coalesceTables_isTableLine (l) { if (typeof l !== "string") return false; l = l.trim(); if (!l.includes("|")) return false; return !/^#+ /.test(l) && !l.startsWith("> ") && !/^[-*+]/.test(l); } static _coalesceLists (buf) { for (let i = 0; i < buf.length; ++i) { const line = buf[i]; if (typeof line !== "string") { this._coalesceConvert_doRecurse(line, this._coalesceLists.bind(this)); } else { const liM = this._coalesceLists_isListItem(line); if (liM) { let j = 1; let blankCount = 0; // Skip forwards until we run out of lines, or until we hit a line that isn't part of the block for (; i + j < buf.length; ++j) { const nxt = buf[i + j]; if (!nxt || !nxt.trim()) { // Allow a max of one blank line before breaking into another list if (blankCount++ < 1) continue; else break; } blankCount = 0; if (typeof nxt !== "string") break; if (!this._coalesceLists_isListItem(nxt)) break; } const listLines = buf.slice(i, i + j); buf.splice(i, j, {mdType: "list", lines: listLines.filter(it => it.trim())}); } } } } static _coalesceLists_isListItem (line) { return /^(\s*)\* /.test(line) || /^(\s*)- /.test(line) || /^(\s*)\+ /.test(line); } static _convertLists (buf) { for (let i = 0; i < buf.length; ++i) { const line = buf[i]; if (typeof line === "string") continue; if (!line.mdType) { this._coalesceConvert_doRecurse(line, this._convertLists.bind(this)); } else { if (line.mdType !== "list") continue; // Normalise line depths line.lines = this._convertLists_doNormalise(line.lines); const stack = []; const getStackDepth = () => { if (!stack.length) return null; return stack.length - 1; }; line.lines.forEach(l => { const depth = l.length - l.trimStart().length; const lText = l.trim(); if (getStackDepth() == null) { const list = {type: "list", items: [lText]}; stack.push(list); } else { if (depth === getStackDepth()) stack.last().items.push(lText); else if (depth > getStackDepth()) { const list = {type: "list", items: [lText]}; stack.last().items.push(list); stack.push(list); } else if (depth < getStackDepth()) { while (depth < getStackDepth()) stack.pop(); if (stack.length) stack.last().items.push(lText); else stack.push({type: "list", items: [lText]}); } } }); buf.splice(i, 1, stack[0]); } } } static _convertLists_doNormalise (lst) { const getCleanLine = l => l.replace(/^\s*[-+*]\s*/, ""); // Allow +/- 1 depth range const isInDepthRange = (depthRange, depth) => (depthRange[0] == null && depthRange[1] == null) || (depth >= depthRange[0] - 1 && depth <= depthRange[1] + 1); const setDepthRange = (depthRange, depth) => depthRange[0] = depthRange[1] = depth; const expandDepthRange = (depthRange, depth) => { if (depthRange[0] == null && depthRange[1] == null) { depthRange[0] = depth; depthRange[1] = depth; } else { depthRange[0] = Math.min(depthRange[0], depth); depthRange[1] = Math.max(depthRange[1], depth); } }; // Normalise leading whitespace let targetDepth = 0; const depthRange = [null, null]; return lst.map(l => { const depth = l.length - l.trimStart().length; if (isInDepthRange(depthRange, depth)) { expandDepthRange(depthRange, depth); } else if (depth > depthRange[1]) { targetDepth++; setDepthRange(depthRange, depth); } else if (depth < depthRange[0]) { // If the depth is below where we're at, step our targetDepth by an appropriate count of 2-spaces const targetDepthReduction = Math.floor((depthRange[0] - depth) / 2); targetDepth = Math.max(0, targetDepth - targetDepthReduction); setDepthRange(depthRange, depth); } return `${" ".repeat(targetDepth)}${getCleanLine(l)}`; }); } static _coalesceInsetsReadalouds (buf) { const getCleanLine = l => l.replace(/^>>?\s*/, ""); for (let i = 0; i < buf.length; ++i) { let line = buf[i]; if (typeof line !== "string") { this._coalesceConvert_doRecurse(line, this._coalesceInsetsReadalouds.bind(this)); } else { line = line.trim(); if (this._coalesceInsets_isInsetLine(line) || this._coalesceInsets_isReadaloudLine(line)) { let type = this._coalesceInsets_isReadaloudLine(line) ? "insetReadaloud" : "inset"; let j = 1; const header = /^>\s*#####\s+/.test(line) ? line.replace(/^>\s*#####\s+/, "") : null; for (; j < buf.length; ++j) { const lNxt = buf[i + j]; if (typeof lNxt === "object") continue; if (!lNxt) break; if (type === "insetReadaloud" && !this._coalesceInsets_isReadaloudLine(lNxt)) break; if (type === "inset" && !this._coalesceInsets_isInsetLine(lNxt)) break; } const lines = buf.slice(i, i + j).map(getCleanLine); const out = {mdType: type, lines}; if (header) { out.name = header; lines.shift(); } buf.splice(i, j, out); } } } } static _coalesceInsets_isReadaloudLine (l) { return l.trim().startsWith(">>"); } static _coalesceInsets_isInsetLine (l) { return l.trim().startsWith(">"); } static _convertInsetsReadalouds (buf) { for (let i = 0; i < buf.length; ++i) { const line = buf[i]; if (typeof line === "string") continue; if (line.mdType === "inset" || line.mdType === "insetReadaloud") { const out = { type: line.mdType, name: line.name, entries: line.lines, }; if (!out.name || !out.name.trim()) delete out.name; buf[i] = out; } } } static _coalesceHeaders (buf) { const stack = []; const i = {_: 0}; for (; i._ < buf.length; ++i._) { let line = buf[i._]; if (typeof line !== "string") { if (!stack.length) continue; else { buf.splice(i._--, 1); stack.last().entries.push(line); continue; } } else line = line.trim(); const mHashes = /^(#+) /.exec(line); const mInlineHeaderStars = /\*\*\*\s*([^.?!:]+[.?!:])\s*\*\*\*(.*)/.exec(line); const mInlineHeaderUnders = /___\s*([^.?!:]+[.?!:])\s*___(.*)/.exec(line); if (mHashes) { const name = line.replace(/^#+ /, ""); const numHashes = line.length - (name.length + 1); // Add back one since we stripped a space switch (numHashes) { // # => "chapter" section, which should start a new section // ## => "regular" section, which should be embedded in a root section if possible case 1: this._coalesceHeaders_addBlock(buf, i, stack, -2, name); break; case 2: this._coalesceHeaders_addBlock(buf, i, stack, -1, name); break; // ### => l0 entries case 3: this._coalesceHeaders_addBlock(buf, i, stack, 0, name); break; // #### => l1 entries // ##### => l1 entries (TODO this should be something else? Is a bold small-caps header) case 4: this._coalesceHeaders_addBlock(buf, i, stack, 1, name); break; case 5: this._coalesceHeaders_addBlock(buf, i, stack, 1, name); break; } } else if (mInlineHeaderStars || mInlineHeaderUnders) { const mInline = mInlineHeaderStars || mInlineHeaderUnders; const name = mInline[1]; const text = mInline[2]; this._coalesceHeaders_addBlock(buf, i, stack, 2, name.replace(/[.?!:]\s*$/, "")); stack.last().entries.push(text); } else { if (!stack.length) continue; buf.splice(i._--, 1); stack.last().entries.push(line); } } } static _coalesceHeaders_getStackDepth (stack) { if (!stack.length) return null; let count = 0; let start = 0; for (let i = stack.length - 1; i >= 0; --i) { const ent = stack[i]; if (ent.type === "section") { start = -1; break; } else { count++; } } return start + count; } static _coalesceHeaders_addBlock (buf, i, stack, depth, name) { const targetDepth = depth === -2 ? -1 : depth; const curDepth = this._coalesceHeaders_getStackDepth(stack); if (curDepth == null || depth === -2) { // -2 = new root section // If we're adding a new chapter, clear the stack while (stack.length) stack.pop(); buf[i._] = this._coalesceHeaders_getRoot(stack, depth); if (depth <= 0) stack.last().name = name; else this._coalesceHeaders_handleTooShallow(stack, targetDepth, name); } else { if (curDepth === targetDepth) { this._coalesceHeaders_handleEqual(buf, i, stack, depth, targetDepth, name); } else if (curDepth < targetDepth) { buf.splice(i._--, 1); this._coalesceHeaders_handleTooShallow(stack, targetDepth, name); } else if (curDepth > targetDepth) { this._coalesceHeaders_handleTooDeep(buf, i, stack, depth, targetDepth, name); } } } static _coalesceHeaders_getRoot (stack, depth) { const root = {type: depth < 0 ? "section" : "entries", name: "", entries: []}; stack.push(root); return root; } static _coalesceHeaders_handleEqual (buf, i, stack, depth, targetDepth, name) { if (stack.length > 1) stack.pop(); else if (targetDepth !== -1) { // If we only have a root, and encounter an entry at the same level as our root, we need to turn the root into a section const nuRoot = { type: "section", entries: [ stack[0], ], }; const ixRoot = buf.indexOf(stack[0]); if (~ixRoot) throw new Error(`Could not find root in buffer!`); buf[ixRoot] = nuRoot; stack.pop(); stack.push(nuRoot); } if (stack.length) { buf.splice(i._--, 1); const nxtBlock = {type: depth < 0 ? "section" : "entries", name, entries: []}; stack.last().entries.push(nxtBlock); stack.push(nxtBlock); } else { buf[i._] = this._coalesceHeaders_getRoot(stack, depth); stack.last().name = name; } } static _coalesceHeaders_handleTooShallow (stack, targetDepth, name) { while (this._coalesceHeaders_getStackDepth(stack) < targetDepth) { const nxt = {type: "entries", name: "", entries: []}; stack.last().entries.push(nxt); stack.push(nxt); } stack.last().name = name; } static _coalesceHeaders_handleTooDeep (buf, i, stack, depth, targetDepth, name) { // Protect the first entry on the stack while (this._coalesceHeaders_getStackDepth(stack) > targetDepth && stack.length > 1) stack.pop(); this._coalesceHeaders_handleEqual(buf, i, stack, depth, targetDepth, name); } static _convertInlineStyling (buf) { const handlers = { object: (obj) => { for (const meta of Renderer.ENTRIES_WITH_CHILDREN) { if (obj.type !== meta.type) continue; if (!obj[meta.key]) continue; obj[meta.key] = obj[meta.key].map(ent => { if (typeof ent !== "string") return ent; // Handle "emphasis" markers (*italic*/**bold**/***bold+italic***) ent = ent.replace(/(\*+)(.+?)(\*+)|(_+)(.+?)(_+)/g, (...m) => { const [open, text, close] = m[1] ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]]; const minLen = Math.min(open.length, close.length); const cleanOpen = open.slice(minLen); const cleanClose = close.slice(minLen); if (minLen === 1) return `{@i ${cleanOpen}${text}${cleanClose}}`; else if (minLen === 2) return `{@b ${cleanOpen}${text}${cleanClose}}`; else return `{@b {@i ${cleanOpen}${text}${cleanClose}}}`; }); // Strikethrough ent = ent.replace(/~~(.+?)~~/g, (...m) => `{@s ${m[1]}}`); // Links (basic inline only) ent = ent.replace(/\[(.+?)]\((.+?)\)/g, (...m) => `{@link ${m[1]}|${m[2]}}`); return ent; }); } return obj; }, }; const nxtBuf = MiscUtil.getWalker().walk(buf, handlers); while (buf.length) buf.pop(); buf.push(...nxtBuf); } static _cleanEmptyLines (buf) { const handlersDoTrim = { array: (arr) => arr.map(it => typeof it === "string" ? it.trim() : it), }; const nxtBufTrim = MiscUtil.getWalker().walk(buf, handlersDoTrim); while (buf.length) buf.pop(); buf.push(...nxtBufTrim); const handlersRmEmpty = { array: (arr) => arr.filter(it => it && (typeof it !== "string" || it.trim())), }; const nxtBufRmEmpty = MiscUtil.getWalker().walk(buf, handlersRmEmpty); while (buf.length) buf.pop(); buf.push(...nxtBufRmEmpty); } static _cleanEntries (buf) { function recursiveClean (obj) { if (typeof obj === "object") { if (obj instanceof Array) { obj.forEach(x => recursiveClean(x)); } else { if ((obj.type === "section" || obj.type === "entries") && obj.name != null && !obj.name.trim()) delete obj.name; if (obj.entries && !obj.entries.length) delete obj.entries; Object.values(obj).forEach(v => recursiveClean(v)); } } } recursiveClean(buf); } // region Table Conversion static getConvertedTable (lines, caption) { // trim leading/trailing pipes if they're uniformly present const contentLines = lines.filter(l => l && l.trim()); if (contentLines.every(l => l.trim().startsWith("|"))) lines = lines.map(l => l.replace(/^\s*\|(.*?)$/, "$1")); if (contentLines.every(l => l.trim().endsWith("|"))) lines = lines.map(l => l.replace(/^(.*?)\|\s*$/, "$1")); const tbl = { type: "table", caption, colLabels: [], colStyles: [], rows: [], }; let seenHeaderBreak = false; let alignment = []; lines.map(l => l.trim()).filter(Boolean).forEach(l => { const cells = l.split("|").map(it => it.trim()); if (cells.length) { if (cells.every(c => !c || !!/^:?\s*---+\s*:?$/.exec(c))) { // a header break alignment = cells.map(c => { if (c.startsWith(":") && c.endsWith(":")) { return "text-center"; } else if (c.startsWith(":")) { return "text-align-left"; } else if (c.endsWith(":")) { return "text-right"; } else { return ""; } }); seenHeaderBreak = true; } else if (seenHeaderBreak) { tbl.rows.push(cells); } else { tbl.colLabels = cells; } } }); tbl.colStyles = alignment; this.postProcessTable(tbl); return tbl; } /** * @param tbl The table to process. * @param [opts] Options object. Defaults assume statblock parsing. * @param [opts.tableWidth] The table width, in characters. 80 is good for statblocks, 150 is good for books. * @param [opts.diceColWidth] The width (in 12ths) of any leading rollable dice column. 1 for statblocks, 2 for books. */ static postProcessTable (tbl, opts) { opts = opts || {}; opts.tableWidth = opts.tableWidth || 80; opts.diceColWidth = opts.diceColWidth || 1; tbl.colStyles = tbl.colStyles || []; // Post-processing (function normalizeCellCounts () { // pad all rows to max width const maxWidth = Math.max((tbl.colLabels || []).length, ...tbl.rows.map(it => it.length)); tbl.rows.forEach(row => { while (row.length < maxWidth) row.push(""); }); })(); let isDiceCol0 = true; (function doCheckDiceOrNumericCol0 () { // check if first column is all strictly number-like tbl.rows.forEach(r => { const r0Clean = Renderer.stripTags((r[0] || "").trim()); // u2012 = figure dash; u2013 = en-dash if (!/^[-+*/×÷x^.,0-9\u2012\u2013]+(?:st|nd|rd|th)?$/i.exec(r0Clean)) return isDiceCol0 = false; }); })(); (function doCalculateWidths () { const BASE_CHAR_CAP = opts.tableWidth; // assume tables are approx 80 characters wide // Get the average/max width of each column let isAllBelowCap = true; const widthMeta = (() => { if (!tbl.rows.length) return null; const outAvgWidths = [...new Array(tbl.rows[0].length)].map(() => 0); // Include the headers in "max width" calculations const outMaxWidths = [...new Array(tbl.rows[0].length)].map((_, i) => tbl.colLabels[i] ? tbl.colLabels[i].length : 0); tbl.rows.forEach(r => { r.forEach((cell, i) => { // This assumes the cells are always strings, which may be faulty const cellStripped = Renderer.stripTags(cell); if (cellStripped.length > BASE_CHAR_CAP) isAllBelowCap = false; outAvgWidths[i] += Math.min(BASE_CHAR_CAP, cellStripped.length); outMaxWidths[i] = Math.max(outMaxWidths[i], cellStripped.length); }); }); return { avgWidths: outAvgWidths.map(it => it / tbl.rows.length), maxWidths: outMaxWidths, }; })(); if (widthMeta == null) return; const {avgWidths, maxWidths} = widthMeta; // If we have a relatively sparse table, give each column enough to fit its max const assignColWidths = (widths) => { // Reserve some space for the dice column, if we have one const splitInto = isDiceCol0 ? 12 - opts.diceColWidth : 12; if (isDiceCol0) widths = widths.slice(1); const totalWidths = widths.reduce((a, b) => a + b, 0); const redistributedWidths = (() => { const MIN = totalWidths / splitInto; const sorted = widths.map((it, i) => ({ix: i, val: it})).sort((a, b) => SortUtil.ascSort(a.val, b.val)); for (let i = 0; i < sorted.length - 1; ++i) { const it = sorted[i]; if (it.val < MIN) { const diff = MIN - it.val; sorted[i].val = MIN; const toSteal = diff / sorted.length - (i + 1); for (let j = i + 1; j < sorted.length; ++j) { sorted[j].val -= toSteal; } } } return sorted.sort((a, b) => SortUtil.ascSort(a.ix, b.ix)).map(it => it.val); })(); let nmlxWidths = redistributedWidths.map(it => it / totalWidths); while (nmlxWidths.reduce((a, b) => a + b, 0) > 1) { const diff = 1 - nmlxWidths.reduce((a, b) => a + b, 0); nmlxWidths = nmlxWidths.map(it => it + diff / nmlxWidths.length); } const twelfthWidths = nmlxWidths.map(it => Math.round(it * splitInto)); if (isDiceCol0) tbl.colStyles[0] = `col-${opts.diceColWidth}`; twelfthWidths.forEach((it, i) => { const widthPart = `col-${it}`; const iOffset = isDiceCol0 ? i + 1 : i; tbl.colStyles[iOffset] = tbl.colStyles[iOffset] ? `${tbl.colStyles[iOffset]} ${widthPart}` : widthPart; }); }; assignColWidths(isAllBelowCap ? maxWidths : avgWidths); })(); if (isDiceCol0 && !tbl.colStyles.includes("text-center")) tbl.colStyles[0] += " text-center"; (function doCheckNumericCols () { if (isDiceCol0 && tbl.colStyles.length === 2) return; // don't apply this step for generic rollable tables tbl.colStyles.forEach((col, i) => { if (col.includes("text-center") || col.includes("text-right")) return; const counts = {number: 0, text: 0}; tbl.rows.forEach(r => { if (typeof r[i] !== "string") return counts.text++; const clean = Renderer.stripTags(r[i]) .replace(/[.,]/g, "") // Remove number separators .replace(/(^| )(cp|sp|gp|pp|lb\.|ft\.)( |$)/g, "") // Remove units .trim(); counts[isNaN(Number(clean)) ? "text" : "number"]++; }); // If most of the cells in this column contain number data, right-align // Unless it's the first column, in which case, center-align if ((counts.number / tbl.rows.length) >= 0.80) { if (i === 0) tbl.colStyles[i] += ` text-center`; else tbl.colStyles[i] += ` text-right`; } }); })(); // If there are columns which have a limited number of words, center these let isFewWordsCol1 = false; (function doCheckFewWordsCols () { if (isDiceCol0 && tbl.colStyles.length === 2) return; // don't apply this step for generic rollable tables // Do this in reverse order, as the style of the first column depends on the others for (let i = tbl.colStyles.length - 1; i >= 0; --i) { const col = tbl.colStyles[i]; // If we're the first column and other columns are not center-aligned, don't center if (i === 0 && tbl.colStyles.length > 1 && tbl.colStyles.filter((_, i) => i !== 0).some(it => !it.includes("text-center"))) continue; const counts = {short: 0, long: 0}; tbl.rows.forEach(r => { const cell = r[i]; if (typeof cell !== "string") return counts.long++; const words = Renderer.stripTags(cell).split(" "); counts[words.length <= 3 ? "short" : "long"]++; }); // If most of the cells in this column contain short text, center-align if ((counts.short / tbl.rows.length) >= 0.80) { if (i === 1) isFewWordsCol1 = true; if (col.includes("text-center") || col.includes("text-right")) continue; tbl.colStyles[i] += ` text-center`; } } })(); this._doCleanTable(tbl); (function doEvenCenteredColumns () { if (!isDiceCol0) return; if (tbl.colStyles.length === 2 && isFewWordsCol1) { tbl.colStyles = ["col-6 text-center", "col-6 text-center"]; } })(); // Convert "--" cells to long-dashes tbl.rows = tbl.rows.map(r => { return r.map(cell => { if (cell === "--") return "\u2014"; return cell; }); }); } static _doCleanTable (tbl) { if (!tbl.caption) delete tbl.caption; if (tbl.colLabels && !tbl.colLabels.some(Boolean)) delete tbl.colLabels; if (tbl.colStyles && !tbl.colStyles.some(Boolean)) delete tbl.colStyles; } // endregion } globalThis.RendererMarkdown = RendererMarkdown; globalThis.MarkdownConverter = MarkdownConverter;