├── .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 `[](${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)');
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 |
--------------------------------------------------------------------------------