├── .gitignore
├── LICENSE.md
├── README.md
├── babel.config.js
├── docs
├── components
│ ├── color-switcher.js
│ └── content.js
├── next.config.js
├── package.json
├── pages
│ ├── _app.js
│ ├── _document.js
│ ├── api
│ │ └── md.js
│ └── index.js
└── yarn.lock
├── package.json
├── prettier.config.js
├── src
├── github.js
├── index.js
├── plugins
│ ├── sh-to-shell.js
│ └── video-link-to-details
│ │ ├── index.js
│ │ └── node.js
└── rehype.js
├── test
├── index.js
└── snapshots
│ ├── index.js.md
│ └── index.js.snap
├── vercel.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | coverage
4 | .next
5 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 |
3 | Copyright (c) 2020 The Hack Foundation
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # `@hackclub/markdown`
2 |
3 | Render Markdown to HTML, Hack Club-style. Used primarily on the [Hack Club Workshops](https://hackclub.com/workshops/) site.
4 |
5 | ```sh
6 | yarn add @hackclub/markdown
7 | # npm i @hackclub/markdown
8 | ```
9 |
10 | [**Try the demo**](https://markdown.hackclub.com)
11 |
12 | ## Usage
13 |
14 | This package does not include any frontend code, such as React or CSS.
15 |
16 | This package is designed for rendering at build or otherwise on a server, not client-side—
17 | the bundle size is significant & it’s not been optimized for performance.
18 |
19 | Always use with `await`.
20 |
21 | ```js
22 | import fs from 'fs'
23 | import md from '@hackclub/markdown'
24 |
25 | const getReadme = async () => (
26 | const text = fs.readFileSync('./README.md', 'utf8')
27 | return await md(text, '/README.md', '/static')
28 | )
29 | ```
30 |
31 | | Param | Default | Description |
32 | | ----------- | -------------- | --------------------------------------------------------------- |
33 | | input | Req’d! | String. The Markdown text to transform. |
34 | | filePath | `'/README.md'` | String. The Markdown’s path (for fixing relative image links). |
35 | | imagePrefix | `'/'` | String. A prefix for the path to relative images. |
36 | | removeTitle | `false` | Bool. Remove starting `h1` (if titles are rendered separately). |
37 |
38 | If you need to parse frontmatter, we recommend using [gray-matter](https://npm.im/gray-matter)
39 | alongside `@hackclub/markdown`, but it’s not included in this package.
40 |
41 | ***
42 |
43 | Partially based on the [Next.js documentation site](https://github.com/zeit/next-site/pull/473/files#diff-879732a0915babd1688248ad1144c2d4).
44 |
45 | MIT License
46 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [['@babel/preset-env']]
3 | }
4 |
--------------------------------------------------------------------------------
/docs/components/color-switcher.js:
--------------------------------------------------------------------------------
1 | import { IconButton, useColorMode } from 'theme-ui'
2 |
3 | const ColorSwitcher = props => {
4 | const [mode, setMode] = useColorMode()
5 | return (
6 | setMode(mode === 'dark' ? 'light' : 'dark')}
8 | title="Invert Colors"
9 | sx={{
10 | position: 'absolute',
11 | top: 3,
12 | right: 3,
13 | color: 'primary',
14 | borderRadius: 'circle',
15 | transition: 'box-shadow .125s ease-in-out',
16 | ':hover,:focus': {
17 | boxShadow: '0 0 0 2px',
18 | outline: 'none'
19 | }
20 | }}
21 | >
22 |
33 |
34 | )
35 | }
36 |
37 | export default ColorSwitcher
38 |
--------------------------------------------------------------------------------
/docs/components/content.js:
--------------------------------------------------------------------------------
1 | import { BaseStyles } from 'theme-ui'
2 | import styled from '@emotion/styled'
3 |
4 | const StyledContent = styled(BaseStyles)`
5 | font-size: 1.25rem;
6 |
7 | a {
8 | word-break: break-word;
9 | }
10 |
11 | .heading a {
12 | color: inherit;
13 | text-decoration: none;
14 | }
15 | `
16 |
17 | const Content = ({ html }) => (
18 |
23 | )
24 |
25 | export default Content
26 |
--------------------------------------------------------------------------------
/docs/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {}
2 |
--------------------------------------------------------------------------------
/docs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@hackclub/markdown-docs",
3 | "version": "0.0.3",
4 | "author": "Lachlan Campbell (https://lachlanjc.com) ",
5 | "license": "MIT",
6 | "private": true,
7 | "scripts": {
8 | "dev": "next",
9 | "build": "next build",
10 | "start": "next start"
11 | },
12 | "dependencies": {
13 | "@hackclub/markdown": "^0.0.43",
14 | "@hackclub/meta": "^1.0.0",
15 | "@hackclub/theme": "^0.2.0",
16 | "lodash": "^4.17.19",
17 | "next": "^9.4.4",
18 | "react": "^16.13.1",
19 | "react-dom": "^16.13.1",
20 | "theme-ui": "^0.3.1"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/docs/pages/_app.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import NextApp from 'next/app'
3 |
4 | import '@hackclub/theme/fonts/reg-ital-bold.css'
5 | import theme from '@hackclub/theme'
6 | import { ThemeProvider } from 'theme-ui'
7 |
8 | export default class App extends NextApp {
9 | render() {
10 | const { Component, pageProps } = this.props
11 | return (
12 |
13 |
14 |
15 | )
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/docs/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Html,Head, Main, NextScript } from 'next/document'
2 | import { InitializeColorMode } from 'theme-ui'
3 |
4 | export default class extends Document {
5 | static async getInitialProps(ctx) {
6 | const initialProps = await Document.getInitialProps(ctx)
7 | return { ...initialProps }
8 | }
9 |
10 | render() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | )
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/docs/pages/api/md.js:
--------------------------------------------------------------------------------
1 | import md from '@hackclub/markdown'
2 |
3 | export default async (req, res) => {
4 | const { text } = req.query
5 | const html = await md(text, 'README.md', '/', false)
6 | console.log(text, html)
7 | res.status(200).end(html)
8 | }
9 |
--------------------------------------------------------------------------------
/docs/pages/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import {
3 | Container,
4 | NavLink,
5 | Heading,
6 | Text,
7 | Card,
8 | Grid,
9 | Label,
10 | Textarea
11 | } from 'theme-ui'
12 | import Head from 'next/head'
13 | import Meta from '@hackclub/meta'
14 | import ColorSwitcher from '../components/color-switcher'
15 | import Content from '../components/content'
16 |
17 | const sample = `# Hello!
18 |
19 | This is [Hack Club **Markdown**](https://github.com/hackclub/markdown).
20 |
21 | \`\`\`js
22 | const hi = () => console.log('Hello!')
23 | \`\`\`
24 | `
25 |
26 | export default () => {
27 | const [text, setText] = useState(sample)
28 | const [html, setHtml] = useState('')
29 | useEffect(() => {
30 | fetch(`/api/md?text=${encodeURIComponent(text)}`)
31 | .then((res) => res.text())
32 | .then((res) => setHtml(res))
33 | }, [text])
34 | return (
35 |
36 |
41 |
42 |
43 |
52 | Hack Club
53 | {' '}
54 |
60 | Markdown
61 |
62 |
63 |
75 | GitHub
76 |
77 | npm
78 |
79 |
80 |
81 |
82 |
85 |
94 |
95 |
96 |
97 |
98 |
99 | )
100 | }
101 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@hackclub/markdown",
3 | "description": "Render Markdown to HTML, Hack Club-style",
4 | "version": "0.0.43",
5 | "author": "Lachlan Campbell (https://lachlanjc.me)",
6 | "source": "src/index.js",
7 | "main": "dist/index.js",
8 | "module": "dist/index.esm.js",
9 | "unpkg": "dist/index.umd.js",
10 | "sideEffects": false,
11 | "scripts": {
12 | "test": "ava",
13 | "prepare": "rm -rf ./dist && microbundle --external none",
14 | "watch": "microbundle watch --no-compress --external none",
15 | "build": "yarn prepare && cd docs && yarn build"
16 | },
17 | "publishConfig": {
18 | "access": "public"
19 | },
20 | "license": "MIT",
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/hackclub/markdown.git"
24 | },
25 | "dependencies": {
26 | "@babel/core": "^7.12.10",
27 | "@babel/preset-env": "^7.12.11",
28 | "@mapbox/rehype-prism": "^0.5.0",
29 | "caniuse-lite": "^1.0.30001282",
30 | "github-slugger": "^1.3.0",
31 | "hast-util-sanitize": "^3.0.2",
32 | "mdast-util-to-string": "^2.0.0",
33 | "next": "^10.0.4",
34 | "rehype": "^12.0.0",
35 | "rehype-raw": "^5.0.0",
36 | "rehype-sanitize": "^4.0.0",
37 | "rehype-stringify": "^8.0.0",
38 | "remark-parse": "^9.0.0",
39 | "remark-rehype": "^8.0.0",
40 | "unified": "^9.2.0",
41 | "unist-util-remove": "^2.0.1",
42 | "unist-util-visit": "^2.0.3"
43 | },
44 | "devDependencies": {
45 | "@babel/cli": "^7.12.10",
46 | "ava": "^3.14.0",
47 | "microbundle": "^0.13.0"
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | trailingComma: 'none',
4 | printWidth: 80,
5 | semi: false
6 | }
7 |
--------------------------------------------------------------------------------
/src/github.js:
--------------------------------------------------------------------------------
1 | export default {
2 | "strip": [
3 | "script"
4 | ],
5 | "clobberPrefix": "user-content-",
6 | "clobber": [
7 | "name",
8 | "id"
9 | ],
10 | "ancestors": {
11 | "tbody": [
12 | "table"
13 | ],
14 | "tfoot": [
15 | "table"
16 | ],
17 | "thead": [
18 | "table"
19 | ],
20 | "td": [
21 | "table"
22 | ],
23 | "th": [
24 | "table"
25 | ],
26 | "tr": [
27 | "table"
28 | ],
29 | "source": [
30 | "video"
31 | ]
32 | },
33 | "protocols": {
34 | "href": [
35 | "http",
36 | "https",
37 | "mailto",
38 | "xmpp",
39 | "irc",
40 | "ircs"
41 | ],
42 | "cite": [
43 | "http",
44 | "https"
45 | ],
46 | "src": [
47 | "http",
48 | "https"
49 | ],
50 | "longDesc": [
51 | "http",
52 | "https"
53 | ]
54 | },
55 | "tagNames": [
56 | "h1",
57 | "h2",
58 | "h3",
59 | "h4",
60 | "h5",
61 | "h6",
62 | "br",
63 | "b",
64 | "i",
65 | "strong",
66 | "em",
67 | "a",
68 | "pre",
69 | "code",
70 | "img",
71 | "tt",
72 | "div",
73 | "ins",
74 | "del",
75 | "sup",
76 | "sub",
77 | "p",
78 | "ol",
79 | "ul",
80 | "table",
81 | "thead",
82 | "tbody",
83 | "tfoot",
84 | "blockquote",
85 | "dl",
86 | "dt",
87 | "dd",
88 | "kbd",
89 | "q",
90 | "samp",
91 | "var",
92 | "hr",
93 | "ruby",
94 | "rt",
95 | "rp",
96 | "li",
97 | "tr",
98 | "td",
99 | "th",
100 | "s",
101 | "strike",
102 | "summary",
103 | "details",
104 | "caption",
105 | "figure",
106 | "figcaption",
107 | "abbr",
108 | "bdo",
109 | "cite",
110 | "dfn",
111 | "mark",
112 | "small",
113 | "span",
114 | "time",
115 | "wbr",
116 | "input",
117 | "video",
118 | "source"
119 | ],
120 | "attributes": {
121 | "a": [
122 | "href"
123 | ],
124 | "img": [
125 | "src",
126 | "longDesc"
127 | ],
128 | "input": [
129 | ["type", "checkbox"],
130 | ["disabled", true]
131 | ],
132 | "li": [
133 | ["className", "task-list-item"]
134 | ],
135 | "div": [
136 | "itemScope",
137 | "itemType"
138 | ],
139 | "blockquote": [
140 | "cite"
141 | ],
142 | "del": [
143 | "cite"
144 | ],
145 | "ins": [
146 | "cite"
147 | ],
148 | "q": [
149 | "cite"
150 | ],
151 | "*": [
152 | "abbr",
153 | "accept",
154 | "acceptCharset",
155 | "accessKey",
156 | "action",
157 | "align",
158 | "alt",
159 | "ariaDescribedBy",
160 | "ariaHidden",
161 | "ariaLabel",
162 | "ariaLabelledBy",
163 | "axis",
164 | "border",
165 | "cellPadding",
166 | "cellSpacing",
167 | "char",
168 | "charOff",
169 | "charSet",
170 | "checked",
171 | "clear",
172 | "cols",
173 | "colSpan",
174 | "color",
175 | "compact",
176 | "coords",
177 | "dateTime",
178 | "dir",
179 | "disabled",
180 | "encType",
181 | "htmlFor",
182 | "frame",
183 | "headers",
184 | "height",
185 | "hrefLang",
186 | "hSpace",
187 | "isMap",
188 | "id",
189 | "label",
190 | "lang",
191 | "maxLength",
192 | "media",
193 | "method",
194 | "multiple",
195 | "name",
196 | "noHref",
197 | "noShade",
198 | "noWrap",
199 | "open",
200 | "prompt",
201 | "readOnly",
202 | "rel",
203 | "rev",
204 | "rows",
205 | "rowSpan",
206 | "rules",
207 | "scope",
208 | "selected",
209 | "shape",
210 | "size",
211 | "span",
212 | "start",
213 | "src",
214 | "summary",
215 | "tabIndex",
216 | "target",
217 | "title",
218 | "type",
219 | "useMap",
220 | "vAlign",
221 | "value",
222 | "vSpace",
223 | "width",
224 | "itemProp"
225 | ]
226 | },
227 | "required": {
228 | "input": {
229 | "type": "checkbox",
230 | "disabled": true
231 | }
232 | }
233 | }
234 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import unified from 'unified'
2 | import markdown from 'remark-parse'
3 | import remarkToRehype from 'remark-rehype'
4 | import raw from 'rehype-raw'
5 | import sanitize from 'rehype-sanitize'
6 | import prism from '@mapbox/rehype-prism'
7 | import html from 'rehype-stringify'
8 |
9 | // https://github.com/syntax-tree/hast-util-sanitize/blob/master/lib/github.json
10 | import githubSchema from './github'
11 | import docs, { handlers } from './rehype'
12 | import shToShellPlugin from './plugins/sh-to-shell'
13 | import videoDetailsPlugin from './plugins/video-link-to-details'
14 |
15 | // Allow className for all elements
16 | githubSchema.attributes['*'].push('className')
17 |
18 | // Create the processor—the order of the plugins is important
19 | const getProcessor = unified()
20 | .use(markdown)
21 | .use(shToShellPlugin)
22 | .use(remarkToRehype, { handlers, allowDangerousHtml: true })
23 | // Add custom HTML found in the markdown file to the AST
24 | .use(raw)
25 | // Sanitize the HTML (disabled temporarily as it was blocking workshops progress)
26 | // .use(sanitize, githubSchema)
27 | // Add syntax highlighting to the sanitized HTML
28 | .use(prism)
29 | .use(html)
30 | .use(videoDetailsPlugin)
31 | .freeze()
32 |
33 | const markdownToHtml = async (
34 | md,
35 | filePath = '/README.md',
36 | imagePrefix = '/',
37 | removeTitle = false
38 | ) => {
39 | try {
40 | // Init the processor with our custom plugin
41 | const processor = getProcessor().use(docs, {
42 | filePath,
43 | imagePrefix,
44 | removeTitle
45 | })
46 | const file = await processor.process(md)
47 |
48 | // Replace non-breaking spaces (char code 160) with normal spaces to avoid style issues
49 | return file.contents.replace(/\xA0/g, ' ')
50 | } catch (error) {
51 | console.error(`Markdown to HTML error: ${error}`)
52 | throw error
53 | }
54 | }
55 |
56 | export default markdownToHtml
57 |
--------------------------------------------------------------------------------
/src/plugins/sh-to-shell.js:
--------------------------------------------------------------------------------
1 | import visit from 'unist-util-visit'
2 |
3 | export default function shToShellPlugin() {
4 | return (tree) => {
5 | visit(tree, 'code', (node) => {
6 | if (node.lang === 'sh') {
7 | node.lang = 'shell'
8 | }
9 | })
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/plugins/video-link-to-details/index.js:
--------------------------------------------------------------------------------
1 | // Derived from https://github.com/jaywcjlove/rehype-video/tree/main/src
2 |
3 | import visit from 'unist-util-visit'
4 | import { detailsNode } from './node'
5 |
6 | // TODO improve regex
7 | const videoTest = /^(https?:\/\/[^\s]+(\.mp4|\.mov))$/
8 |
9 | // const srcDelimiter = /((?:https?:\/\/)(?:(?:[a-z0-9]?(?:[a-z0-9\-]{1,61}[a-z0-9])?\.[^\.|\s])+[a-z\.]*[a-z]+|(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3})(?::\d{1,5})*[a-z0-9.,_\/~#&=;%+?\-\\(\\)]*)/g
10 |
11 | function nodeToDetails(node, src) {
12 | const filename = src.split('/').pop()
13 | const newNode = detailsNode(filename, src)
14 | for (const k in newNode) node[k] = newNode[k]
15 | }
16 |
17 | const videoLinkToDetails = () => {
18 | const nodeTest = (node) => {
19 | const child = node.children && node.children[0]
20 | return (
21 | node.tagName === 'p' &&
22 | node.children.length === 1 &&
23 | child.type === 'text' &&
24 | videoTest.test(child.value)
25 | )
26 | }
27 |
28 | return (tree) => {
29 | visit(tree, nodeTest, (node) => {
30 | const child = node.children[0]
31 | nodeToDetails(node, child.value)
32 |
33 | // if (
34 | // child.type === 'element' &&
35 | // child.tagName === 'a' &&
36 | // child.properties &&
37 | // typeof child.properties.href === 'string' &&
38 | // videoTest.test(child.properties.href)
39 | // ) {
40 | // nodeToDetails(node, child.properties.href)
41 | // }
42 | })
43 | }
44 | }
45 |
46 | export default videoLinkToDetails
47 |
--------------------------------------------------------------------------------
/src/plugins/video-link-to-details/node.js:
--------------------------------------------------------------------------------
1 | export function detailsNode(title, src) {
2 | return {
3 | type: 'element',
4 | tagName: 'details',
5 | properties: { open: true, className: 'details-video' },
6 | children: [
7 | {
8 | type: 'element',
9 | tagName: 'summary',
10 | children: [
11 | {
12 | type: 'element',
13 | tagName: 'div',
14 | properties: { className: 'details-video-summary' },
15 | children: [
16 | {
17 | type: 'element',
18 | tagName: 'span',
19 | properties: {
20 | className: 'details-video-caret'
21 | },
22 | children: []
23 | },
24 | {
25 | type: 'element',
26 | tagName: 'svg',
27 | properties: {
28 | 'aria-hidden': true,
29 | height: 16,
30 | width: 16,
31 | viewBox: '0 0 16 16',
32 | version: '1.1',
33 | className: 'video-summary-camera-icon'
34 | },
35 | children: [
36 | {
37 | type: 'element',
38 | tagName: 'path',
39 | properties: {
40 | 'fill-rule': 'evenodd',
41 | d: 'M16 3.75a.75.75 0 00-1.136-.643L11 5.425V4.75A1.75 1.75 0 009.25 3h-7.5A1.75 1.75 0 000 4.75v6.5C0 12.216.784 13 1.75 13h7.5A1.75 1.75 0 0011 11.25v-.675l3.864 2.318A.75.75 0 0016 12.25v-8.5zm-5 5.075l3.5 2.1v-5.85l-3.5 2.1v1.65zM9.5 6.75v-2a.25.25 0 00-.25-.25h-7.5a.25.25 0 00-.25.25v6.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-4.5z'
42 | },
43 | children: []
44 | }
45 | ]
46 | },
47 | {
48 | type: 'element',
49 | tagName: 'span',
50 | properties: {
51 | 'aria-label': `${title}`
52 | },
53 | children: [
54 | {
55 | type: 'text',
56 | value: title || ''
57 | }
58 | ]
59 | }
60 | ]
61 | }
62 | ]
63 | },
64 | {
65 | type: 'element',
66 | tagName: 'video',
67 | properties: {
68 | src: src,
69 | muted: 'muted',
70 | controls: 'controls',
71 | style: 'max-height:640px;'
72 | },
73 | children: []
74 | }
75 | ]
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/rehype.js:
--------------------------------------------------------------------------------
1 | import visit from 'unist-util-visit'
2 | import toString from 'mdast-util-to-string'
3 | import removeNode from 'unist-util-remove'
4 | import GithubSlugger from 'github-slugger'
5 |
6 | export const handlers = {
7 | // Add a className to inlineCode so we can differentiate between it and code fragments
8 | inlineCode(h, node) {
9 | return {
10 | ...node,
11 | type: 'element',
12 | tagName: 'code',
13 | properties: { className: 'inline' },
14 | children: [
15 | {
16 | type: 'text',
17 | value: node.value
18 | }
19 | ]
20 | }
21 | }
22 | }
23 |
24 | const ABSOLUTE_URL = /^https?:\/\/|^\/\//i
25 | // These headings will be include a link to their hash
26 | const HEADINGS = ['h2', 'h3', 'h4', 'h5', 'h6']
27 |
28 | const removeExt = path => {
29 | const basePath = path.split(/#|\?/)[0]
30 | const i = basePath.lastIndexOf('.')
31 |
32 | if (i === -1) return path
33 | return basePath.substring(0, i) + path.substring(basePath.length)
34 | }
35 |
36 | const rehypeDocs = ({ filePath, imagePrefix, removeTitle }) => {
37 | const slugger = new GithubSlugger()
38 | const anchorSlugger = new GithubSlugger()
39 |
40 | const visitAnchor = node => {
41 | if (!node.properties) return
42 | const { href } = node.properties
43 | if (!href) return
44 |
45 | const [relativePath, hash] = href.split('#')
46 |
47 | // Reset the slugger because single pages can have multiple urls to the same hash
48 | anchorSlugger.reset()
49 | // Update the hash used by anchors to match the one set for headers
50 | node.properties.href = hash
51 | ? `${relativePath}#${anchorSlugger.slug(hash)}`
52 | : relativePath
53 | // Relative URL for other files
54 | if (href.startsWith('/') || href.startsWith('#')) {
55 | node.properties.href = removeExt(node.properties.href)
56 | node.properties.className = 'internal'
57 | } else {
58 | node.properties.className = 'external'
59 | }
60 | }
61 |
62 | const visitHeading = node => {
63 | const text = toString(node)
64 |
65 | if (!text) return
66 |
67 | const id = slugger.slug(text)
68 | node.properties.id = id
69 | node.properties.className = 'heading'
70 |
71 | node.children = [
72 | {
73 | type: 'element',
74 | tagName: 'a',
75 | properties: {
76 | href: `#${id}`
77 | },
78 | children: node.children
79 | }
80 | ]
81 | }
82 |
83 | const visitImage = node => {
84 | let { src } = node.properties
85 | if (!src) return
86 | // If linking to a remote image, ignore
87 | if (src.match(ABSOLUTE_URL)) return node
88 | // If linking to an internal image, possibly prefix path
89 | node.properties.src = `${imagePrefix}/${filePath.replace(
90 | '/README.md',
91 | ''
92 | )}/${src}`
93 | }
94 |
95 | // Remove title from beginning of docs
96 | const visitH1 = (node, tree) => {
97 | if (node.position.start.line < 4) removeNode(tree, node)
98 | return tree
99 | }
100 |
101 | return tree => {
102 | visit(tree, node => node.tagName === 'a', visitAnchor)
103 | visit(tree, node => HEADINGS.includes(node.tagName), visitHeading)
104 | visit(tree, node => node.tagName === 'img', visitImage)
105 | if (removeTitle) {
106 | visit(
107 | tree,
108 | node => node.tagName === 'h1',
109 | node => visitH1(node, tree)
110 | )
111 | }
112 | }
113 | }
114 |
115 | export default rehypeDocs
116 |
--------------------------------------------------------------------------------
/test/index.js:
--------------------------------------------------------------------------------
1 | const test = require('ava')
2 | const md = require('../dist')
3 |
4 | const text = `# Beep
5 |
6 | **Boop!** from [Hack Club](https://hackclub.com/).
7 | `
8 |
9 | const text2 = `
10 | \`\`\`sh
11 | const hi = () => console.log('Hello!')
12 | \`\`\`
13 | `
14 |
15 | const textWithVideo = `
16 | This should not be rendered: https://user-images.githubusercontent.com/27078897/141502788-e4687f86-2d43-458b-a524-c420fba09ffd.mov
17 |
18 | But this should!
19 |
20 | https://user-images.githubusercontent.com/27078897/141502788-e4687f86-2d43-458b-a524-c420fba09ffd.mov
21 | `
22 |
23 | test('returns html for markdown', async (t) => {
24 | const result = await md(text)
25 | t.is(typeof result, 'string')
26 | t.true(result.startsWith(''))
27 | t.true(result.includes(''))
28 | t.true(result.includes('Hack Club'))
29 | t.snapshot(result)
30 | })
31 |
32 | test('returns html code sh markdown', async (t) => {
33 | const result = await md(text2)
34 | t.is(typeof result, 'string')
35 | t.true(result.includes(' {
40 | const result = await md(textWithVideo)
41 | t.is(typeof result, 'string')
42 | t.true(result.includes(' Snapshot 1
10 |
11 | `Beep
␊
12 | Boop! from Hack Club.
`
13 |
14 | ## returns html code sh markdown
15 |
16 | > Snapshot 1
17 |
18 | `const hi = () => console.log('Hello!')␊
19 |
`
20 |
21 | ## returns html code for video link
22 |
23 | > Snapshot 1
24 |
25 | `This should not be rendered: https://user-images.githubusercontent.com/27078897/141502788-e4687f86-2d43-458b-a524-c420fba09ffd.mov
␊
26 | But this should!
␊
27 | 141502788-e4687f86-2d43-458b-a524-c420fba09ffd.mov
`
28 |
--------------------------------------------------------------------------------
/test/snapshots/index.js.snap:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hackclub/markdown/d3f8142814e2eb918d532df9b80a4415bd303d03/test/snapshots/index.js.snap
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "public": true,
3 | "github": { "silent": true }
4 | }
5 |
--------------------------------------------------------------------------------