Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ on:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
- name: Shell sanity
run: |
bash -n open-reader.command
sudo apt-get update -qq
sudo apt-get install -y -qq shellcheck
shellcheck --severity=warning --format=gcc open-reader.command
- run: npm install
- run: node test/sanitize.mjs
- run: node test/generate-bundle.mjs
- run: node test/heartbeat_v16.mjs
10 changes: 7 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,27 @@ MD Reader is a single HTML file with zero external dependencies. This is the pro
git clone https://github.com/andersyin/md-reader.git
cd md-reader
npm install # installs playwright-core for testing
node test/sanitize.mjs # no browser
node test/generate-bundle.mjs # generate test bundle
node test/heartbeat_v16.mjs # run 34 assertions
node test/heartbeat_v16.mjs # run 37 Playwright assertions
```

## Guidelines

### HTML/JS changes
- All code goes in `md-reader.html` — single file, no build step
- Escape all user content (markdown is untrusted input)
- Test XSS resistance with `test/xss-sample.md`
- Reject `javascript:` / `data:` / `vbscript:` even when camouflaged (ZWSP, HTML entities, percent-encoding)
- Test XSS resistance with `test/xss-sample.md` and `node test/sanitize.mjs`
- Keep file size under 100KB

### Testing
```bash
node test/sanitize.mjs # regenerates nothing; must stay green
node test/generate-bundle.mjs # regenerate bundle after fixture changes
node test/heartbeat_v16.mjs # must pass all 34 assertions
node test/heartbeat_v16.mjs # must pass all 37 assertions
```
Linux CI runs `bash -n` + ShellCheck on `open-reader.command`, then the Node checks above. It cannot exercise Finder double-click.

### Pull requests
- One feature/fix per PR
Expand Down
54 changes: 32 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ AI tools and developers produce a lot of markdown — reports, analysis, documen

### Security

- **XSS hardened** — all HTML is escaped; `<script>`, `javascript:`, `data:`, `vbscript:` protocols are stripped from links and images
- **XSS hardened** — all HTML is escaped; `javascript:`, `data:`, and `vbscript:` (including Unicode / HTML-entity / percent-encoding camouflage) are stripped from links and images. CSP + `no-referrer` as defense in depth.
- **Code blocks** — payloads inside code blocks are displayed as text, never executed
- **Tested** — adversarial XSS samples are included in the test suite
- **Zero network** — no external requests, no CDN, no tracking, no analytics
- **Tested** — adversarial XSS samples (including protocol camouflage) are in the test suite
- **Zero network (reader chrome)** — no CDN, no tracking, no analytics. The reader itself makes no requests. `http(s)` images *inside a markdown file* will still be fetched by the browser; referrers are stripped.

### Themes

Expand All @@ -82,31 +82,32 @@ AI tools and developers produce a lot of markdown — reports, analysis, documen

```bash
git clone https://github.com/andersyin/md-reader.git
# macOS
open md-reader/md-reader.html
# Linux: xdg-open md-reader/md-reader.html
```

Drag and drop any `.md` file onto the browser window.
Drag and drop any `.md` file onto the browser window. This is the empty-state path the HTML itself describes.

### Option B: macOS Launcher (recommended)
### Option B: Launcher (multi-file + AI sidecars)

```bash
git clone https://github.com/andersyin/md-reader.git
cd md-reader

# Open one or more markdown files
# Open one or more markdown files (macOS `open`, Linux `xdg-open`)
bash open-reader.command report.md analysis.md notes.md

