├── .github ├── .agp ├── dependabot.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── Makefile ├── README.md ├── _config.yml ├── markdown_live_preview ├── __init__.py ├── __main__.py ├── client │ ├── index.html │ ├── main.ts │ ├── mermaid.ts │ ├── recon.ts │ └── site.scss └── server │ ├── __init__.py │ ├── __main__.py │ ├── consts.py │ ├── lexers.py │ ├── log.py │ ├── render.py │ ├── server.py │ └── watch.py ├── mypy.ini ├── package.json ├── preview ├── preview.gif └── smol.gif ├── pyproject.toml ├── setup.cfg └── tsconfig.json /.github/.agp: -------------------------------------------------------------------------------- 1 | 2 | Auto Github Push (AGP) 3 | https://github.com/ms-jpq/auto-github-push 4 | 5 | --- 6 | 2023-08-22 05:28 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: pip 5 | directory: / 6 | schedule: 7 | interval: daily 8 | 9 | - package-ecosystem: npm 10 | directory: / 11 | schedule: 12 | interval: daily 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | push: 6 | pull_request: 7 | schedule: 8 | - cron: "0 0 * * *" # daily 9 | 10 | jobs: 11 | release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-python@v3 18 | 19 | - uses: actions/setup-node@v3 20 | 21 | - run: |- 22 | make lint 23 | 24 | - run: |- 25 | make release 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Release 3 | 4 | on: 5 | push: 6 | branches: 7 | - md 8 | paths: 9 | - "pyproject.toml" 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - uses: actions/setup-python@v3 19 | 20 | - uses: actions/setup-node@v3 21 | 22 | - run: |- 23 | make lint 24 | 25 | - run: |- 26 | make release 27 | 28 | - uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | password: ${{ secrets.PYPI_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | .DS_Store 3 | .mypy_cache/ 4 | /.cache/ 5 | /.git/ 6 | /.parcel-cache/ 7 | /.venv/ 8 | /dist/ 9 | /node_modules/ 10 | /temp/ 11 | __pycache__/ 12 | build/ 13 | markdown_live_preview/js/ 14 | package-lock.json 15 | tsconfig.tsbuildinfo 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 80, 4 | "semi": false, 5 | "tabWidth": 2 6 | } 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS += --jobs 2 | MAKEFLAGS += --no-builtin-rules 3 | MAKEFLAGS += --warn-undefined-variables 4 | SHELL := bash 5 | .DELETE_ON_ERROR: 6 | .ONESHELL: 7 | .SHELLFLAGS := --norc --noprofile -Eeuo pipefail -O dotglob -O nullglob -O extglob -O failglob -O globstar -c 8 | 9 | .DEFAULT_GOAL := dev 10 | 11 | .PHONY: clean clobber init run 12 | .PHONY: lint mypy tsc 13 | .PHONY: fmt black prettier 14 | .PHONY: release build 15 | .PHONY: dev watch-py watch-js 16 | 17 | DIST := markdown_live_preview/js 18 | 19 | clean: 20 | shopt -u failglob 21 | rm -v -rf -- .cache/ .mypy_cache/ build/ dist/ markdown_live_preview.egg-info/ '$(DIST)' tsconfig.tsbuildinfo 22 | 23 | clobber: clean 24 | shopt -u failglob 25 | rm -v -rf -- node_modules/ .venv/ 26 | 27 | .venv/bin/python3: 28 | python3 -m venv -- .venv 29 | 30 | define PYDEPS 31 | from itertools import chain 32 | from os import execl 33 | from sys import executable 34 | 35 | from tomli import load 36 | 37 | with open("pyproject.toml", "rb") as fd: 38 | toml = load(fd) 39 | 40 | project = toml["project"] 41 | execl( 42 | executable, 43 | executable, 44 | "-m", 45 | "pip", 46 | "install", 47 | "--upgrade", 48 | "--", 49 | *project.get("dependencies", ()), 50 | *chain.from_iterable(project["optional-dependencies"].values()), 51 | ) 52 | endef 53 | export -- PYDEPS 54 | 55 | .venv/bin/mypy: .venv/bin/python3 56 | '$<' -m pip install -- tomli 57 | '$<' <<< "$$PYDEPS" 58 | 59 | tsc: node_modules/.bin/esbuild 60 | node_modules/.bin/tsc 61 | 62 | mypy: .venv/bin/mypy 63 | '$<' --disable-error-code=method-assign -- . 64 | 65 | node_modules/.bin/esbuild: 66 | npm install 67 | 68 | node_modules/.bin/sass: | node_modules/.bin/esbuild 69 | 70 | init: .venv/bin/mypy node_modules/.bin/esbuild 71 | 72 | lint: mypy tsc 73 | 74 | $(DIST): 75 | mkdir -p -- '$@' 76 | 77 | $(DIST)/__init__.py: $(DIST) 78 | touch -- '$@' 79 | 80 | .cache/codehl.css: | .venv/bin/mypy 81 | mkdir -p -- .cache 82 | .venv/bin/python3 -m markdown_live_preview.server > '$@' 83 | 84 | $(DIST)/site.css: node_modules/.bin/sass .cache/codehl.css $(shell shopt -u failglob && printf -- '%s ' ./markdown_live_preview/client/*.*css) | $(DIST) 85 | '$<' --style compressed -- markdown_live_preview/client/site.scss '$@' 86 | 87 | $(DIST)/index.html: markdown_live_preview/client/index.html | $(DIST) 88 | cp --recursive --force --reflink=auto -- '$<' '$@' 89 | 90 | $(DIST)/main.js $(DIST)/mermaid.js: node_modules/.bin/esbuild $(shell shopt -u failglob && printf -- '%s ' ./markdown_live_preview/client/*.ts) | $(DIST) 91 | node_modules/.bin/esbuild --bundle --format=esm --outfile='$@' ./markdown_live_preview/client/$(basename $(@F)).ts 92 | 93 | build: $(DIST)/__init__.py $(DIST)/index.html $(DIST)/main.js $(DIST)/mermaid.js $(DIST)/site.css 94 | 95 | release: build 96 | .venv/bin/python3 < Namespace: 34 | parser = ArgumentParser() 35 | 36 | parser.add_argument("markdown") 37 | 38 | location = parser.add_argument_group() 39 | location.add_argument("-p", "--port", type=int, default=0) 40 | location.add_argument("-o", "--open", action="store_true") 41 | location.add_argument("-e", "--encoding", default=getdefaultencoding()) 42 | 43 | watcher = parser.add_argument_group() 44 | watcher.add_argument("-t", "--throttle", type=float, default=0.10) 45 | 46 | behaviour = parser.add_argument_group() 47 | behaviour.add_argument("--nf", "--no-follow", dest="follow", action="store_false") 48 | behaviour.add_argument("--nb", "--no-browser", dest="browser", action="store_false") 49 | 50 | return parser.parse_args() 51 | 52 | 53 | def _socks(open: bool, port: int) -> Iterator[socket]: 54 | fam = AddressFamily.AF_INET6 if has_ipv6 else AddressFamily.AF_INET 55 | sock = socket(fam) 56 | host = "" if open else "localhost" 57 | 58 | if has_dualstack_ipv6() and fam == AddressFamily.AF_INET6: 59 | sock.setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, 0) 60 | sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 61 | sock.bind((host, port)) 62 | yield sock 63 | 64 | addr, port, *_ = sock.getsockname() 65 | ip = ip_address(addr) 66 | if isinstance(ip, IPv6Address) and ip.is_loopback: 67 | sock = socket(AddressFamily.AF_INET) 68 | sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) 69 | sock.bind((host, port)) 70 | yield sock 71 | 72 | 73 | @lru_cache(maxsize=None) 74 | def _fqdn() -> str: 75 | return getfqdn() 76 | 77 | 78 | def _addr(sock: socket) -> str: 79 | addr, bind, *_ = sock.getsockname() 80 | ip = ip_address(addr) 81 | if ip.is_unspecified: 82 | host = _fqdn() 83 | elif ip.is_loopback: 84 | host = "localhost" 85 | elif isinstance(ip, IPv6Address): 86 | host = f"[{ip}]" 87 | else: 88 | host = str(ip) 89 | return f"{host}:{bind}" 90 | 91 | 92 | async def _main() -> int: 93 | args = _parse_args() 94 | try: 95 | path = Path(args.markdown).resolve(strict=True) 96 | except OSError as e: 97 | log.critical("%s", e) 98 | return 1 99 | else: 100 | render_f = render("friendly") 101 | 102 | async def gen() -> AsyncIterator[Payload]: 103 | async for __ in watch(args.throttle, path=path): 104 | assert __ or True 105 | try: 106 | md = path.read_text(args.encoding) 107 | except (OSError, UnicodeDecodeError) as e: 108 | log.warn("%s", e) 109 | await sleep(args.throttle) 110 | else: 111 | xhtml = render_f(md) 112 | sha = str(hash(xhtml)) 113 | payload = Payload( 114 | follow=args.follow, title=path.name, sha=sha, xhtml=xhtml 115 | ) 116 | yield payload 117 | 118 | socks = tuple(_socks(args.open, port=args.port)) 119 | 120 | serve = build(socks, cwd=path.parent, gen=gen()) 121 | async for __ in serve(): 122 | assert not __ 123 | binds = sorted({*map(_addr, socks)}, key=strxfrm) 124 | for idx, bind in enumerate(binds): 125 | uri = f"http://{bind}" 126 | log.info("%s", f"SERVING -- {quote(uri)}") 127 | if not idx and args.browser: 128 | open_w(uri) 129 | 130 | return 0 131 | 132 | 133 | def main() -> NoReturn: 134 | try: 135 | code = run(_main()) 136 | except KeyboardInterrupt: 137 | exit(130) 138 | else: 139 | exit(code) 140 | 141 | 142 | if __name__ == "__main__": 143 | main() 144 | -------------------------------------------------------------------------------- /markdown_live_preview/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 |
15 | 16 | 17 |
18 |
19 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /markdown_live_preview/client/main.ts: -------------------------------------------------------------------------------- 1 | import mermaid from "mermaid" 2 | import { diff_key, mermaid_class, reconciliate } from "./recon.js" 3 | 4 | const CYCLE = 500 5 | 6 | const head = document.body.querySelector("#title")! 7 | const root = document.body.querySelector("article")! 8 | const template = document.createElement("template") 9 | 10 | type API = { title: string; sha: string; follow: boolean } 11 | 12 | const api_request = async (): Promise => 13 | await (await fetch(`${location.origin}/api/info`)).json() 14 | 15 | const ws_connect = async function* () { 16 | const remote = new URL(`ws://${location.host}/ws`) 17 | let flag = false 18 | let cb = (): void => undefined 19 | let ws = new WebSocket(remote) 20 | 21 | const provision = () => { 22 | ws.onmessage = () => { 23 | flag = true 24 | cb() 25 | } 26 | ws.onclose = async () => { 27 | await new Promise((resolve) => setTimeout(resolve, CYCLE)) 28 | ws = new WebSocket(remote) 29 | provision() 30 | } 31 | } 32 | provision() 33 | 34 | while (true) { 35 | try { 36 | if (!flag) { 37 | await new Promise((resolve) => (cb = resolve)) 38 | } 39 | yield 40 | flag = false 41 | } catch (e) { 42 | console.error(e) 43 | ws.close() 44 | } 45 | } 46 | } 47 | 48 | const render = async (root: DocumentFragment) => { 49 | const nodes = root.querySelectorAll(`.${mermaid_class} code`) 50 | await Promise.all( 51 | (function* () { 52 | let i = 0 53 | for (const node of nodes) { 54 | const text = node.textContent 55 | if (text) { 56 | const id = `${mermaid_class}-svg-${i++}` 57 | yield (async () => { 58 | try { 59 | const { svg, bindFunctions } = await mermaid.render(id, text) 60 | const figure = document.createElement("figure") 61 | figure.classList.add(mermaid_class) 62 | figure.dataset.mermaid = text 63 | figure.innerHTML = svg 64 | bindFunctions?.(figure) 65 | node.replaceWith(figure) 66 | } catch (e) { 67 | const { message } = e as Error 68 | const el = document.createElement("pre") 69 | el.append(new Text(message)) 70 | node.parentElement?.insertBefore(el, node) 71 | } 72 | })() 73 | } 74 | } 75 | })(), 76 | ) 77 | } 78 | 79 | const update = ((sha) => async (follow: boolean, new_sha: string) => { 80 | if (new_sha === sha) { 81 | return 82 | } else { 83 | sha = new_sha 84 | } 85 | 86 | const page = await (await fetch(`${location.origin}/api/markdown`)).text() 87 | 88 | template.innerHTML = page 89 | const figs = document.querySelectorAll(`figure.${mermaid_class}`) 90 | for (const fig of figs) { 91 | fig.firstElementChild?.removeAttribute("id") 92 | } 93 | await render(template.content) 94 | template.normalize() 95 | for (const fig of figs) { 96 | fig.firstElementChild?.remove() 97 | } 98 | 99 | reconciliate({ 100 | root, 101 | lhs: root, 102 | rhs: template.content, 103 | }) 104 | 105 | const marked = root.querySelectorAll(`[${diff_key}="${true}"]`) 106 | const [touched, ..._] = marked 107 | 108 | if (follow && touched) { 109 | new IntersectionObserver((entries, obs) => { 110 | obs.disconnect() 111 | const visible = entries.some(({ isIntersecting }) => isIntersecting) 112 | if (!visible) { 113 | touched.scrollIntoView({ 114 | behavior: "smooth", 115 | block: "center", 116 | inline: "center", 117 | }) 118 | } 119 | }).observe(touched) 120 | } 121 | })("") 122 | 123 | const main = async () => { 124 | mermaid.initialize({ startOnLoad: false }) 125 | 126 | const gen = ws_connect() 127 | for await (const _ of gen) { 128 | console.debug("-> ws") 129 | try { 130 | const { title, follow, sha } = await api_request() 131 | document.title ||= title 132 | head.textContent ||= title 133 | await update(follow, sha) 134 | } catch (e) { 135 | console.error(e) 136 | await new Promise((resolve) => setTimeout(resolve, CYCLE)) 137 | } 138 | } 139 | } 140 | 141 | await main() 142 | -------------------------------------------------------------------------------- /markdown_live_preview/client/mermaid.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/markdown-live-preview/aac80957a57242c3726b15a23ccd95000e06114b/markdown_live_preview/client/mermaid.ts -------------------------------------------------------------------------------- /markdown_live_preview/client/recon.ts: -------------------------------------------------------------------------------- 1 | export const diff_key = "diff" 2 | export const mermaid_class = "language-mermaid" 3 | 4 | const long_zip = function* < 5 | T extends Iterable[], 6 | R extends { 7 | readonly [K in keyof T]: T[K] extends Iterable 8 | ? V | undefined 9 | : never 10 | }, 11 | >(...iterables: T): IterableIterator { 12 | const iterators = iterables.map((i) => i[Symbol.iterator]()) 13 | while (true) { 14 | const acc = iterators.map((i) => i.next()) 15 | if (acc.every((r) => r.done ?? false)) { 16 | break 17 | } else { 18 | yield acc.map((r) => r.value) as unknown as R 19 | } 20 | } 21 | } 22 | 23 | export const reconciliate = ({ 24 | root, 25 | lhs, 26 | rhs, 27 | }: { 28 | root: Node 29 | lhs: Node 30 | rhs: Node 31 | }) => { 32 | let diff = false 33 | for (const [l, r] of long_zip([...lhs.childNodes], [...rhs.childNodes])) { 34 | if (l && !r) { 35 | diff = true 36 | l.remove() 37 | } else if (!l && r) { 38 | if ( 39 | !( 40 | lhs instanceof HTMLElement && 41 | rhs instanceof HTMLElement && 42 | lhs.classList.contains(mermaid_class) && 43 | rhs.classList.contains(mermaid_class) && 44 | lhs.dataset.mermaid === rhs.dataset.mermaid 45 | ) 46 | ) { 47 | diff = true 48 | } 49 | lhs.appendChild(r) 50 | } else if (l instanceof HTMLElement && r instanceof HTMLElement) { 51 | if (l.tagName !== r.tagName) { 52 | l.replaceWith(r) 53 | } else { 54 | for (const { name, value } of l.attributes) { 55 | if (r.getAttribute(name) !== value) { 56 | if (name !== diff_key) { 57 | diff = true 58 | } 59 | l.removeAttribute(name) 60 | } 61 | } 62 | 63 | for (const { name, value } of r.attributes) { 64 | if (l.getAttribute(name) !== value) { 65 | diff = true 66 | l.setAttribute(name, value) 67 | } 68 | } 69 | 70 | reconciliate({ root, lhs: l, rhs: r }) 71 | } 72 | } else if (l!.nodeType !== r!.nodeType) { 73 | diff = true 74 | lhs.replaceChild(r!, l!) 75 | } else if (l!.nodeValue !== r!.nodeValue) { 76 | diff = true 77 | l!.nodeValue = r!.nodeValue 78 | } 79 | } 80 | 81 | if (diff && lhs !== root) { 82 | const el = lhs instanceof Element ? lhs : lhs.parentElement 83 | el?.setAttribute(diff_key, String(true)) 84 | } else if (lhs instanceof Element) { 85 | lhs.removeAttribute(diff_key) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /markdown_live_preview/client/site.scss: -------------------------------------------------------------------------------- 1 | @use "../../node_modules/modern-normalize/modern-normalize.css"; 2 | @use "../../node_modules/github-markdown-css/github-markdown.css"; 3 | @use "../../.cache/codehl.css"; 4 | 5 | :root { 6 | --focus-colour: #fdf5bf70; 7 | --border-colour: rgb(225, 228, 232); 8 | --padding-size: 1em; 9 | } 10 | 11 | body { 12 | padding: 1em; 13 | background-color: #ffffff; 14 | } 15 | 16 | @media (prefers-color-scheme: dark) { 17 | :root { 18 | --focus-colour: #83782e70; 19 | } 20 | 21 | body { 22 | color: #c9d1d9; 23 | background-color: #0d1117; 24 | } 25 | } 26 | 27 | main { 28 | border: thin solid var(--border-colour); 29 | border-radius: 0.3em; 30 | 31 | margin: auto; 32 | max-width: 60em; 33 | 34 | padding: var(--padding-size); 35 | } 36 | 37 | header { 38 | display: grid; 39 | grid-template-columns: auto auto 1fr auto; 40 | align-items: baseline; 41 | column-gap: 0.5em; 42 | 43 | > * { 44 | margin-top: 0; 45 | margin-bottom: 0; 46 | } 47 | 48 | > .spacer { 49 | flex-grow: 1; 50 | } 51 | 52 | margin-bottom: var(--padding-size); 53 | 54 | .menu-icon { 55 | font-size: 130%; 56 | } 57 | } 58 | 59 | .mermaid { 60 | display: flex; 61 | justify-content: center; 62 | } 63 | 64 | [diff="true"] { 65 | background-color: var(--focus-colour) !important; 66 | } 67 | -------------------------------------------------------------------------------- /markdown_live_preview/server/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/markdown-live-preview/aac80957a57242c3726b15a23ccd95000e06114b/markdown_live_preview/server/__init__.py -------------------------------------------------------------------------------- /markdown_live_preview/server/__main__.py: -------------------------------------------------------------------------------- 1 | from sys import stdout 2 | 3 | from .render import css 4 | 5 | stdout.write(css()) 6 | -------------------------------------------------------------------------------- /markdown_live_preview/server/consts.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | TOP_LV = Path(__file__).parent.parent 4 | JS_ROOT = TOP_LV / "js" 5 | 6 | HEARTBEAT_TIME = 1 7 | -------------------------------------------------------------------------------- /markdown_live_preview/server/lexers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pygments import lexers 4 | from pygments.lexer import Lexer 5 | from pygments.lexers import get_lexer_by_name 6 | from pygments.lexers.special import TextLexer 7 | 8 | _glbn = get_lexer_by_name 9 | 10 | 11 | class _MermaidLexer(TextLexer): 12 | name = "mermaid" 13 | aliases = [name] 14 | 15 | 16 | def _get_lexer_by_name(alias: str, **options: Any) -> Lexer: 17 | if alias == _MermaidLexer.name: 18 | return _MermaidLexer(**options) 19 | else: 20 | return _glbn(alias, **options) 21 | 22 | 23 | lexers.get_lexer_by_name = _get_lexer_by_name 24 | 25 | _ = 1 * 1 26 | -------------------------------------------------------------------------------- /markdown_live_preview/server/log.py: -------------------------------------------------------------------------------- 1 | from logging import INFO, StreamHandler, getLogger 2 | 3 | log = getLogger(__name__) 4 | log.setLevel(INFO) 5 | log.addHandler(StreamHandler()) 6 | -------------------------------------------------------------------------------- /markdown_live_preview/server/render.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable, Sequence 2 | from locale import strxfrm 3 | from os import linesep 4 | from typing import no_type_check 5 | 6 | from markdown import Markdown 7 | from markdown.extensions import Extension 8 | from markdown.extensions.abbr import makeExtension as abbr 9 | from markdown.extensions.attr_list import makeExtension as attr_list 10 | from markdown.extensions.footnotes import makeExtension as footnotes 11 | from markdown.extensions.md_in_html import makeExtension as md_in_html 12 | from markdown.extensions.sane_lists import makeExtension as sane_lists 13 | from markdown.extensions.smarty import makeExtension as smarty 14 | from markdown.extensions.tables import makeExtension as tables 15 | from markdown.extensions.toc import makeExtension as toc 16 | from markdown.extensions.wikilinks import makeExtension as wikilinks 17 | from pygments.formatters.html import HtmlFormatter 18 | from pygments.styles import get_all_styles, get_style_by_name 19 | from pymdownx.arithmatex import makeExtension as arithmatex 20 | from pymdownx.b64 import makeExtension as b64 21 | from pymdownx.betterem import makeExtension as betterem 22 | from pymdownx.blocks.admonition import makeExtension as admonition 23 | from pymdownx.blocks.definition import makeExtension as definition 24 | from pymdownx.blocks.details import makeExtension as details 25 | from pymdownx.blocks.html import makeExtension as html 26 | from pymdownx.blocks.tab import makeExtension as tab 27 | from pymdownx.caret import makeExtension as caret 28 | from pymdownx.critic import makeExtension as critic 29 | from pymdownx.highlight import makeExtension as highlight 30 | from pymdownx.keys import makeExtension as keys 31 | from pymdownx.mark import makeExtension as mark 32 | from pymdownx.progressbar import makeExtension as progressbar 33 | from pymdownx.saneheaders import makeExtension as saneheaders 34 | from pymdownx.smartsymbols import makeExtension as smartsymbols 35 | from pymdownx.snippets import makeExtension as snippets 36 | from pymdownx.superfences import makeExtension as superfences 37 | from pymdownx.tasklist import makeExtension as tasklist 38 | from pymdownx.tilde import makeExtension as tilde 39 | 40 | _CODEHL_CLASS = "codehilite" 41 | 42 | 43 | @no_type_check 44 | def _extensions(style: str) -> Sequence[Extension]: 45 | return ( 46 | abbr(), 47 | admonition(), 48 | arithmatex(), 49 | attr_list(), 50 | b64(), 51 | betterem(), 52 | caret(), 53 | critic(), 54 | definition(), 55 | details(), 56 | footnotes(), 57 | highlight( 58 | css_class=f"{_CODEHL_CLASS} {style}", 59 | pygments_lang_class=True, 60 | ), 61 | html(), 62 | keys(), 63 | mark(), 64 | md_in_html(), 65 | progressbar(), 66 | sane_lists(), 67 | saneheaders(), 68 | smartsymbols(), 69 | smarty(), 70 | snippets(), 71 | superfences(), 72 | tab(), 73 | tables(), 74 | tasklist(), 75 | tilde(), 76 | toc(), 77 | wikilinks(), 78 | ) 79 | 80 | 81 | def css() -> str: 82 | lines = ( 83 | f".{_CODEHL_CLASS}.{name} {line}" 84 | for name in get_all_styles() 85 | for line in HtmlFormatter(style=get_style_by_name(name)) 86 | .get_style_defs() 87 | .splitlines() 88 | ) 89 | css = linesep.join(sorted(lines, key=strxfrm)) 90 | return css 91 | 92 | 93 | def render(style: str) -> Callable[[str], str]: 94 | parser = Markdown(extensions=_extensions(style), tab_length=2) 95 | 96 | def render(md: str) -> str: 97 | return parser.convert(md) 98 | 99 | return render 100 | -------------------------------------------------------------------------------- /markdown_live_preview/server/server.py: -------------------------------------------------------------------------------- 1 | from asyncio import gather 2 | from collections.abc import AsyncIterator, Callable, Iterable 3 | from contextlib import suppress 4 | from dataclasses import dataclass 5 | from pathlib import Path, PurePath, PurePosixPath 6 | from posixpath import join, sep 7 | from socket import socket 8 | from weakref import WeakSet 9 | 10 | from aiohttp.typedefs import Handler 11 | from aiohttp.web import ( 12 | Application, 13 | AppRunner, 14 | Response, 15 | RouteTableDef, 16 | SockSite, 17 | WebSocketResponse, 18 | json_response, 19 | middleware, 20 | ) 21 | from aiohttp.web_fileresponse import FileResponse 22 | from aiohttp.web_middlewares import normalize_path_middleware 23 | from aiohttp.web_request import BaseRequest, Request 24 | from aiohttp.web_response import StreamResponse 25 | 26 | from .consts import HEARTBEAT_TIME, JS_ROOT 27 | 28 | 29 | @dataclass(frozen=True) 30 | class Payload: 31 | follow: bool 32 | title: str 33 | sha: str 34 | xhtml: str 35 | 36 | 37 | def build( 38 | socks: Iterable[socket], cwd: PurePath, gen: AsyncIterator[Payload] 39 | ) -> Callable[[], AsyncIterator[None]]: 40 | payload = Payload(follow=False, title="", sha="", xhtml="") 41 | 42 | @middleware 43 | async def cors(request: Request, handler: Handler) -> StreamResponse: 44 | resp = await handler(request) 45 | resp.headers["Access-Control-Allow-Origin"] = "*" 46 | return resp 47 | 48 | @middleware 49 | async def local_files(request: Request, handler: Handler) -> StreamResponse: 50 | with suppress(ValueError, OSError): 51 | rel = PurePosixPath(request.path).relative_to(join(sep, "cwd")) 52 | path = Path(cwd / rel).resolve(strict=True) 53 | if path.relative_to(cwd) and path.is_file(): 54 | return FileResponse(path) 55 | 56 | return await handler(request) 57 | 58 | middlewares = ( 59 | normalize_path_middleware(), 60 | local_files, 61 | cors, 62 | ) 63 | routes = RouteTableDef() 64 | websockets: WeakSet = WeakSet() 65 | app = Application(middlewares=middlewares) 66 | 67 | @routes.get(sep) 68 | async def index_resp(request: BaseRequest) -> FileResponse: 69 | assert request 70 | return FileResponse(JS_ROOT / "index.html") 71 | 72 | assert index_resp 73 | 74 | @routes.get(join(sep, "ws")) 75 | async def ws_resp(request: BaseRequest) -> WebSocketResponse: 76 | ws = WebSocketResponse(heartbeat=HEARTBEAT_TIME) 77 | await ws.prepare(request) 78 | websockets.add(ws) 79 | 80 | with suppress(ConnectionError): 81 | await ws.send_str("") 82 | async for _ in ws: 83 | with suppress(ConnectionError): 84 | await ws.send_str("") 85 | 86 | return ws 87 | 88 | assert ws_resp 89 | 90 | @routes.get(join(sep, "api", "info")) 91 | async def meta_resp(request: BaseRequest) -> StreamResponse: 92 | assert request 93 | 94 | json = {"follow": payload.follow, "title": payload.title, "sha": payload.sha} 95 | return json_response(json) 96 | 97 | assert meta_resp 98 | 99 | @routes.get(join(sep, "api", "markdown")) 100 | async def markdown_resp(request: BaseRequest) -> StreamResponse: 101 | assert request 102 | 103 | return Response(text=payload.xhtml, content_type="text/html") 104 | 105 | assert markdown_resp 106 | 107 | async def broadcast() -> None: 108 | nonlocal payload 109 | async for p in gen: 110 | payload = p 111 | tasks = (ws.send_str("") for ws in websockets) 112 | with suppress(ConnectionError): 113 | await gather(*tasks) 114 | 115 | routes.static(prefix=sep, path=JS_ROOT) 116 | routes.static(prefix=sep, path=cwd) 117 | app.add_routes(routes) 118 | 119 | async def start() -> AsyncIterator[None]: 120 | runner = AppRunner(app) 121 | try: 122 | await runner.setup() 123 | sites = {SockSite(runner, sock=sock).start() for sock in socks} 124 | await gather(*sites) 125 | yield None 126 | await broadcast() 127 | finally: 128 | await runner.shutdown() 129 | await runner.cleanup() 130 | 131 | return start 132 | -------------------------------------------------------------------------------- /markdown_live_preview/server/watch.py: -------------------------------------------------------------------------------- 1 | from asyncio import Queue, get_running_loop 2 | from asyncio.locks import Event 3 | from asyncio.tasks import ( 4 | FIRST_COMPLETED, 5 | create_task, 6 | gather, 7 | run_coroutine_threadsafe, 8 | sleep, 9 | wait, 10 | ) 11 | from collections.abc import AsyncIterable, ByteString 12 | from os.path import normcase 13 | from pathlib import Path 14 | 15 | from watchdog.events import FileSystemEvent, FileSystemEventHandler 16 | from watchdog.observers import Observer 17 | 18 | 19 | async def watch(throttle: float, path: Path) -> AsyncIterable[str]: 20 | loop = get_running_loop() 21 | chan: Queue[None] = Queue(1) 22 | ev = Event() 23 | ev.set() 24 | 25 | async def notify() -> None: 26 | done, _ = await wait( 27 | (create_task(ev.wait()), create_task(sleep(throttle, False))), 28 | return_when=FIRST_COMPLETED, 29 | ) 30 | go = done.pop().result() 31 | if go and ev.is_set(): 32 | ev.clear() 33 | await gather(chan.put(None), create_task(sleep(throttle))) 34 | ev.set() 35 | 36 | def send(event: FileSystemEvent) -> None: 37 | src = ( 38 | bytes(event.src_path).decode() 39 | if isinstance(event.src_path, ByteString) 40 | else event.src_path 41 | ) 42 | if Path(src) == path: 43 | run_coroutine_threadsafe(notify(), loop=loop) 44 | 45 | class Handler(FileSystemEventHandler): 46 | def on_created(self, event: FileSystemEvent) -> None: 47 | send(event) 48 | 49 | def on_modified(self, event: FileSystemEvent) -> None: 50 | send(event) 51 | 52 | obs = Observer() 53 | obs.schedule(Handler(), path=normcase(path.parent)) 54 | obs.start() 55 | 56 | while True: 57 | try: 58 | yield path.read_text("UTF-8") 59 | except GeneratorExit: 60 | obs.stop() 61 | break 62 | await chan.get() 63 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | check_untyped_defs = true 3 | disallow_any_generics = false 4 | disallow_any_unimported = false 5 | disallow_incomplete_defs = true 6 | disallow_subclassing_any = false 7 | disallow_untyped_calls = true 8 | disallow_untyped_decorators = true 9 | disallow_untyped_defs = true 10 | error_summary = true 11 | implicit_reexport = false 12 | no_implicit_optional = true 13 | pretty = true 14 | show_column_numbers = true 15 | show_error_codes = true 16 | show_error_context = true 17 | strict = true 18 | strict_concatenate = true 19 | strict_equality = true 20 | warn_incomplete_stub = true 21 | warn_redundant_casts = true 22 | warn_return_any = true 23 | warn_unreachable = true 24 | warn_unused_configs = true 25 | warn_unused_ignores = true 26 | 27 | [mypy-pygments.*] 28 | ignore_missing_imports = True 29 | 30 | [mypy-pymdownx.*] 31 | ignore_missing_imports = True 32 | 33 | [mypy-setuptools.*] 34 | ignore_missing_imports = True 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/package.json", 3 | "dependencies": { 4 | "esbuild": "^0.24", 5 | "github-markdown-css": "^5", 6 | "mermaid": "^11", 7 | "modern-normalize": "^3", 8 | "sass": "^1" 9 | }, 10 | "devDependencies": { 11 | "@types/node": "*", 12 | "typescript": "*" 13 | }, 14 | "type": "module" 15 | } 16 | -------------------------------------------------------------------------------- /preview/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/markdown-live-preview/aac80957a57242c3726b15a23ccd95000e06114b/preview/preview.gif -------------------------------------------------------------------------------- /preview/smol.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ms-jpq/markdown-live-preview/aac80957a57242c3726b15a23ccd95000e06114b/preview/smol.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | authors = [{ name = "ms-jpq", email = "github@bigly.dog" }] 3 | description = "live web preview of markdown docs" 4 | name = "markdown-live-preview" 5 | readme = "README.md" 6 | requires-python = ">=3.9.0" 7 | version = "0.2.69" 8 | 9 | dependencies = [ 10 | "Markdown==3.7", 11 | "Pygments==2.18.0", 12 | "aiohttp==3.11.10", 13 | "pymdown-extensions==10.9", 14 | "watchdog==6.0.0", 15 | ] 16 | [project.optional-dependencies] 17 | dev = ["mypy", "types-Markdown", "black", "isort"] 18 | dist = ["setuptools", "wheel"] 19 | 20 | [project.urls] 21 | homepage = "https://github.com/ms-jpq/markdown-live-preview" 22 | 23 | [project.scripts] 24 | "mlp" = "markdown_live_preview.__main__:main" 25 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [options] 2 | include_package_data = True 3 | packages = find: 4 | 5 | [options.package_data] 6 | * = 7 | py.typed 8 | *.html 9 | *.css 10 | *.js 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig.json", 3 | "compileOnSave": true, 4 | "compilerOptions": { 5 | "checkJs": true, 6 | "composite": true, 7 | "module": "nodenext", 8 | "noEmit": true, 9 | "noErrorTruncation": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitOverride": true, 12 | "noImplicitReturns": true, 13 | "noUncheckedIndexedAccess": true, 14 | "resolveJsonModule": true, 15 | "rootDir": ".", 16 | "skipLibCheck": true, 17 | "sourceMap": true, 18 | "strict": true, 19 | "target": "esnext", 20 | "verbatimModuleSyntax": true 21 | }, 22 | "exclude": ["node_modules", "tmp", "markdown_live_preview/js"], 23 | "include": ["."], 24 | "references": [] 25 | } 26 | --------------------------------------------------------------------------------