diff --git a/packages/remark-callouts/.babelrc b/packages/remark-callouts/.babelrc new file mode 100644 index 00000000..2fdda153 --- /dev/null +++ b/packages/remark-callouts/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["@babel/preset-env"], + "plugins": ["@babel/plugin-transform-runtime"] +} diff --git a/packages/remark-callouts/.eslintrc.json b/packages/remark-callouts/.eslintrc.json new file mode 100644 index 00000000..9d9c0db5 --- /dev/null +++ b/packages/remark-callouts/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/remark-callouts/.mocharc.yaml b/packages/remark-callouts/.mocharc.yaml new file mode 100644 index 00000000..dbc2c67b --- /dev/null +++ b/packages/remark-callouts/.mocharc.yaml @@ -0,0 +1,4 @@ +extension: [ts] +node-option: + - experimental-specifier-resolution=node + - loader=ts-node/esm diff --git a/packages/remark-callouts/README.md b/packages/remark-callouts/README.md new file mode 100644 index 00000000..7f670cad --- /dev/null +++ b/packages/remark-callouts/README.md @@ -0,0 +1,121 @@ +# remark-callouts + +Remark plugin to add support for blockquote-based callouts/admonitions similar to the approach of [Obsidian](https://help.obsidian.md/How+to/Use+callouts) and [Microsoft Learn](https://learn.microsoft.com/en-us/contribute/markdown-reference#alerts-note-tip-important-caution-warning) style. + +Using this plugin, markdown like this: + +```md +> [!tip] +> hello callout +``` + +Would render as a callout like this: + +Tip callout block + +## Features supported + +- [x] Supports blockquote style callouts +- [x] Supports nested blockquote callouts +- [x] Supports 13 types out of the box (with appropriate styling in default theme) - see list below +- [x] Supports aliases for types +- [x] Defaults to note callout for all other types eg. `> [!xyz]` +- [x] Supports dark and light mode styles + +Future support: + +- [ ] Support custom types and icons +- [ ] Support custom aliases +- [ ] Support Foldable callouts +- [ ] Support custom styles + +## Geting Started + +### Installation + +```bash +npm install remark-callouts +``` + +### Usage + +```js +import callouts from "remark-callouts"; + +await remark() + .use(remarkParse) + .use(callouts) + .use(remarkRehype) + .use(rehypeStringify).process(`\ +> [!tip] +> hello callout +`); +``` + +HTML output + +```js +
+
+
+ + ... + + Tip +
+
+

hello callout

