markdown-table.ts 2.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. import { Marked, type Tokens } from 'marked'
  2. import { UNA_URL } from 'app/config'
  3. import { toTableImageSrc } from './inline-video'
  4. /*
  5. * Tables in the post body. The editors have no table node (web or native), so a
  6. * table is an atomic image — UNA's table-na.png carrying the table's GFM Markdown
  7. * (`toTableImageSrc`) — and the post view draws the real table from that Markdown.
  8. *
  9. * The Markdown sits encoded in the image URL, out of reach of UNA's HTML purifier,
  10. * so it is rendered here with no raw HTML (escaped as text), no images (their alt
  11. * text) and only http(s) / mailto / site-relative links. Render the result with
  12. * `<Html preprocess={false}>`: entity decoding would turn escaped text back into tags.
  13. */
  14. /** Placeholder size: table-na.png is 1280x720, inserted media is at most 720px wide. */
  15. const PLACEHOLDER = { width: 720, height: 405 }
  16. export type TableAlign = 'left' | 'center' | 'right' | null
  17. /** A parsed table; cells are inline HTML (bold, links, code). */
  18. export type MarkdownTable = { align: TableAlign[]; header: string[]; rows: string[][] }
  19. const escapeHtml = (text: string) => text
  20. .replace(/&/g, '&amp;')
  21. .replace(/</g, '&lt;')
  22. .replace(/>/g, '&gt;')
  23. .replace(/"/g, '&quot;')
  24. .replace(/'/g, '&#39;')
  25. const SAFE_HREF = /^(https?:\/\/|mailto:|\/(?!\/))/i
  26. const tableMarked = new Marked({
  27. gfm: true,
  28. async: false,
  29. renderer: {
  30. html: ({ text }) => escapeHtml(text),
  31. image: ({ text }) => escapeHtml(text),
  32. link({ href, title, tokens }) {
  33. const text = this.parser.parseInline(tokens)
  34. if (!SAFE_HREF.test(href.trim())) return text
  35. return `<a href="${escapeHtml(href.trim())}"${title ? ` title="${escapeHtml(title)}"` : ''}>${text}</a>`
  36. },
  37. },
  38. })
  39. const findTable = (markdown: string) =>
  40. tableMarked.lexer(markdown).find((t) => t.type === 'table') as Tokens.Table | undefined
  41. const inline = (text: string) => tableMarked.parseInline(text) as string
  42. /** The first GFM table of `markdown`, `null` when it has none. */
  43. export function parseMarkdownTable(markdown: string): MarkdownTable | null {
  44. const token = findTable(markdown)
  45. if (!token) return null
  46. return {
  47. align: token.align,
  48. header: token.header.map((cell) => inline(cell.text)),
  49. rows: token.rows.map((row) => row.map((cell) => inline(cell.text))),
  50. }
  51. }
  52. /** The table as HTML (web post view and the web editor preview). */
  53. export function markdownTableToHtml(markdown: string) {
  54. const token = findTable(markdown)
  55. return token ? (tableMarked.parser([token]) as string) : ''
  56. }
  57. /** Body HTML for a table: its placeholder image in a paragraph of its own. */
  58. export function tableImageHtml(markdown: string) {
  59. const src = toTableImageSrc(`${UNA_URL}/template/images/table-na.png`, markdown.trim())
  60. return `<p><img src="${escapeHtml(src)}" width="${PLACEHOLDER.width}" height="${PLACEHOLDER.height}" alt=""></p>`
  61. }