diff --git a/src/css/prose.css b/src/css/prose.css index 98e7f0ee..76be69d8 100644 --- a/src/css/prose.css +++ b/src/css/prose.css @@ -89,3 +89,11 @@ .prose table tbody tr:nth-child(2n-1) { @apply bg-zinc-100; } + +.prose .task-list { + @apply list-none; +} + +.prose > .task-list { + @apply pl-0; +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts index 5177ca1c..22c544aa 100644 --- a/src/markdown/index.ts +++ b/src/markdown/index.ts @@ -3,6 +3,7 @@ import { pluginCodeBlock } from "./plugin-code-block" import { pluginExcerpt } from "./plugin-excerpt" import { pluginImage } from "./plugin-image" import { pluginTable } from "./plugin-table" +import { pluginTaskList } from "./plugin-task-list" export type MarkdownEnv = { excerpt: string @@ -24,6 +25,7 @@ export const renderPageContent = async (content: string): Promise => { md.use(pluginCodeBlock) md.use(pluginExcerpt) md.use(pluginTable) + md.use(pluginTaskList) const env: MarkdownEnv = { excerpt: "", __internal: {} } const contentHTML = md.render(content, env) diff --git a/src/markdown/plugin-task-list.ts b/src/markdown/plugin-task-list.ts new file mode 100644 index 00000000..2ec30566 --- /dev/null +++ b/src/markdown/plugin-task-list.ts @@ -0,0 +1,138 @@ +import MarkdownIt from "markdown-it" +import Token from "markdown-it/lib/token" + +export const pluginTaskList = (md: MarkdownIt, options?: any) => { + options = Object.assign( + { + disabled: true, + divWrap: false, + divClass: "checkbox", + idPrefix: "cbx_", + ulClass: "task-list", + liClass: "task-list-item", + }, + options, + ) + md.core.ruler.after("inline", "github-task-lists", (state) => { + const { tokens } = state + let lastId = 0 + for (let i = 2; i < tokens.length; i++) { + if (isTodoItem(tokens, i)) { + todoify(tokens[i], `${lastId}`, options, state.Token) + lastId += 1 + const token = tokens[i - 2] + token.attrSet("class", options.liClass) + const parentTokenIndex = getParentTokenIndex(tokens, i - 2) + const parentToken = tokens[parentTokenIndex] + if (parentToken) { + parentToken.attrSet("class", options.ulClass) + } + } + } + }) +} + +function getParentTokenIndex(tokens: Token[], index: number) { + const targetLevel = tokens[index].level - 1 + for (let i = index - 1; i >= 0; i--) { + if (tokens[i].level === targetLevel) { + return i + } + } + + return -1 +} + +function isTodoItem(tokens: Token[], index: number) { + return ( + isInline(tokens[index]) && + isParagraph(tokens[index - 1]) && + isListItem(tokens[index - 2]) && + startsWithTodoMarkdown(tokens[index]) + ) +} + +function todoify( + token: Token, + lastId: string, + options: any, + TokenConstructor: typeof Token, +) { + const id = `${options.idPrefix}${lastId}` + token.children![0].content = token.children![0].content.slice(3) + // label + token.children!.unshift(beginLabel(id, TokenConstructor)) + token.children!.push(endLabel(TokenConstructor)) + // checkbox + token.children!.unshift(makeCheckbox(token, id, options, TokenConstructor)) + if (options.divWrap) { + token.children!.unshift(beginWrap(options, TokenConstructor)) + token.children!.push(endWrap(TokenConstructor)) + } +} + +function makeCheckbox( + token: Token, + id: string, + options: any, + TokenConstructor: typeof Token, +) { + const checkbox = new TokenConstructor("checkbox_input", "input", 0) + checkbox.attrs = [ + ["type", "checkbox"], + ["id", id], + ] + const checked = /^\[[xX]\][ \u00A0]/.test(token.content) // if token.content starts with '[x] ' or '[X] ' + if (checked === true) { + checkbox.attrs.push(["checked", "true"]) + } + + if (options.disabled === true) { + checkbox.attrs.push(["disabled", "true"]) + } + + return checkbox +} + +function beginLabel(id: string, TokenConstructor: typeof Token) { + const label = new TokenConstructor("label_open", "label", 1) + label.attrs = [["for", id]] + return label +} + +function endLabel(TokenConstructor: typeof Token) { + return new TokenConstructor("label_close", "label", -1) +} + +// these next two functions are kind of hacky; probably should really be a +// true block-level token with .tag=='label' +function beginWrap(options: any, TokenConstructor: typeof Token) { + const token = new TokenConstructor("checkbox_open", "div", 0) + token.attrs = [["class", options.divClass]] + return token +} + +function endWrap(TokenConstructor: typeof Token) { + const token = new TokenConstructor("checkbox_close", "div", -1) + // token.content = ''; + return token +} + +function isInline(token: Token) { + return token.type === "inline" +} + +function isParagraph(token: Token) { + return token.type === "paragraph_open" +} + +function isListItem(token: Token) { + return token.type === "list_item_open" +} + +function startsWithTodoMarkdown(token: Token) { + // The leading whitespace in a list item (token.content) is already trimmed off by markdown-it. + // The regex below checks for '[ ] ' or '[x] ' or '[X] ' at the start of the string token.content, + // where the space is either a normal space or a non-breaking space (character 160 = \u00A0). + return /^\[[xX \u00A0]\][ \u00A0]/.test(token.content) +}