+
+
+
+``` + +Import the styles in your .css file + +```css +@import "remark-callouts/styles.css"; +``` + +or in your app.js + +```js +import "remark-callouts/styles.css"; +``` + +### Supported Callout Types + +- note +- tip `aliases: hint, important` +- warning `alises: caution, attention` +- abstract `aliases: summary, tldr` +- info +- todo +- success `aliases: check, done` +- question `aliases: help, faq` +- failure `aliases: fail, missing` +- danger `alias: error` +- bug +- example +- quote `alias: cite` + +# Change Log + +## [2.0.0] - 2022-11-21 + +### Added + +- Classname for icon. + +### Changed + +- Extract css styles which can be imported separately. + +## [1.0.2] - 2022-11-03 + +### Fixed + +- Case insensitive match for types. + +## License + +MIT diff --git a/packages/remark-callouts/package.json b/packages/remark-callouts/package.json new file mode 100644 index 00000000..7056813d --- /dev/null +++ b/packages/remark-callouts/package.json @@ -0,0 +1,41 @@ +{ + "name": "@flowershow/remark-callouts", + "version": "1.0.0", + "description": "remark plugin to add support for blockquote-based admonitions/callouts", + "repository": { + "type": "git", + "url": "git+https://github.com/flowershow/flowershow.git", + "directory": "packages/remark-callouts" + }, + "keywords": [ + "remark", + "remark-plugin", + "markdown", + "admonitions", + "callouts", + "obsidian" + ], + "author": "Rufus Pollock", + "license": "MIT", + "bugs": { + "url": "https://github.com/flowershow/flowershow/issues" + }, + "homepage": "https://github.com/flowershow/flowershow#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "mdast-util-from-markdown": "^1.2.0", + "svg-parser": "^2.0.4", + "unist-util-visit": "^4.1.0" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./styles.css": "./styles.css" + } +} diff --git a/packages/remark-callouts/project.json b/packages/remark-callouts/project.json new file mode 100644 index 00000000..02ef1f8d --- /dev/null +++ b/packages/remark-callouts/project.json @@ -0,0 +1,64 @@ +{ + "name": "remark-callouts", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/remark-callouts/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/remark-callouts/**/*.ts"] + } + }, + "test": { + "executor": "nx:run-commands", + "options": { + "command": "TS_NODE_PROJECT='packages/remark-callouts/tsconfig.spec.json' mocha --config packages/remark-callouts/.mocharc.yaml packages/remark-callouts/test/remark-callouts.spec.ts" + } + }, + "build": { + "executor": "@nrwl/web:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "entryFile": "packages/remark-callouts/src/index.ts", + "outputPath": "packages/remark-callouts/dist", + "compiler": "babel", + "tsConfig": "packages/remark-callouts/tsconfig.lib.json", + "project": "packages/remark-callouts/package.json", + "format": ["esm", "cjs"], + "extractCss": true, + "generateExportsField": true, + "assets": [ + { + "glob": "packages/remark-callouts/README.md", + "input": ".", + "output": "." + }, + { + "glob": "packages/remark-callouts/styles.css", + "input": ".", + "output": "." + } + ] + } + }, + "publish:dry": { + "executor": "nx:run-commands", + "options": { + "commands": ["npm publish --dry-run"], + "parallel": false, + "cwd": "packages/remark-callouts/dist" + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "commands": ["npm publish"], + "parallel": false, + "cwd": "packages/remark-callouts/dist" + } + } + }, + "tags": [] +} diff --git a/packages/remark-callouts/src/index.ts b/packages/remark-callouts/src/index.ts new file mode 100644 index 00000000..28c2aea5 --- /dev/null +++ b/packages/remark-callouts/src/index.ts @@ -0,0 +1,2 @@ +export * from "./lib/remark-callouts"; +export { default } from "./lib/remark-callouts"; diff --git a/packages/remark-callouts/src/lib/calloutTypes.ts b/packages/remark-callouts/src/lib/calloutTypes.ts new file mode 100644 index 00000000..a2dc81c4 --- /dev/null +++ b/packages/remark-callouts/src/lib/calloutTypes.ts @@ -0,0 +1,83 @@ +export const calloutTypes = { + // aliases + summary: "abstract", + tldr: "abstract", + hint: "tip", + important: "tip", + check: "success", + done: "success", + help: "question", + faq: "question", + caution: "warning", + attention: "warning", + fail: "failure", + missing: "failure", + error: "danger", + cite: "quote", + // base types + note: { + keyword: "note", + color: "#448aff", + svg: '', + }, + tip: { + keyword: "tip", + color: "#00bfa6", + svg: '', + }, + warning: { + keyword: "warning", + color: "#ff9100", + svg: '', + }, + abstract: { + keyword: "abstract", + color: "#00aeff", + svg: '', + }, + info: { + keyword: "info", + color: "#00b8d4", + svg: '', + }, + todo: { + keyword: "todo", + color: "#00b8d4", + svg: '', + }, + success: { + keyword: "success", + color: "#00c853", + svg: '', + }, + question: { + keyword: "question", + color: "#63dd17", + svg: '', + }, + failure: { + keyword: "failure", + color: "#ff5252", + svg: '', + }, + danger: { + keyword: "danger", + color: "#ff1745", + svg: '', + }, + bug: { + keyword: "bug", + color: "#f50057", + svg: '', + }, + example: { + keyword: "example", + color: "#7c4dff", + svg: '', + }, + quote: { + keyword: "quote", + color: "#9e9e9e", + svg: '', + }, +}; diff --git a/packages/remark-callouts/src/lib/remark-callouts.ts b/packages/remark-callouts/src/lib/remark-callouts.ts new file mode 100644 index 00000000..4924638f --- /dev/null +++ b/packages/remark-callouts/src/lib/remark-callouts.ts @@ -0,0 +1,285 @@ +import { visit } from "unist-util-visit"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import type { Plugin } from "unified"; +import type { Node, Data, Parent } from "unist"; +import type { Blockquote, Heading, Text, BlockContent } from "mdast"; +import { parse } from "svg-parser"; +import { calloutTypes } from "./calloutTypes"; + +// escape regex special characters +function escapeRegExp(s: string) { + return s.replace(new RegExp(`[-[\\]{}()*+?.\\\\^$|/]`, "g"), "\\$&"); +} + +// match breaks +const find = /[\t ]*(?:\r?\n|\r)/g; + +export const callouts: Plugin = function (providedConfig?: Partial) { + const config: Config = { ...defaultConfig, ...providedConfig }; + const defaultKeywords: string = Object.keys(config.types) + .map(escapeRegExp) + .join("|"); + + return function (tree) { + visit(tree, (node: Node, index, parent: Parent) => { + // Filter required elems + if (node.type !== "blockquote") return; + + /** add breaks to text without needing spaces or escapes (turns enters into
) + * code taken directly from remark-breaks, + * see https://github.com/remarkjs/remark-breaks for more info on what this does. + */ + visit(node, "text", (node: Text, index: number, parent: Parent) => { + const result = []; + let start = 0; + + find.lastIndex = 0; + + let match = find.exec(node.value); + + while (match) { + const position = match.index; + + if (start !== position) { + result.push({ + type: "text", + value: node.value.slice(start, position), + }); + } + + result.push({ type: "break" }); + start = position + match[0].length; + match = find.exec(node.value); + } + + if (result.length > 0 && parent && typeof index === "number") { + if (start < node.value.length) { + result.push({ type: "text", value: node.value.slice(start) }); + } + + parent.children.splice(index, 1, ...result); + return index + result.length; + } + }); + + /** add classnames to headings within blockquotes, + * mainly to identify when using other plugins that + * might interfere. for eg, rehype-auto-link-headings. + */ + visit(node, "heading", (node) => { + const heading = node as Heading; + heading.data = { + hProperties: { + className: "blockquote-heading", + }, + }; + }); + + // wrap blockquote in a div + const wrapper = { + ...node, + type: "element", + tagName: "div", + data: { + hProperties: {}, + }, + children: [node], + }; + + parent.children.splice(Number(index), 1, wrapper); + + const blockquote = wrapper.children[0] as Blockquote; + + blockquote.data = { + hProperties: { + className: "blockquote", + }, + }; + + // check for callout syntax starts here + if ( + blockquote.children.length <= 0 || + blockquote.children[0].type !== "paragraph" + ) + return; + const paragraph = blockquote.children[0]; + + if ( + paragraph.children.length <= 0 || + paragraph.children[0].type !== "text" + ) + return; + + const [t, ...rest] = paragraph.children; + + const regex = new RegExp( + `^\\[!(?(.*?))\\][\t\f ]?(?.*?)$`, + "gi" + ); + const m = regex.exec(t.value); + + // if no callout syntax, forget about it. + if (!m) return; + + const [key, title] = [m.groups?.keyword, m.groups?.title]; + + // if there's nothing inside the brackets, is it really a callout ? + if (!key) return; + + const keyword = key.toLowerCase(); + const isOneOfKeywords: boolean = new RegExp(defaultKeywords).test( + keyword + ); + + if (title) { + const mdast = fromMarkdown(title.trim()).children[0]; + if (mdast.type === "heading") { + mdast.data = { + ...mdast.data, + hProperties: { + className: "blockquote-heading", + }, + }; + } + blockquote.children.unshift(mdast as BlockContent); + } else { + t.value = + typeof keyword.charAt(0) === "string" + ? keyword.charAt(0).toUpperCase() + keyword.slice(1) + : keyword; + } + + const entry: { [index: string]: string } = {}; + + if (isOneOfKeywords) { + if (typeof config?.types[keyword] === "string") { + const e = String(config?.types[keyword]); + Object.assign(entry, config?.types[e]); + } else { + Object.assign(entry, config?.types[keyword]); + } + } else { + Object.assign(entry, config?.types["note"]); + } + + let parsedSvg; + + if (entry && entry.svg) { + parsedSvg = parse(entry.svg); + } + + // create icon and title node wrapped in div + const titleNode: object = { + type: "element", + children: [ + { + type: "element", + tagName: "span", + data: { + hName: "span", + hProperties: { + style: `color:${entry?.color}`, + className: "callout-icon", + }, + hChildren: parsedSvg?.children ? parsedSvg.children : [], + }, + }, + { + type: "element", + children: title ? [blockquote.children[0]] : [t], + data: { + hName: "strong", + }, + }, + ], + data: { + ...blockquote.children[0]?.data, + hProperties: { + className: `${formatClassNameMap(config.classNameMaps.title)( + keyword + )} ${isOneOfKeywords ? keyword : "note"}`, + style: `background-color: ${entry?.color}1a;`, + }, + }, + }; + + // remove the callout paragraph from the content body + if (title) { + blockquote.children.shift(); + } + + if (rest.length > 0) { + rest.shift(); + paragraph.children = rest; + } else { + blockquote.children.shift(); + } + + // wrap blockquote content in div + const contentNode: object = { + type: "element", + children: blockquote.children, + data: { + hProperties: { + className: "callout-content", + style: + parent.type !== "root" + ? `border-right:1px solid ${entry?.color}33; + border-bottom:1px solid ${entry?.color}33;` + : "", + }, + }, + }; + + if (blockquote.children.length > 0) + blockquote.children = [contentNode] as BlockContent[]; + blockquote.children.unshift(titleNode as BlockContent); + + // Add classes for the callout block + blockquote.data = config.dataMaps.block({ + ...blockquote.data, + hProperties: { + className: formatClassNameMap(config.classNameMaps.block)( + keyword.toLowerCase() + ), + style: `border-left-color:${entry?.color};`, + }, + }); + }); + }; +}; + +export interface Config { + classNameMaps: { + block: ClassNameMap; + title: ClassNameMap; + }; + dataMaps: { + block: (data: Data) => Data; + title: (data: Data) => Data; + }; + types: { [index: string]: string | object }; +} + +export const defaultConfig: Config = { + classNameMaps: { + block: "callout", + title: "callout-title", + }, + dataMaps: { + block: (data) => data, + title: (data) => data, + }, + types: { ...calloutTypes }, +}; + +type ClassNames = string | string[]; +type ClassNameMap = ClassNames | ((title: string) => ClassNames); +function formatClassNameMap(gen: ClassNameMap) { + return (title: string) => { + const classNames = typeof gen == "function" ? gen(title) : gen; + return typeof classNames == "object" ? classNames.join(" ") : classNames; + }; +} + +export default callouts; diff --git a/packages/remark-callouts/styles.css b/packages/remark-callouts/styles.css new file mode 100644 index 00000000..d9609226 --- /dev/null +++ b/packages/remark-callouts/styles.css @@ -0,0 +1,64 @@ +:root { + --callout-bg-color: #f2f3f5; +} + +:root.dark { + --callout-bg-color: #161616; +} + +.blockquote, +.callout { + background: #f2f3f5; + background: var(--callout-bg-color); + font-style: normal; + border-radius: 2px; +} + +.callout { + padding: 0 !important; +} + +.callout-title { + display: flex; + align-items: center; + padding: 10px; + gap: 10px; +} + +.callout-title > strong { + font-weight: 700; +} + +.blockquote, +.callout-content { + padding: 10px 20px; +} + +.blockquote-heading { + margin: 5px 0 !important; + padding: 0 !important; +} + +.blockquote > p, +.callout-content > p { + font-weight: normal; + margin: 5px 0; +} + +.callout-title p { + margin: 0; +} + +.callout-title > strong { + line-height: 1.5; +} + +.callout p:before, +p:after { + display: none; +} + +.blockquote > p:before, +p:after { + display: none; +} diff --git a/packages/remark-callouts/test/remark-callouts.spec.ts b/packages/remark-callouts/test/remark-callouts.spec.ts new file mode 100644 index 00000000..518c5b85 --- /dev/null +++ b/packages/remark-callouts/test/remark-callouts.spec.ts @@ -0,0 +1,143 @@ +import { expect } from "chai"; +import { parseDocument } from "htmlparser2"; +import { selectOne } from "css-select"; +import { remark } from "remark"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; +import rehypeStringify from "rehype-stringify"; +import { callouts, Config } from "../src"; + +async function mdToHtml(md: string, options?: Partial<Config>) { + return String( + await remark() + .use(remarkParse) + .use(callouts, options) + .use(remarkRehype) + .use(rehypeStringify) + .process(md) + ); +} + +describe("remark callouts", function () { + it("parses a blockquote without a callout", async function () { + const html = await mdToHtml(`\ +> no callout +`); + const doc = parseDocument(html); + const blockquote = selectOne("div > blockquote.blockquote > p", doc); + expect(blockquote).to.have.nested.property("firstChild.data", "no callout"); + }); + + it("parses a blockquote callout with title and content", async function () { + const html = await mdToHtml(`\ +> [!tip] +> example content here + `); + const doc = parseDocument(html); + const calloutTitle = selectOne( + "div > blockquote.callout > div.callout-title.tip > strong", + doc + ); + const calloutContent = selectOne( + "div > blockquote.callout > div.callout-content > p", + doc + ); + + expect(calloutTitle).to.have.nested.property("firstChild.data", "Tip"); + expect(calloutContent).to.have.nested.property( + "firstChild.data", + "example content here" + ); + }); + + it("parses a blockquote callout with case insensitive keyword", async function () { + const html = await mdToHtml(`\ +> [!INFO] + `); + const doc = parseDocument(html); + const calloutTitle = selectOne( + "div > blockquote.callout > div.callout-title.info > strong", + doc + ); + + expect(calloutTitle).to.have.nested.property("firstChild.data", "Info"); + }); + + it("parses a blockquote callout with an icon", async function () { + const html = await mdToHtml(`\ +> [!tip] +> example content here + `); + const doc = parseDocument(html); + const calloutIcon = selectOne( + "div > blockquote.callout > div.callout-title.tip > span.callout-icon > svg", + doc + ); + + expect(calloutIcon).to.exist; + }); + + it("parses a blockquote callout with a custom title", async function () { + const html = await mdToHtml(`\ +> [!tip] Custom Title +> content + `); + const doc = parseDocument(html); + const calloutTitle = selectOne( + "div > blockquote.callout > div.callout-title.tip > strong > p", + doc + ); + + expect(calloutTitle).to.have.nested.property( + "firstChild.data", + "Custom Title" + ); + }); + + it("parses a blockquote callout with unknown type to use note", async function () { + const html = await mdToHtml(`\ +> [!xyz] +> content + `); + const doc = parseDocument(html); + const calloutTitle = selectOne( + "div > blockquote.callout > div.callout-title.note > strong", + doc + ); + + expect(calloutTitle).to.have.nested.property("firstChild.data", "Xyz"); + }); + + it("parses a blockquote callout with unknown type and custom title", async function () { + const html = await mdToHtml(`\ +> [!xyz] Some title +> content + `); + const doc = parseDocument(html); + const calloutTitle = selectOne( + "div > blockquote.callout > div.callout-title.note > strong > p", + doc + ); + + expect(calloutTitle).to.have.nested.property( + "firstChild.data", + "Some title" + ); + }); + + it("parses a nested blockquote with callout", async function () { + const html = await mdToHtml(`\ +> [!note] +> content +> > [!info] +> > nested callout + `); + const doc = parseDocument(html); + const nestedCallout = selectOne( + "div > blockquote.callout > div.callout-content > div > blockquote.callout > div.callout-title > strong", + doc + ); + + expect(nestedCallout).to.have.nested.property("firstChild.data", "Info"); + }); +}); diff --git a/packages/remark-callouts/tsconfig.json b/packages/remark-callouts/tsconfig.json new file mode 100644 index 00000000..62ebbd94 --- /dev/null +++ b/packages/remark-callouts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/remark-callouts/tsconfig.lib.json b/packages/remark-callouts/tsconfig.lib.json new file mode 100644 index 00000000..18857e49 --- /dev/null +++ b/packages/remark-callouts/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "target": "es2020", + "module": "es2020", + "types": ["node"], + "moduleResolution": "node", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/packages/remark-callouts/tsconfig.spec.json b/packages/remark-callouts/tsconfig.spec.json new file mode 100644 index 00000000..094684db --- /dev/null +++ b/packages/remark-callouts/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "module": "es2020", + "moduleResolution": "node", + "types": ["mocha", "node"] + }, + "include": [ + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.tsx", + "**/*.spec.tsx", + "**/*.test.js", + "**/*.spec.js", + "**/*.test.jsx", + "**/*.spec.jsx", + "**/*.d.ts" + ] +}