├── .gitignore ├── main.js ├── .editorconfig ├── README.sb ├── README.md ├── lib ├── sb2md.js ├── formats.js ├── CodeBlock.js ├── Hashtag.js ├── Line.js ├── Document.js ├── Link.js └── Bracket.js ├── .github └── workflows │ └── ci.yml ├── package.json ├── cli.js └── test └── sb2md.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const { sb2md } = require("./lib/sb2md"); 3 | exports.convert = sb2md; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | -------------------------------------------------------------------------------- /README.sb: -------------------------------------------------------------------------------- 1 | [** sb2md] 2 | 3 | `sb2md` converts Scrapbox notation to Markdown. 4 | 5 | code:bash 6 | $ sb2md README.sb > README.md 7 | 8 | 9 | [** Related Works] 10 | 11 | - https://github.com/daiiz/sb2md 12 | - https://github.com/pastak/md2sb 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sb2md 2 | 3 | `sb2md` converts Scrapbox notation to Markdown. 4 | 5 | ```bash 6 | $ sb2md README.sb > README.md 7 | ``` 8 | 9 | 10 | Related Works 11 | 12 | - https://github.com/daiiz/sb2md 13 | - https://github.com/pastak/md2sb 14 | 15 | -------------------------------------------------------------------------------- /lib/sb2md.js: -------------------------------------------------------------------------------- 1 | const { Document } = require("./Document"); 2 | 3 | // todo: parse `[]` 4 | // todo: handle .icon 5 | // todo: handle# in url 6 | 7 | const sb2md = (source) => { 8 | const lines = source.split(/\n/); 9 | const document = new Document; 10 | document.accept(lines); 11 | return document.toMarkdown(); 12 | } 13 | 14 | exports.sb2md = sb2md; 15 | -------------------------------------------------------------------------------- /lib/formats.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | 3 | const link = (content, href) => { 4 | const parsed = url.parse(href); 5 | if (parsed.host && (parsed.host.match(/^gyazo\.com$/i) || parsed.host.match(/\.gyazo\.com$/i))) { 6 | return image(`${href}/thumb/250`, href); 7 | } 8 | return `[${content}](${href})`; 9 | }; 10 | 11 | const image = (src, href) => { 12 | return `[![${src}](${src})](${href})`; 13 | }; 14 | 15 | exports.link = link; 16 | exports.image = image; 17 | -------------------------------------------------------------------------------- /lib/CodeBlock.js: -------------------------------------------------------------------------------- 1 | class CodeBlock { 2 | constructor(lines) { 3 | this.header = lines.shift(); 4 | this.lines = []; 5 | } 6 | static match(lines) { 7 | return lines[0].match(/^code:/); 8 | } 9 | canAccept(lines) { 10 | return lines[0] && lines[0].match(/^\s+/); 11 | } 12 | accept(lines) { 13 | this.lines.push(lines.shift()); 14 | } 15 | toMarkdown() { 16 | const headerPart = this.header.split(':')[1].replace(/^.+\./, ''); 17 | return "```" + headerPart + "\n" + this.lines.join("\n") + "\n```"; 18 | } 19 | } 20 | 21 | exports.CodeBlock = CodeBlock; 22 | -------------------------------------------------------------------------------- /lib/Hashtag.js: -------------------------------------------------------------------------------- 1 | const { link } = require("./formats"); 2 | class Hashtag { 3 | constructor(chars) { 4 | this.chars = [chars.shift()]; 5 | } 6 | static match(chars) { 7 | return chars[0] === '#'; 8 | } 9 | canAccept(chars) { 10 | return chars[0] && chars[0].match(/\S/); 11 | } 12 | accept(chars) { 13 | this.chars.push(chars.shift()); 14 | } 15 | toMarkdown() { 16 | return link(`#${this.keyword()}`, `./${encodeURIComponent(this.keyword())}.md`); 17 | } 18 | keyword() { 19 | return this.chars.slice(1).join(''); 20 | } 21 | } 22 | 23 | exports.Hashtag = Hashtag; 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: CI 4 | on: 5 | pull_request: 6 | 7 | jobs: 8 | ci: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: '12.x' 16 | - uses: actions/cache@v1 17 | with: 18 | path: ~/.npm 19 | key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} 20 | restore-keys: | 21 | ${{ runner.os }}-npm- 22 | - name: install 23 | run: npm ci 24 | - name: test 25 | run: npm test 26 | -------------------------------------------------------------------------------- /lib/Line.js: -------------------------------------------------------------------------------- 1 | const { parseSymbols } = require("./Bracket"); 2 | 3 | class Line { 4 | constructor(lines) { 5 | this.rawContent = lines.shift(); 6 | } 7 | toMarkdown() { 8 | return this.indentPart() + this.bodyPart(); 9 | } 10 | indentPart() { 11 | const indentLevel = this.rawContent.match(/^\s*/)[0].length; 12 | if (indentLevel > 0) { 13 | return ' '.repeat(indentLevel) + '- '; 14 | } 15 | else { 16 | return ''; 17 | } 18 | } 19 | bodyPart() { 20 | const { symbols } = parseSymbols(this.rawContent.trim()); 21 | return symbols.map(s => s.toMarkdown ? s.toMarkdown() : s).join(''); 22 | } 23 | } 24 | 25 | exports.Line = Line; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sb2md", 3 | "version": "1.0.1", 4 | "description": "Convert Scrapbox notation to Markdown", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "ava" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+ssh://git@github.com/hitode909/sb2md.git" 12 | }, 13 | "author": "hitode909", 14 | "license": "ISC", 15 | "bin": { 16 | "sb2md": "./cli.js" 17 | }, 18 | "bugs": { 19 | "url": "https://github.com/hitode909/sb2md/issues" 20 | }, 21 | "homepage": "https://github.com/hitode909/sb2md#readme", 22 | "devDependencies": { 23 | "ava": "^1.2.1" 24 | }, 25 | "dependencies": { 26 | "commander": "^2.19.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/Document.js: -------------------------------------------------------------------------------- 1 | const { Line } = require("./Line"); 2 | const { CodeBlock } = require("./CodeBlock"); 3 | 4 | class Document { 5 | constructor() { 6 | this.contents = []; 7 | } 8 | accept(lines) { 9 | while (lines.length > 0) { 10 | if (CodeBlock.match(lines)) { 11 | const code = new CodeBlock(lines); 12 | while (code.canAccept(lines)) { 13 | code.accept(lines); 14 | } 15 | this.contents.push(code); 16 | } 17 | else { 18 | this.contents.push(new Line(lines)); 19 | } 20 | } 21 | } 22 | toMarkdown() { 23 | return this.contents.map(content => content.toMarkdown()).join(" \n"); 24 | } 25 | } 26 | exports.Document = Document; 27 | -------------------------------------------------------------------------------- /cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const command = require('commander'); 5 | const settings = require('./package.json'); 6 | const { sb2md } = require('./lib/sb2md'); 7 | 8 | let stdin = ''; 9 | 10 | command 11 | .version(settings.version) 12 | .description(settings.description) 13 | .usage('\n\tsb2mb [file] \n\tcat hoge.md | sb2mb') 14 | .arguments('[file]') 15 | .action(async (file) => { 16 | if (file) { 17 | const result = sb2md(fs.readFileSync(path.resolve(file), 'utf8')); 18 | console.log(result); 19 | } else { 20 | command.help(); 21 | } 22 | }); 23 | 24 | if (process.stdin.isTTY) { 25 | command.parse(process.argv) 26 | } else { 27 | process.stdin.on('readable', () => { 28 | const chunk = process.stdin.read(); 29 | if (chunk !== null) { 30 | stdin += chunk; 31 | } 32 | }) 33 | process.stdin.on('end', async () => { 34 | console.log(sb2md(stdin)); 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /lib/Link.js: -------------------------------------------------------------------------------- 1 | const { link, image } = require("./formats"); 2 | 3 | class Link { 4 | constructor(text) { 5 | this.text = text; 6 | } 7 | toMarkdown() { 8 | if (this.isRelativeLink()) { 9 | return this.toMarkdownRelativeLink(); 10 | } 11 | if (this.isExternalLink()) { 12 | return this.toMarkdownExternalLink(); 13 | } 14 | return link(this.text, `./${encodeURIComponent(this.text)}.md`); 15 | } 16 | isRelativeLink() { 17 | return this.text.charAt(0) === '/'; 18 | } 19 | toMarkdownRelativeLink() { 20 | return link(this.text, 'https://scrapbox.io' 21 | + this.text.split("/").map(t => encodeURIComponent(t)).join("/")); 22 | } 23 | isExternalLink() { 24 | const segments = this.text.split(/ /); 25 | return segments[0].match(/^http/i) || segments[segments.length - 1].match(/^http/i); 26 | } 27 | toMarkdownExternalLink() { 28 | const segments = this.text.split(/ /); 29 | let uri; 30 | if (segments[0].match(/^http/i)) { 31 | uri = segments.shift(); 32 | } 33 | else { 34 | uri = segments.pop(); 35 | } 36 | const keyword = segments.join(' '); 37 | if (!keyword && uri.match(/\.(jpg|png|gif)$/i)) { 38 | return image(uri, uri); 39 | } 40 | return link(keyword, uri); 41 | } 42 | } 43 | 44 | exports.Link = Link; 45 | -------------------------------------------------------------------------------- /test/sb2md.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | const { sb2md } = require('../lib/sb2md'); 3 | 4 | test('indent', t => { 5 | t.is(sb2md(' a'), ' - a'); 6 | }); 7 | 8 | test('single em', t => { 9 | t.is(sb2md("[* 強調]"), '強調'); 10 | }); 11 | 12 | test('triple em', t => { 13 | t.is(sb2md("[*** 強調]"), '強調'); 14 | }); 15 | 16 | test('not em but link', t => { 17 | t.is(sb2md('[*test]'), "[*test](./*test.md)"); 18 | }); 19 | 20 | test('link', t => { 21 | t.is(sb2md('[日本語]'), '[日本語](./%E6%97%A5%E6%9C%AC%E8%AA%9E.md)'); 22 | }); 23 | 24 | test('numerical link', t => { 25 | t.is(sb2md('[0]'), "[0](./0.md)"); 26 | }); 27 | 28 | test('internal link', t => { 29 | t.is(sb2md('[/textalive/TextAlive Fonts]'), "[/textalive/TextAlive Fonts](https://scrapbox.io/textalive/TextAlive%20Fonts)"); 30 | }); 31 | 32 | test('hashtag', t => { 33 | t.is(sb2md('#日本語'), '[#日本語](./%E6%97%A5%E6%9C%AC%E8%AA%9E.md)'); 34 | }); 35 | 36 | test('space', t => { 37 | t.is(sb2md(' [日本語] [* hoge]'), ' - [日本語](./%E6%97%A5%E6%9C%AC%E8%AA%9E.md) hoge'); 38 | }); 39 | 40 | test('nested', t => { 41 | t.is(sb2md('[* [日本語]]'), '[日本語](./%E6%97%A5%E6%9C%AC%E8%AA%9E.md)'); 42 | }); 43 | 44 | test('complex', t => { 45 | t.is(sb2md("\t[- [日本語]][[テスト]] #English"), ' - [日本語](./%E6%97%A5%E6%9C%AC%E8%AA%9E.md)テスト [#English](./English.md)'); 46 | }); 47 | 48 | // // FIXME closing `]` not found 49 | // test('error', t => { 50 | // t.is(sb2md('[* [日本語]'), '[日本語'); 51 | // }); 52 | 53 | test('image', t => { 54 | t.is(sb2md('[https://gyazo.com/b50a9bd54b16d3b1924043648ddca7d2]'), '[![https://gyazo.com/b50a9bd54b16d3b1924043648ddca7d2/thumb/250](https://gyazo.com/b50a9bd54b16d3b1924043648ddca7d2/thumb/250)](https://gyazo.com/b50a9bd54b16d3b1924043648ddca7d2)'); 55 | }); 56 | -------------------------------------------------------------------------------- /lib/Bracket.js: -------------------------------------------------------------------------------- 1 | const { Hashtag } = require("./Hashtag"); 2 | const { Link } = require("./Link"); 3 | 4 | class Bracket { 5 | constructor(chars) { 6 | this.chars = [chars.shift()]; 7 | this.symbols = []; 8 | } 9 | static match(chars) { 10 | return chars[0] === '['; 11 | } 12 | parse(chars) { 13 | if (chars.length <= 0) { 14 | // `[` at the end of line 15 | return; 16 | } 17 | if (chars[0] === ']') { 18 | // `[]` 19 | this.chars.push(chars.shift()); 20 | return; 21 | } 22 | if (chars[0] === '[') { 23 | // `[[bold text]]` 24 | this.bold = 1; 25 | this.chars.push(chars.shift()); 26 | 27 | // parse bracket content 28 | const res = parseSymbols(chars.join(''), ']'); 29 | if (!res) { 30 | // closing `]` not found 31 | this.chars.push(...chars.splice(0, chars.length)); 32 | this.bold = 0; 33 | this.symbols.splice(0, this.symbols.length); 34 | return; 35 | } 36 | this.symbols.push(...res.symbols); 37 | this.chars.push(...chars.splice(0, chars.length - res.left)); 38 | 39 | // `]]` 40 | this.chars.push(chars.shift()); 41 | this.chars.push(chars.shift()); 42 | return; 43 | } 44 | if (!/^[*_-]+/.test(chars.join(''))) { 45 | // `[link text]` 46 | this.parseLink(chars); 47 | return; 48 | } 49 | 50 | // check control char 51 | const c = chars.shift(); 52 | let level = 1; 53 | while (chars[0] === c) { 54 | this.chars.push(chars.shift()); 55 | level ++; 56 | } 57 | switch (c) { 58 | case '*': 59 | // `[* bold text]` 60 | this.bold = level; 61 | break; 62 | case '-': 63 | // `[- strike text]` 64 | this.del = level; 65 | break; 66 | case '_': 67 | // `[_ underline text]` 68 | this.u = level; 69 | break; 70 | } 71 | 72 | // remove spaces 73 | const spaces = chars.join('').match(/^\s+/); 74 | if (!spaces) { 75 | // no space after control chars: treat this as a link 76 | this.bold = this.del = this.u = 0; 77 | this.symbols.push(...this.chars.slice(1), c); 78 | this.parseLink(chars); 79 | return; 80 | } 81 | const numSpaces = spaces[0].length; 82 | this.chars.push(c, ...chars.splice(0, numSpaces)); 83 | 84 | // parse bracket content 85 | const res = parseSymbols(chars.join(''), ']'); 86 | if (!res) { 87 | // closing `]` not found 88 | this.chars.push(...chars.splice(0, chars.length)); 89 | this.bold = this.del = this.u = 0; 90 | this.symbols.splice(0, this.symbols.length); 91 | return; 92 | } 93 | this.symbols.push(...res.symbols); 94 | this.chars.push(...chars.splice(0, chars.length - res.left + 1)); 95 | } 96 | parseLink(chars) { 97 | this.link = true; 98 | while (chars.length > 0 && chars[0] !== ']') { 99 | const c = chars.shift(); 100 | this.chars.push(c); 101 | this.symbols.push(c); 102 | } 103 | if (chars.length <= 0) { 104 | this.link = false; 105 | this.symbols.splice(0, this.symbols.length); 106 | return; 107 | } 108 | this.chars.push(chars.shift()); 109 | } 110 | toMarkdown() { 111 | if (this.symbols.length > 0) { 112 | if (this.link) { 113 | const text = s2md(this.symbols); 114 | return new Link(text).toMarkdown(); 115 | // return `[${text}](./${encodeURIComponent(text)}.md)`; 116 | } 117 | if (this.bold) { 118 | return `${s2md(this.symbols)}`; 119 | } 120 | if (this.del) { 121 | return `${s2md(this.symbols)}`; 122 | } 123 | if (this.u) { 124 | return `${s2md(this.symbols)}`; 125 | } 126 | } 127 | // `[`, `[]`, and other unsupported brackets 128 | return this.chars.join(''); 129 | } 130 | } 131 | 132 | function parseSymbols(content, delimiter) { 133 | const symbols = []; 134 | const chars = content.split(''); 135 | while (chars.length > 0) { 136 | if (Hashtag.match(chars)) { 137 | // push a hashtag object 138 | const hashtag = new Hashtag(chars); 139 | while (hashtag.canAccept(chars)) { 140 | hashtag.accept(chars); 141 | } 142 | symbols.push(hashtag); 143 | } 144 | else if (Bracket.match(chars)) { 145 | // push a bracket symbol 146 | const bracket = new Bracket(chars); 147 | bracket.parse(chars); 148 | symbols.push(bracket); 149 | } 150 | else if (chars[0] === delimiter) { 151 | // delimiter found 152 | return { symbols, left: chars.length }; 153 | } 154 | else { 155 | // push a raw char 156 | symbols.push(chars.shift()); 157 | } 158 | } 159 | if (delimiter) { 160 | // delimiter not found 161 | return null; 162 | } 163 | 164 | // end of line 165 | return { symbols, left: 0 }; 166 | } 167 | 168 | function s2md(symbols) { 169 | return symbols.map(s => s.toMarkdown ? s.toMarkdown() : s).join(''); 170 | } 171 | 172 | exports.Bracket = Bracket; 173 | exports.parseSymbols = parseSymbols; 174 | exports.s2md = s2md; 175 | --------------------------------------------------------------------------------