# Or double-click open-reader.command in Finder (uses last bundle)
# Or double-click open-reader.command in Finder (uses last bundle; macOS)
```

The launcher bundles your markdown files (and AI summary sidecars) into a single `_md_bundle.js`, then opens the reader with all files pre-loaded. Switch between documents from the file list sidebar.
The launcher bundles your markdown files (and AI summary sidecars) into a single `_md_bundle.js`, then opens the reader with all files pre-loaded. Switch between documents from the file list sidebar. Requires Python 3.

### AI Summary Sidecar

Create a JSON file next to your markdown file:
Create `report.md.summary.json` next to `report.md`:

```json
// report.md.summary.json
{
"tl_dr": "Quarterly revenue grew 23% YoY, driven by SaaS expansion.",
"key_points": [
Expand Down Expand Up @@ -156,25 +157,32 @@ When you open `report.md` via the launcher, the summary card appears at the top.

```bash
npm install # installs playwright-core
node test/sanitize.mjs # sanitizer + first-run/policy checks (no browser)
node test/generate-bundle.mjs # generates test bundle from fixtures
node test/heartbeat_v16.mjs # runs 34 assertions across 3 phases
node test/heartbeat_v16.mjs # Playwright: 37 assertions across 3 phases
```

Or `npm test` (sanitize + bundle + heartbeat).

Test phases:
- **sanitize** — `safeUrl` camouflage, XSS fixture render, CSP/zero-CDN, empty-state filename
- **Phase C** — Edit mode: enter, modify, apply, undo, download, switch, keyboard shortcuts
- **Phase D** — XSS resistance: script injection, dangerous protocols, code block safety
- **Phase E** — Regression: zero-network, search, HTML export, lightbox, status bar, back-to-top
- **Phase D** — XSS resistance: fixture + edit-injected script/protocol/camouflage, code block safety
- **Phase E** — Regression: zero-network chrome, search, HTML export, lightbox, status bar, back-to-top

## File Structure

```
md-reader/
├── md-reader.html # The reader (single file, 77KB, zero deps)
├── open-reader.command # macOS launcher (bundles files + opens reader)
├── md-reader.html # The reader (single file, ~77KB, zero deps)
├── open-reader.command # Launcher (bundles files + opens reader)
├── LICENSE
├── CONTRIBUTING.md
├── package.json # For running tests only
├── .github/workflows/test.yml # sanitize + shellcheck + Playwright
├── test/
│ ├── heartbeat_v16.mjs # Playwright test suite (34 assertions)
│ ├── sanitize.mjs # Node-only sanitizer / policy checks
│ ├── heartbeat_v16.mjs # Playwright suite (37 assertions)
│ ├── generate-bundle.mjs # Generates test bundle from fixtures
│ ├── xss-sample.md # XSS adversarial test fixture
│ ├── summary-demo.md # AI summary sidecar demo
Expand All @@ -186,14 +194,15 @@ md-reader/
## Requirements

- Any modern browser (Chrome, Firefox, Safari, Edge)
- For the launcher: macOS with Python 3 (pre-installed on macOS)
- For testing: Node.js + Chrome/Chromium
- For the launcher: Python 3; macOS `open` or Linux `xdg-open`
- For testing: Node.js 22 + Chrome/Chromium

## Limitations

- Files are loaded into browser memory; very large files (>10MB) may be slow
- The launcher (`open-reader.command`) is macOS-only; on other platforms, use drag-and-drop
- Finder double-click of `open-reader.command` is macOS-only; on Linux use `bash open-reader.command …` or drag-and-drop
- AI summaries are read-only from sidecar JSON; the reader does not generate summaries
- Markdown may contain `http(s)` or `file:` URLs. The reader will load those images and follow links on click. Untrusted markdown can therefore cause network requests (tracking pixels) even though the reader chrome is offline.

## Contributing

Expand Down Expand Up @@ -223,8 +232,8 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). Especially:
- **阅读进度** — 状态栏显示滚动百分比,按文档记忆位置
- **轻量编辑** — V1.6 新增,可直接编辑源码,实时预览,原文件只读不修改
- **三主题** — 亮色 / 暗色 / 护眼
- **XSS 防护** — 所有 HTML 转义,危险协议拦截,代码块载荷原样展示
- **零外网** — 无 CDN、无追踪、无分析,完全离线
- **XSS 防护** — 所有 HTML 转义,危险协议(含 Unicode/实体/百分号伪装)拦截,CSP + 无 Referer
- **阅读器零外网** — 无 CDN、无追踪、无分析。Markdown 里的 `http(s)` 图片仍会被浏览器拉取

### 快速开始

Expand Down Expand Up @@ -260,8 +269,9 @@ open md-reader.html

```bash
npm install # 安装 playwright-core
node test/sanitize.mjs # 无浏览器的消毒 / 首启检查
node test/generate-bundle.mjs # 从测试夹具生成 bundle
node test/heartbeat_v16.mjs # 运行 34 项断言
node test/heartbeat_v16.mjs # 运行 37 项断言
```

---
Expand Down
94 changes: 68 additions & 26 deletions md-reader.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-mdreader'; style-src 'unsafe-inline'; img-src * file: blob:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-src 'none'">
<title>MD 阅读器</title>
<style>
:root{
Expand Down Expand Up @@ -421,9 +423,9 @@
<div class="big">📖</div>
<h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>
<p>
推荐入口:让 AI 跑 <code>打开阅读器.command &lt;md文件路径&gt;</code><br>
自动装载文件、生成大纲与要点提炼,附带 AI 摘要(如有)。<br><br>
也可以直接把 <code>.md</code> 文件拖进本页面,或
<code>.md</code> 文件拖进本页面,或点下方按钮选择文件。<br>
多文件 + AI 摘要 sidecar:在仓库目录运行<br>
<code>bash open-reader.command notes.md</code>
</p>
<button class="pick" id="btn-pick">选择 md 文件…</button>
</div>
Expand Down Expand Up @@ -462,8 +464,8 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>

<input type="file" id="file-input" accept=".md,.markdown,.txt,text/markdown" style="display:none" multiple>

<script src="_md_bundle.js"></script>
<script>
<script nonce="mdreader" src="_md_bundle.js"></script>
<script nonce="mdreader">
'use strict';
/* ============================================================
* MD 阅读器 V1.6 — 单文件 / 零依赖 / 零外网请求
Expand All @@ -488,12 +490,13 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>
* V1.3(2026-07-29):① frontmatter 系统/AI 字段收进折叠;② 文档元信息提炼。
* V1.2(2026-07-29):🖨 PDF 导出 + 🙋 人读模式(AI 向内容折叠)。
* V1.1(2026-07-29):① 路径复制;② 默认护眼绿主题;③ 多文档一次性传全部路径。
* 数据链路:打开阅读器.command 收集 md(+可选 *.md.summary.json AI 摘要
* 数据链路:open-reader.command 收集 md(+可选 *.md.summary.json AI 摘要
* sidecar)→ 写 _md_bundle.js(window.__MD_BUNDLE__)→ classic script
* 装载(file:// 无 CORS);兜底:拖拽 / 文件选择。
* 安全:所有 md 文本先 HTML 转义再按规则生成标签;链接/图片 URL 走
* 协议白名单(javascript: 等一律拒绝),正文不入 localStorage(只存
* 阅读偏好与滚动位置,规避 5MB 配额)。
* 协议白名单(javascript: / data: / vbscript: 及 Unicode/实体/百分号伪装一律拒绝),
* 正文不入 localStorage(只存阅读偏好与滚动位置,规避 5MB 配额)。
* V1.6.2:CSP + Referrer-Policy;safeUrl 归一化后再判协议。
* ============================================================ */

/* ── 工具函数 ─────────────────────────── */
Expand All @@ -516,13 +519,51 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>
navigator.clipboard.writeText(txt).then(()=>toast(okMsg)).catch(fallback);
else fallback();
}
/* URL 白名单:http/https/mailto/锚点/相对路径/file;拒绝 javascript: data: vbscript: 等 */
/* URL 白名单:http/https/mailto/锚点/相对路径/file;拒绝 javascript: data: vbscript: 等。
* 先解码 HTML 实体与百分号、剥 Unicode 空白/格式字符,再判协议(防 ZWSP / &colon; / %3A 伪装)。 */
function decodeHtmlEntities(s){
return String(s)
.replace(/&amp;/gi,'&')
.replace(/&colon;/gi,':')
.replace(/&#x([0-9a-f]+);/gi,(m,h)=>{const n=parseInt(h,16);return (n>=0&&n<=0x10ffff)?String.fromCodePoint(n):'';})
.replace(/&#(\d+);/g,(m,d)=>{const n=Number(d);return (n>=0&&n<=0x10ffff)?String.fromCodePoint(n):'';});
}
function stripUrlNoise(s){
return String(s).replace(/[\u0000-\u0020\u007f\u00a0\u00ad\u1680\u180e\u2000-\u200f\u2028-\u202f\u205f-\u206f\u3000\u3164\ufeff\uffa0]/g,'');
}
function pctDecode(s){
let out=String(s),n=0;
while(n++<5){
try{
const next=decodeURIComponent(out);
if(next===out)break;
out=next;
}catch{
const next=out.replace(/%([0-9a-fA-F]{2})/g,(m,h)=>{
const c=parseInt(h,16);
if(c<=32||c===127)return '';
return String.fromCharCode(c);
});
if(next===out)break;
out=next;
}
}
return out;
}
function safeUrl(u){
const s=String(u).trim().replace(/[\u0000-\u0020]/g,'').toLowerCase();
if(/["'<>\\]|&quot;|&#39;|&lt;|&gt;/.test(s))return false; /* 含引号/尖括号的畸形 URL 一律拒绝 */
const raw=String(u).trim();
if(!raw)return false;
if(/["'<>\\]|&quot;|&#39;|&lt;|&gt;/.test(raw.toLowerCase()))return false;
let s=stripUrlNoise(pctDecode(decodeHtmlEntities(raw))).toLowerCase();
s=stripUrlNoise(pctDecode(decodeHtmlEntities(s))).toLowerCase();
if(s.startsWith('//')||s.startsWith('\\\\'))return false;
if(/^(https?:|mailto:|file:|#)/.test(s))return true;
if(/^[a-z][a-z0-9+.-]*:/.test(s))return false; /* 其余带协议的一律拒绝 */
return true; /* 相对路径 / 绝对路径 */
if(/^[a-z][a-z0-9+.-]*:/.test(s))return false;
return true;
}
function asList(v){
if(v==null||v==='')return [];
return Array.isArray(v)?v:[v];
}

/* ── Markdown 解析器(GFM 常用子集)────── */
Expand All @@ -533,7 +574,7 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>
let s=String(raw).replace(/`([^`\n]+)`/g,(m,c)=>{codes.push(c);return '\u0000C'+(codes.length-1)+'\u0000';});
s=esc(s);
s=s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g,
(m,alt,src)=> safeUrl(src)?'<img src="'+src+'" alt="'+alt+'" loading="lazy">':alt);
(m,alt,src)=> safeUrl(src)?'<img src="'+src+'" alt="'+alt+'" loading="lazy" referrerpolicy="no-referrer">':alt);
s=s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g,
(m,txt,href)=> safeUrl(href)
? '<a href="'+href+'"'+(href.startsWith('#')?'':' target="_blank" rel="noopener noreferrer"')+'>'+txt+'</a>'
Expand Down Expand Up @@ -888,16 +929,17 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>

renderAiCard(sum){
const el=$('#ai-card');
if(!sum||(!sum.tl_dr&&!(sum.key_points||[]).length)){el.style.display='none';el.innerHTML='';return;}
const li=a=>(a||[]).map(x=>'<li>'+esc(String(x))+'</li>').join('');
const points=asList(sum&&sum.key_points), conclusions=asList(sum&&sum.conclusions), actions=asList(sum&&sum.action_items);
if(!sum||(!sum.tl_dr&&!points.length)){el.style.display='none';el.innerHTML='';return;}
const li=a=>asList(a).map(x=>'<li>'+esc(String(x))+'</li>').join('');
el.style.display='';
el.innerHTML='<div class="ai-head"><span>✨ AI 摘要</span><small>'+
esc(sum.generated_by||'')+(sum.generated_at?' · '+esc(sum.generated_at):'')+'</small></div>'+
esc(sum.generated_by||'')+(sum.generated_at?' · '+esc(String(sum.generated_at)):'')+'</small></div>'+
'<div class="ai-body">'+
(sum.tl_dr?'<div class="ai-tldr">'+esc(sum.tl_dr)+'</div>':'')+
((sum.key_points||[]).length?'<h5>核心要点</h5><ul>'+li(sum.key_points)+'</ul>':'')+
((sum.conclusions||[]).length?'<h5>核心结论</h5><ul>'+li(sum.conclusions)+'</ul>':'')+
((sum.action_items||[]).length?'<h5>行动项</h5><ul>'+li(sum.action_items)+'</ul>':'')+
(sum.tl_dr?'<div class="ai-tldr">'+esc(String(sum.tl_dr))+'</div>':'')+
(points.length?'<h5>核心要点</h5><ul>'+li(points)+'</ul>':'')+
(conclusions.length?'<h5>核心结论</h5><ul>'+li(conclusions)+'</ul>':'')+
(actions.length?'<h5>行动项</h5><ul>'+li(actions)+'</ul>':'')+
'</div>';
},

Expand Down Expand Up @@ -949,10 +991,10 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>
else h+='<div class="dg-muted">未提取到概要</div>';
if(sum.tags.length)h+='<div class="dg-tags">'+sum.tags.map(t=>'<span>'+esc(t)+'</span>').join('')+'</div>';
/* ② AI 摘要(如有 sidecar,放在概要之后作补充) */
if(f.summary&&(f.summary.tl_dr||(f.summary.key_points||[]).length)){
if(f.summary&&(f.summary.tl_dr||asList(f.summary.key_points).length)){
h+='<h3>✨ AI 摘要</h3>';
if(f.summary.tl_dr)h+='<div class="dg-item">'+esc(f.summary.tl_dr)+'</div>';
(f.summary.key_points||[]).forEach(k=>h+='<div class="dg-item">• '+esc(String(k))+'</div>');
if(f.summary.tl_dr)h+='<div class="dg-item">'+esc(String(f.summary.tl_dr))+'</div>';
asList(f.summary.key_points).forEach(k=>h+='<div class="dg-item">• '+esc(String(k))+'</div>');
}
/* ③ 全文脑图:标题骨架嵌套树 + 每节挂提炼要点 */
h+='<h3>🧠 全文脑图</h3>';
Expand Down Expand Up @@ -1446,8 +1488,8 @@ <h2>MD 阅读器 — 让 AI 产出的 Markdown 好读起来</h2>
if(e.key==='j'||e.key==='J'){e.preventDefault();window.scrollBy({top:scrollAmt,behavior:'smooth'});}
else if(e.key==='k'||e.key==='K'){e.preventDefault();window.scrollBy({top:-scrollAmt,behavior:'smooth'});}
else if(e.key==='/'){e.preventDefault();Search.show();}
else if(e.key==='g'||e.key==='G'){e.preventDefault();window.scrollTo({top:0,behavior:'smooth'});}
else if(e.key==='G'&&e.shiftKey){e.preventDefault();window.scrollTo({top:document.documentElement.scrollHeight,behavior:'smooth'});}
else if(e.key==='g'){e.preventDefault();window.scrollTo({top:0,behavior:'smooth'});}
else if(e.key==='G'){e.preventDefault();window.scrollTo({top:document.documentElement.scrollHeight,behavior:'smooth'});}
else if(e.key==='t'||e.key==='T'){e.preventDefault();$('#btn-side').click();}
else if(e.key==='d'||e.key==='D'){e.preventDefault();$('#btn-digest').click();}
else if(e.key==='['){e.preventDefault();if(App.cur>0)App.open(App.cur-1);}
Expand Down
Loading
Loading