├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── .vscodeignore ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs └── images │ ├── CHANGELOG │ ├── multi-workspace-tree-view.png │ ├── status-bar-upload-image.png │ └── tree-view.png │ ├── create-workspace.png │ ├── demo.gif │ ├── icon.png │ ├── icon.svg │ ├── preview-button.png │ ├── tree-view-commands.png │ └── zenn-contents-explorer.png ├── media └── icon │ ├── draft.svg │ ├── lock.svg │ ├── preview-dark.svg │ ├── preview-light.svg │ ├── published.svg │ └── unlock.svg ├── package.json ├── src ├── extension │ ├── extension.ts │ ├── preview │ │ ├── previewBackend.ts │ │ ├── previewDocument.ts │ │ ├── previewView.ts │ │ └── previewViewManager.ts │ ├── resource │ │ └── extensionResource.ts │ ├── statusBar │ │ └── imageUploaderItem.ts │ ├── treeView │ │ ├── articles.ts │ │ ├── books.ts │ │ ├── markdownMeta.ts │ │ ├── openZennTreeViewItemCommand.ts │ │ ├── workspace.ts │ │ ├── zennTreeItem.ts │ │ ├── zennTreeViewManager.ts │ │ └── zennTreeViewProvider.ts │ ├── util │ │ ├── uri.ts │ │ └── zennWorkspace.ts │ └── zenncli │ │ ├── zennCli.ts │ │ ├── zennNewArticle.ts │ │ ├── zennNewBook.ts │ │ ├── zennPreview.ts │ │ ├── zennPreviewProxyServer.ts │ │ └── zennVersion.ts ├── test │ ├── runTest.ts │ └── suite │ │ ├── extension.test.ts │ │ └── index.ts └── webview │ ├── full-page-iframe.css │ ├── proxyView │ └── proxyView.ts │ └── webview │ └── webview.ts ├── tsconfig.extension.json ├── tsconfig.json ├── tsconfig.webview.json ├── vsc-extension-quickstart.md ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ["v*"] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-20.04 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v2 11 | with: 12 | node-version: '20' 13 | - name: Install dependencies 14 | run: yarn install 15 | - name: Publish 16 | run: yarn vsce:publish 17 | env: 18 | VSCE_PAT: ${{ secrets.VSCE_PAT }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint", 6 | "eamodio.tsl-problem-matcher" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/dist/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "npm: test-watch" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off" 11 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": [ 10 | "$ts-webpack-watch", 11 | "$tslint-webpack-watch" 12 | ], 13 | "isBackground": true, 14 | "presentation": { 15 | "reveal": "never" 16 | }, 17 | "group": { 18 | "kind": "build", 19 | "isDefault": true 20 | } 21 | }, 22 | { 23 | "type": "npm", 24 | "script": "test-watch", 25 | "problemMatcher": "$tsc-watch", 26 | "isBackground": true, 27 | "presentation": { 28 | "reveal": "never" 29 | }, 30 | "group": "build" 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !dist/ 3 | !media/ 4 | !README.md 5 | !docs/images/icon.png 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | --ignore-engines true -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | vscode-zenn-editor の注目すべき変更はこのファイルで文書化されます。 4 | 5 | このファイルの書き方に関する推奨事項については、[Keep a Changelog](http://keepachangelog.com/) を確認してください。 6 | 7 | ## [リリース予定] 8 | [リリース予定]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.9.2...HEAD 9 | 10 | ## [0.9.2] - 2024-09-15 11 | [0.9.2]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.9.1...v0.9.2 12 | 13 | ### Fixed 14 | 15 | - Windows 環境で `A system error occurred (spawn EINVAL)` が発生する問題を修正 [#41](https://github.com/negokaz/vscode-zenn-editor/issues/41) 16 | 17 | ## [0.9.1] - 2023-05-06 18 | [0.9.1]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.9.0...v0.9.1 19 | 20 | ### Changed 21 | 22 | - `zenn-cli 0.1.143` をサポート [PR#38](https://github.com/negokaz/vscode-zenn-editor/pull/38) 23 | 24 | ### Fixed 25 | 26 | - Devcontainer を利用している場合にプレビューできない問題を修正 [#34](https://github.com/negokaz/vscode-zenn-editor/issues/34) 27 | - 表示ファイルを切り替えるとサイドバーが開いてしまう問題を修正 [#35](https://github.com/negokaz/vscode-zenn-editor/issues/35) 28 | 29 | ## [0.9.0] - 2022-01-22 30 | [0.9.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.8.0...v0.9.0 31 | 32 | ### Changed 33 | 34 | - `zenn-cli 0.1.103` をサポート [PR#32](https://github.com/negokaz/vscode-zenn-editor/pull/32) 35 | - Book の設定をプレビューできるように 36 | - Book チャプターの URL 形式変更に対応 37 | 38 | ⚠ この変更に伴い、zenn-cli 0.1.81 以前のバージョンでは Book チャプターのプレビューができなくなります。 39 | zenn-cli のバージョンアップをお願いします。 40 | - インストールされている zenn-cli が拡張の動作確認時のバージョンよりも古い場合はコンテンツのプレビュー時に警告を表示する 41 | 42 | ## [0.8.0] - 2021-06-22 43 | [0.8.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.7.2...v0.8.0 44 | 45 | ### Changed 46 | 47 | - `zenn-cli 0.1.86` をサポート [PR#28](https://github.com/negokaz/vscode-zenn-editor/pull/28) 48 | - プレビューのサイドバーを隠す 49 | - 新規作成された article を自動で開く 50 | 51 | ## [0.7.2] - 2021-05-09 52 | [0.7.2]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.7.1...v0.7.2 53 | 54 | ### Fixed 55 | 56 | - VSCode Insiders でプレビューが表示されない問題 [#23](https://github.com/negokaz/vscode-zenn-editor/issues/23) を修正 [PR#24](https://github.com/negokaz/vscode-zenn-editor/pull/24) 57 | 58 | ## [0.7.1] - 2021-05-03 59 | [0.7.1]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.7.0...v0.7.1 60 | 61 | ### Fixed 62 | 63 | - タイトルを変更したときにコンテンツ一覧での並び順が変わってしまうことがある問題を修正しました [PR#20](https://github.com/negokaz/vscode-zenn-editor/pull/20) 64 | - 投稿コンテンツの一覧で VSCode のフィルター機能が使えない問題を修正しました [PR#21](https://github.com/negokaz/vscode-zenn-editor/pull/21) 65 | 66 | フィルター機能については、次のページを参照してください 67 | 68 | > Improved keyboard navigation 69 | > 70 | > [Visual Studio Code January 2019](https://code.visualstudio.com/updates/v1_31#_new-tree-widget) 71 | 72 | ## [0.7.0] - 2021-04-29 73 | [0.7.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.6.0...v0.7.0 74 | 75 | ### Changed 76 | 77 | - ロゴを変更しました [PR#17](https://github.com/negokaz/vscode-zenn-editor/pull/17) 78 | - 投稿コンテンツのタイトルなどの変更がリアルタイムで Explorer に反映されるようになりました [PR#18](https://github.com/negokaz/vscode-zenn-editor/pull/18) 79 | 80 | ## [0.6.0] - 2021-04-28 81 | [0.6.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.5.1...v0.6.0 82 | 83 | ### Added 84 | 85 | - ワークスペースをサポートしました [PR#13](https://github.com/negokaz/vscode-zenn-editor/pull/13) 86 | 87 | Zenn に複数のリポジトリを連携した場合、それぞれのローカルリポジトリのフォルダをワークスペースとしてまとめることで両方の投稿コンテンツを一覧したり、プレビューしたりできます。 88 | 89 | **関連する Zenn の変更** 90 | > GitHubリポジトリを2つまで連携することがが可能に 91 | > 92 | > [Zenn Changelog ✨](https://zenn.dev/changelog#20210401) 93 | 94 | Explorer 上で全てのワークスペースフォルダが一覧できます。 95 | 96 | ![](docs/images/CHANGELOG/multi-workspace-tree-view.png) 97 | 98 | ## [0.5.1] - 2021-04-26 99 | [0.5.1]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.5.0...v0.5.1 100 | 101 | 自動リリース動作確認用バージョンで、拡張本体の変更点はありません 102 | 103 | ## [0.5.0] - 2021-04-19 104 | [0.5.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.4.0...v0.5.0 105 | 106 | ### Added 107 | 108 | - article と book を VSCode 上で作成できるようになりました [PR#9](https://github.com/negokaz/vscode-zenn-editor/pull/9) 109 | 110 | `ZENN CONTENTS` ビューに表示されるアイコンをクリックするか、コマンドパレットで次のコマンドを実行します 111 | 112 | - Zenn Editor: Create New Article 113 | - Zenn Editor: Create New Book 114 | 115 | ## [0.4.0] - 2021-02-28 116 | [0.4.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.3.0...v0.4.0 117 | 118 | ### Added 119 | 120 | - article と book の一覧を Explorer で確認できるようになりました [PR#7](https://github.com/negokaz/vscode-zenn-editor/pull/7) 121 | 122 | 一覧はファイル名ではなくコンテンツのタイトルで表示されます。 123 | また、公開/非公開の状態や本のセクションの有料/無料をアイコンで確認できます 124 | 125 | ![](docs/images/CHANGELOG/tree-view.png) 126 | 127 | 128 | ## [0.3.0] - 2021-02-28 129 | [0.3.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.2.0...v0.3.0 130 | 131 | ### Added 132 | 133 | - ステータスバーに画像アップロードページへのリンクが表示されるようになりました [PR#5](https://github.com/negokaz/vscode-zenn-editor/pull/5) 134 | 135 | ![](docs/images/CHANGELOG/status-bar-upload-image.png) 136 | 137 | Zenn Editor のプレビューが開いている時にだけ表示されます 138 | 139 | ## [0.2.0] - 2021-02-27 140 | 141 | [0.2.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.1.0...v0.2.0 142 | 143 | ### Added 144 | 145 | - `./node_modules/.bin` の zenn コマンドを環境変数 `PATH` の設定なしで認識する [PR#2](https://github.com/negokaz/vscode-zenn-editor/pull/2) 146 | 147 | ### Fixed 148 | 149 | - リンクカードをクリックしてもページを開けない問題を修正 [PR#3](https://github.com/negokaz/vscode-zenn-editor/pull/3) 150 | 151 | ## [0.1.0] - 2021-02-23 152 | 153 | [0.1.0]: https://github.com/negokaz/vscode-zenn-editor/compare/v0.0.0...v0.1.0 154 | 155 | - 初回リリース🚀 156 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Kazuki Negoro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VS Code Zenn Editor 2 | 3 | [![](https://img.shields.io/visual-studio-marketplace/v/negokaz.zenn-editor.svg)](https://marketplace.visualstudio.com/items?itemName=negokaz.zenn-editor) [![](https://img.shields.io/visual-studio-marketplace/i/negokaz.zenn-editor)](https://marketplace.visualstudio.com/items?itemName=negokaz.zenn-editor) 4 | 5 | [Zenn CLI](https://zenn.dev/zenn/articles/install-zenn-cli) を VS Code に統合する非公式の拡張です。 6 | 7 | ## Features 8 | 9 | - 編集中の投稿コンテンツをプレビューできます 10 | - 投稿コンテンツを一覧表示できます 11 | - 投稿コンテンツを作成できます 12 | - 画像アップロードページに素早くアクセスできます 13 | 14 | ## Requirements 15 | 16 | - Zenn CLI がインストールされている必要があります。インストール方法は [こちら](https://zenn.dev/zenn/articles/install-zenn-cli) 17 | 18 | ## Demo 19 | 20 | ![demo](docs/images/demo.gif) 21 | 22 | ## Known Issues 23 | 24 | - 記事に埋め込まれた YouTube の動画は VS Code の制約によりプレビュー上で再生できません: 25 | - [Can not play video in webview · Issue · microsoft/vscode](https://github.com/microsoft/vscode/issues/54097) 26 | 27 | ## Usages 28 | 29 | ### 投稿コンテンツのプレビュー 30 | 31 | 投稿コンテンツの編集中に、次のプレビューボタンをクリックします。 32 | 33 | ![](docs/images/preview-button.png) 34 | 35 | ### 編集する投稿コンテンツをタイトルで選択する 36 | 37 | Explorer にある「ZENN CONTENTS」ビューで投稿コンテンツの一覧を確認できます。 38 | 39 | コンテンツをクリックするとテキストエディタが開きます。 40 | 41 | 42 | 43 | ### Article の作成 44 | 45 | Zenn Contents ビュー上の紙のアイコンをクリックするか、コマンドパレットから `Zenn Editor: Create New Article` を実行します。 46 | 47 | ![](docs/images/tree-view-commands.png) 48 | 49 | ### Book の作成 50 | 51 | Zenn Contents ビュー上の本のアイコンをクリックするか、コマンドパレットから `Zenn Editor: Create New Book` を実行します。 52 | 53 | ![](docs/images/tree-view-commands.png) 54 | 55 | ### 画像アップロードページを開く 56 | 57 | プレビュー中に表示される次のボタンをクリックすると、Zenn の画像アップロードページが外部ブラウザで開きます。 58 | 59 | ![](docs/images/CHANGELOG/status-bar-upload-image.png) 60 | 61 | ### 複数のリポジトリをひとつのウィンドウで編集する 62 | 63 | Zenn には 2 つまで連携するリポジトリを設定できます。 64 | 65 | 複数のリポジトリをひとつのウィンドウで編集するには、VSCode のワークスペースを構成します。 66 | 67 | `Add Folder to Workspace` でもうひとつのローカルリポジトリを指定し、`Save Workspace As` でワークスペースを保存します。保存されたワークスペースを開くと複数のリポジトリの同時編集をいつでも再開できます。 68 | 69 | 70 | 71 | ## Changelog 72 | 73 | [CHANGELOG.md](CHANGELOG.md) を参照してください。 74 | 75 | ## License 76 | 77 | Copyright (c) 2021 Kazuki Negoro 78 | 79 | vscode-zenn-editor is released under the [MIT License](LICENSE) 80 | -------------------------------------------------------------------------------- /docs/images/CHANGELOG/multi-workspace-tree-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/CHANGELOG/multi-workspace-tree-view.png -------------------------------------------------------------------------------- /docs/images/CHANGELOG/status-bar-upload-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/CHANGELOG/status-bar-upload-image.png -------------------------------------------------------------------------------- /docs/images/CHANGELOG/tree-view.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/CHANGELOG/tree-view.png -------------------------------------------------------------------------------- /docs/images/create-workspace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/create-workspace.png -------------------------------------------------------------------------------- /docs/images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/demo.gif -------------------------------------------------------------------------------- /docs/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/icon.png -------------------------------------------------------------------------------- /docs/images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 21 | 23 | 24 | 26 | image/svg+xml 27 | 29 | Zenn icon 30 | 31 | 32 | 33 | 35 | 55 | 58 | 59 | Zenn icon 61 | 67 | 71 | 72 | -------------------------------------------------------------------------------- /docs/images/preview-button.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/preview-button.png -------------------------------------------------------------------------------- /docs/images/tree-view-commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/tree-view-commands.png -------------------------------------------------------------------------------- /docs/images/zenn-contents-explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negokaz/vscode-zenn-editor/65b49f835559337c85825bdabd229b23b8396111/docs/images/zenn-contents-explorer.png -------------------------------------------------------------------------------- /media/icon/draft.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/icon/lock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/icon/preview-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 40 | 43 | 44 | IcoFont Icons 46 | 48 | image/svg+xml 49 | 51 | ui-play 52 | 53 | 54 | 55 | ui-play 57 | 62 | 71 | z 83 | 84 | -------------------------------------------------------------------------------- /media/icon/preview-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 40 | 43 | 44 | IcoFont Icons 46 | 48 | image/svg+xml 49 | 51 | ui-play 52 | 53 | 54 | 55 | ui-play 57 | 62 | 71 | z 83 | 84 | -------------------------------------------------------------------------------- /media/icon/published.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /media/icon/unlock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zenn-editor", 3 | "displayName": "Zenn Editor", 4 | "description": "An unofficial extension integrates Zenn CLI into VS Code", 5 | "version": "0.9.2", 6 | "publisher": "negokaz", 7 | "engines": { 8 | "vscode": "^1.52.0" 9 | }, 10 | "categories": [ 11 | "Other" 12 | ], 13 | "keywords": [ 14 | "Markdown" 15 | ], 16 | "icon": "docs/images/icon.png", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/negokaz/vscode-zenn-editor.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/negokaz/vscode-zenn-editor/issues" 23 | }, 24 | "homepage": "https://github.com/negokaz/vscode-zenn-editor", 25 | "activationEvents": [ 26 | "workspaceContains:/articles/", 27 | "workspaceContains:/books/" 28 | ], 29 | "main": "./dist/extension.js", 30 | "contributes": { 31 | "commands": [ 32 | { 33 | "command": "zenn-editor.preview", 34 | "title": "Zenn Editor: Open Preview", 35 | "icon": { 36 | "light": "./media/icon/preview-light.svg", 37 | "dark": "./media/icon/preview-dark.svg" 38 | } 39 | }, 40 | { 41 | "command": "zenn-editor.create-new-article", 42 | "title": "Zenn Editor: Create New Article", 43 | "icon": "$(file-text)" 44 | }, 45 | { 46 | "command": "zenn-editor.create-new-book", 47 | "title": "Zenn Editor: Create New Book", 48 | "icon": "$(book)" 49 | }, 50 | { 51 | "command": "zenn-editor.refresh-tree-view", 52 | "title": "Zenn Editor: Refresh Tree View", 53 | "icon": "$(refresh)" 54 | }, 55 | { 56 | "command": "zenn-editor.open-tree-view-item", 57 | "title": "Zenn Editor: Open Tree View Item" 58 | } 59 | ], 60 | "menus": { 61 | "commandPalette": [ 62 | { 63 | "command": "zenn-editor.preview", 64 | "when": "zenn-editor.activated && resourceLangId in zenn-editor.previewable-language-ids && zenn-editor.active-text-editor-is-previewable" 65 | } 66 | ], 67 | "editor/title": [ 68 | { 69 | "command": "zenn-editor.preview", 70 | "when": "zenn-editor.activated && resourceLangId in zenn-editor.previewable-language-ids && zenn-editor.active-text-editor-is-previewable", 71 | "group": "navigation" 72 | } 73 | ], 74 | "editor/title/context": [ 75 | { 76 | "command": "zenn-editor.preview", 77 | "when": "zenn-editor.activated && resourceLangId in zenn-editor.previewable-language-ids && zenn-editor.active-text-editor-is-previewable", 78 | "group": "navigation" 79 | } 80 | ], 81 | "view/title": [ 82 | { 83 | "command": "zenn-editor.create-new-article", 84 | "when": "zenn-editor.activated && view == zenn", 85 | "group": "navigation" 86 | }, 87 | { 88 | "command": "zenn-editor.create-new-book", 89 | "when": "zenn-editor.activated && view == zenn", 90 | "group": "navigation" 91 | }, 92 | { 93 | "command": "zenn-editor.refresh-tree-view", 94 | "when": "zenn-editor.activated && view == zenn", 95 | "group": "navigation" 96 | } 97 | ] 98 | }, 99 | "views": { 100 | "explorer": [ 101 | { 102 | "id": "zenn", 103 | "name": "Zenn Contents", 104 | "when": "zenn-editor.activated" 105 | } 106 | ] 107 | } 108 | }, 109 | "scripts": { 110 | "vscode:prepublish": "yarn run package", 111 | "compile": "webpack", 112 | "watch": "webpack --watch", 113 | "package": "webpack --mode production --devtool hidden-source-map", 114 | "test-compile": "tsc -p ./", 115 | "test-watch": "tsc -watch -p ./", 116 | "pretest": "yarn run test-compile && yarn run lint", 117 | "lint": "eslint src --ext ts", 118 | "test": "node ./out/test/runTest.js", 119 | "vsce:package": "vsce package --githubBranch main", 120 | "vsce:publish": "vsce publish --githubBranch main $(git-tag-version)" 121 | }, 122 | "devDependencies": { 123 | "@types/glob": "^7.1.3", 124 | "@types/http-proxy": "^1.17.5", 125 | "@types/mocha": "^8.0.4", 126 | "@types/node": "^12.11.7", 127 | "@types/ps-tree": "^1.1.0", 128 | "@types/vscode": "^1.52.0", 129 | "@types/which": "^2.0.0", 130 | "@typescript-eslint/eslint-plugin": "^4.14.1", 131 | "@typescript-eslint/parser": "^4.14.1", 132 | "css-loader": "^5.0.2", 133 | "eslint": "^7.19.0", 134 | "git-tag-version": "^1.3.1", 135 | "glob": "^7.1.6", 136 | "mocha": "^8.2.1", 137 | "style-loader": "^2.0.0", 138 | "ts-loader": "^8.0.14", 139 | "typescript": "^4.1.3", 140 | "vsce": "^1.85.0", 141 | "vscode-test": "^1.5.0", 142 | "webpack": "^5.19.0", 143 | "webpack-cli": "^4.4.0", 144 | "zenn-cli": "^0.1.72" 145 | }, 146 | "dependencies": { 147 | "get-port": "^5.1.1", 148 | "http-proxy": "^1.18.1", 149 | "path-array": "^1.0.1", 150 | "ps-tree": "^1.2.0", 151 | "which": "^2.0.2", 152 | "yaml": "^1.10.2" 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/extension/extension.ts: -------------------------------------------------------------------------------- 1 | // The module 'vscode' contains the VS Code extensibility API 2 | // Import the module and reference it with the alias vscode in your code below 3 | import * as vscode from 'vscode'; 4 | import PreviewViewManager from './preview/previewViewManager'; 5 | import { ZennTeeViewManager } from './treeView/zennTreeViewManager'; 6 | import { ZennCli } from './zenncli/zennCli'; 7 | import Uri from './util/uri'; 8 | import { ZennWorkspace } from './util/zennWorkspace'; 9 | import ZennVersion from './zenncli/zennVersion'; 10 | import { PreviewDocument } from './preview/previewDocument'; 11 | 12 | const treeViewManager = ZennTeeViewManager.create(); 13 | 14 | // this method is called when your extension is activated 15 | // your extension is activated the very first time the command is executed 16 | export function activate(context: vscode.ExtensionContext) { 17 | vscode.commands.executeCommand('setContext', 'zenn-editor.activated', true); 18 | vscode.commands.executeCommand('setContext', 'zenn-editor.previewable-language-ids', ['markdown', 'yaml']); 19 | context.subscriptions.push( 20 | treeViewManager.openTreeView(context), 21 | vscode.commands.registerCommand('zenn-editor.refresh-tree-view', () => treeViewManager.refresh()), 22 | vscode.commands.registerCommand('zenn-editor.open-tree-view-item', openTreeViewItem()), 23 | vscode.commands.registerCommand('zenn-editor.preview', previewDocument(context)), 24 | vscode.commands.registerCommand('zenn-editor.create-new-article', createNewArticle()), 25 | vscode.commands.registerCommand('zenn-editor.create-new-book', createNewBook()), 26 | vscode.commands.registerCommand('zenn-editor.open-image-uploader', openImageUploader()), 27 | vscode.window.onDidChangeActiveTextEditor(editor => onDidChangeActiveTextEditor(editor)), 28 | vscode.workspace.onDidCreateFiles(() => onDidCreateFiles()), 29 | vscode.workspace.onDidDeleteFiles(() => onDidDeleteFiles()), 30 | vscode.workspace.onDidRenameFiles(() => onDidRenameFiles()), 31 | vscode.workspace.onDidSaveTextDocument(d => onDidSaveTextDocument(d)), 32 | ); 33 | onDidChangeActiveTextEditor(vscode.window.activeTextEditor); 34 | console.log('zenn-editor is now active'); 35 | } 36 | 37 | // this method is called when your extension is deactivated 38 | export function deactivate() {} 39 | 40 | const previewViewManager = PreviewViewManager.create(); 41 | 42 | function previewDocument(context: vscode.ExtensionContext) { 43 | return (uri?: vscode.Uri) => { 44 | if (uri) { 45 | const documentUri = Uri.of(uri); 46 | checkZennCliVersion(documentUri.workspaceDirectory()); 47 | previewViewManager.openPreview(documentUri, context); 48 | } 49 | }; 50 | } 51 | 52 | async function checkZennCliVersion(workspace: Uri | undefined) { 53 | if (workspace) { 54 | const zennCli = await ZennCli.create(workspace); 55 | const version = await zennCli.version(); 56 | const reqireVersion = ZennVersion.create("0.1.143"); 57 | if (version.compare(reqireVersion) < 0) { 58 | vscode.window.showWarningMessage(`zenn-cli の更新を推奨します(現在のバージョン: ${version.displayVersion})`); 59 | } 60 | } 61 | } 62 | 63 | function createNewArticle() { 64 | return async () => { 65 | const workspace = await treeViewManager.activeWorkspace(); 66 | const cli = await ZennCli.create(workspace.rootDirectory); 67 | const newArticle = await cli.createNewArticle(); 68 | await treeViewManager.refresh(newArticle.articleUri); 69 | try { 70 | const doc = await vscode.workspace.openTextDocument(newArticle.articleUri.underlying); 71 | return await vscode.window.showTextDocument(doc, vscode.ViewColumn.One, false); 72 | } catch (e) { 73 | vscode.window.showErrorMessage(e.toString()); 74 | } 75 | }; 76 | } 77 | 78 | function createNewBook() { 79 | return async () => { 80 | const workspace = await treeViewManager.activeWorkspace(); 81 | const cli = await ZennCli.create(workspace.rootDirectory); 82 | const newBook = await cli.createNewBook(); 83 | await treeViewManager.refresh(newBook.configUri); 84 | try { 85 | const doc = await vscode.workspace.openTextDocument(newBook.configUri.underlying); 86 | return await vscode.window.showTextDocument(doc, vscode.ViewColumn.One, false); 87 | } catch (e) { 88 | vscode.window.showErrorMessage(e.toString()); 89 | } 90 | } 91 | } 92 | 93 | function openImageUploader() { 94 | return () => { 95 | vscode.env.openExternal(vscode.Uri.parse('https://zenn.dev/dashboard/uploader')); 96 | }; 97 | } 98 | 99 | function openTreeViewItem() { 100 | return async (uri?: vscode.Uri) => { 101 | if (uri) { 102 | try { 103 | const doc = await vscode.workspace.openTextDocument(uri); 104 | return await vscode.window.showTextDocument(doc, vscode.ViewColumn.One, true); 105 | } catch (e) { 106 | // 選択したファイルがテキストではない場合 107 | vscode.commands.executeCommand('vscode.open', uri, { viewColumn: vscode.ViewColumn.One }); 108 | } 109 | } 110 | } 111 | } 112 | 113 | async function onDidChangeActiveTextEditor(editor: vscode.TextEditor | undefined): Promise { 114 | if (editor) { 115 | const uri = Uri.of(editor.document.uri); 116 | const document = PreviewDocument.create(uri); 117 | vscode.commands.executeCommand('setContext', 'zenn-editor.active-text-editor-is-previewable', document.isPreviewable()); 118 | const item = await treeViewManager.selectItem(uri, /*attemptLimit*/1); 119 | if (item) { 120 | checkZennCliVersion(uri.workspaceDirectory()); 121 | await previewViewManager.changePreviewDocument(editor.document); 122 | } 123 | } 124 | } 125 | 126 | async function onDidSaveTextDocument(docuemnt: vscode.TextDocument): Promise { 127 | return treeViewManager.refresh(Uri.of(docuemnt.uri)); 128 | } 129 | 130 | async function onDidCreateFiles(): Promise { 131 | return treeViewManager.refresh(); 132 | } 133 | 134 | async function onDidDeleteFiles(): Promise { 135 | return treeViewManager.refresh(); 136 | } 137 | 138 | async function onDidRenameFiles(): Promise { 139 | return treeViewManager.refresh(); 140 | } 141 | -------------------------------------------------------------------------------- /src/extension/preview/previewBackend.ts: -------------------------------------------------------------------------------- 1 | import ZennPreview from "../zenncli/zennPreview"; 2 | import * as vscode from 'vscode'; 3 | import { ZennPreviewProxyServer } from "../zenncli/zennPreviewProxyServer"; 4 | import Uri from '../util/uri'; 5 | import * as getPort from 'get-port'; 6 | import { ZennCli } from "../zenncli/zennCli"; 7 | import ExtensionResource from "../resource/extensionResource"; 8 | import { PreviewDocument } from "./previewDocument"; 9 | 10 | export class PreviewBackend { 11 | 12 | public static async start(document: PreviewDocument, resource: ExtensionResource) { 13 | const workspace = document.uri().workspaceDirectory(); 14 | if (workspace) { 15 | const documentRelativePath = document.urlPath(); 16 | const host = 'localhost'; 17 | const port = await getPort(); 18 | const backendPort = await getPort(); 19 | const zennCli = await ZennCli.create(workspace); 20 | const zennPreview = zennCli.preview(backendPort); 21 | const zennPreviewProxyServer = 22 | ZennPreviewProxyServer.start(host, port, backendPort, documentRelativePath, resource); 23 | return new PreviewBackend(workspace, await zennPreview, await zennPreviewProxyServer); 24 | } else { 25 | const message = `ドキュメントのワークスペースが見つかりません: ${document.uri().fsPath}`; 26 | vscode.window.showErrorMessage(message) 27 | return Promise.reject(message); 28 | } 29 | } 30 | 31 | public readonly workspace: Uri; 32 | 33 | private readonly zennPreview: ZennPreview; 34 | 35 | private readonly zennPreviewProxyServer: ZennPreviewProxyServer; 36 | 37 | private constructor(workspace: Uri, zennPreview: ZennPreview, zennPreviewProxyServer: ZennPreviewProxyServer) { 38 | this.workspace = workspace; 39 | this.zennPreview = zennPreview; 40 | this.zennPreviewProxyServer = zennPreviewProxyServer; 41 | } 42 | 43 | public isProvide(uri: Uri): boolean { 44 | return uri.contains(this.workspace); 45 | } 46 | 47 | public entrypointUrl(): string { 48 | return this.zennPreviewProxyServer.entrypointUrl(); 49 | } 50 | 51 | public stop(): void { 52 | this.zennPreview.close(); 53 | this.zennPreviewProxyServer.stop(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/extension/preview/previewDocument.ts: -------------------------------------------------------------------------------- 1 | import Uri from '../util/uri'; 2 | import * as path from 'path'; 3 | 4 | export abstract class PreviewDocument { 5 | 6 | static create(document: Uri): PreviewDocument { 7 | const workspace = document.workspaceDirectory(); 8 | if (workspace) { 9 | const relativePath = this.normalizePath(document.relativePathFrom(workspace)); 10 | const filePath = path.parse(relativePath); 11 | if (filePath.dir.match(/books\/[^/]+$/)) { 12 | if (filePath.ext === '.md') { 13 | return new BookDocument(document, relativePath); 14 | } else if (filePath.base === "config.yaml") { 15 | return new BookConfigDocument(document, relativePath); 16 | } 17 | } else if (filePath.dir === "articles" && filePath.ext === '.md') { 18 | return new ArticleDocument(document, relativePath); 19 | } 20 | } 21 | return new UnknownDocument(document); 22 | } 23 | 24 | static normalizePath(path: string): string { 25 | return path.replace(/\.git$/, ""); 26 | } 27 | 28 | abstract uri(): Uri; 29 | 30 | abstract urlPath(): string; 31 | 32 | isPreviewable(): boolean { 33 | return true; 34 | } 35 | } 36 | 37 | export class UnknownDocument extends PreviewDocument { 38 | 39 | private readonly documentUri: Uri; 40 | 41 | constructor(documentUri: Uri) { 42 | super(); 43 | this.documentUri = documentUri; 44 | } 45 | 46 | urlPath(): string { 47 | return ""; 48 | } 49 | 50 | uri(): Uri { 51 | return this.documentUri; 52 | } 53 | 54 | isPreviewable(): boolean { 55 | return false; 56 | } 57 | } 58 | 59 | export class BookDocument extends PreviewDocument { 60 | 61 | private readonly documentUri: Uri; 62 | 63 | private readonly relativePath: string; 64 | 65 | constructor(documentUri: Uri, relativePath: string) { 66 | super(); 67 | this.relativePath = relativePath; 68 | this.documentUri = documentUri; 69 | } 70 | 71 | urlPath(): string { 72 | return encodeURI(this.relativePath.replace(/\./g, "%2E")); 73 | } 74 | 75 | uri(): Uri { 76 | return this.documentUri; 77 | } 78 | } 79 | 80 | export class BookConfigDocument extends PreviewDocument { 81 | 82 | private readonly documentUri: Uri; 83 | 84 | private readonly relativePath: string; 85 | 86 | constructor(documentUri: Uri, relativePath: string) { 87 | super(); 88 | this.relativePath = relativePath; 89 | this.documentUri = documentUri; 90 | } 91 | 92 | urlPath(): string { 93 | return this.relativePath.replace(/\/config\.yaml$/, ""); 94 | } 95 | 96 | uri(): Uri { 97 | return this.documentUri; 98 | } 99 | } 100 | 101 | export class ArticleDocument extends PreviewDocument { 102 | 103 | private readonly documentUri: Uri; 104 | 105 | private readonly relativePath: string; 106 | 107 | constructor(documentUri: Uri, relativePath: string) { 108 | super(); 109 | this.relativePath = relativePath; 110 | this.documentUri = documentUri; 111 | } 112 | 113 | urlPath(): string { 114 | return this.relativePath.replace(/\.md$/, ""); 115 | } 116 | 117 | uri(): Uri { 118 | return this.documentUri; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/extension/preview/previewView.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { PreviewBackend } from './previewBackend'; 3 | import ExtensionResource from "../resource/extensionResource"; 4 | import Uri from '../util/uri'; 5 | import { PreviewDocument } from "./previewDocument"; 6 | 7 | export default class PreviewView { 8 | 9 | static async create(context: vscode.ExtensionContext): Promise { 10 | const resource = new ExtensionResource(context); 11 | const panel = vscode.window.createWebviewPanel( 12 | 'zenn-editor.preview', 13 | 'Zenn Editor Preview', 14 | { 15 | viewColumn: vscode.ViewColumn.Two, 16 | preserveFocus: true, 17 | }, 18 | { 19 | enableScripts: true, 20 | localResourceRoots: [vscode.Uri.file(context.extensionPath)], 21 | } 22 | ); 23 | context.subscriptions.push(panel); 24 | 25 | return new PreviewView(panel, resource); 26 | } 27 | 28 | 29 | private readonly webviewPanel: vscode.WebviewPanel; 30 | 31 | private readonly previewBackends: Map = new Map(); 32 | 33 | private currentBackend: PreviewBackend | undefined; 34 | 35 | private readonly resource: ExtensionResource; 36 | 37 | private disposables: vscode.Disposable[] = []; 38 | 39 | private constructor(webviewPanel: vscode.WebviewPanel, resource: ExtensionResource) { 40 | this.resource = resource; 41 | this.webviewPanel = webviewPanel; 42 | this.disposables.push( 43 | this.webviewPanel.webview.onDidReceiveMessage(this.receiveWebviewMessage), 44 | ); 45 | this.webviewPanel.onDidDispose(() => { 46 | this.disposables.forEach(d => d.dispose()); 47 | }); 48 | } 49 | 50 | private receiveWebviewMessage(message: any): void { 51 | switch (message.command) { 52 | case 'open-link': 53 | vscode.env.openExternal(vscode.Uri.parse(message.link)); 54 | break; 55 | default: 56 | console.log('unhandled message', message); 57 | } 58 | } 59 | 60 | private webviewLoadingHtml(): string { 61 | return ` 62 | 63 | 64 | 72 | 73 | Loading... 74 | 75 | `; 76 | } 77 | 78 | private webviewHtml(previewBackend: PreviewBackend): string { 79 | return ` 80 | 81 | 82 | 83 | 84 | 85 |
86 | 87 |
88 | 89 | 90 | `; 91 | } 92 | 93 | public async changePreviewDocument(textDocument: vscode.TextDocument): Promise { 94 | const document = Uri.of(textDocument.uri); 95 | const previewDocument = PreviewDocument.create(document); 96 | if (previewDocument.isPreviewable()) { 97 | if (!(this.currentBackend && this.currentBackend.isProvide(document))) { 98 | await this.open(document); 99 | } 100 | const documentRelativePath = previewDocument.urlPath(); 101 | this.webviewPanel.webview.postMessage({ command: 'change_path', relativePath: documentRelativePath }); 102 | } 103 | } 104 | 105 | public async open(uri: Uri): Promise { 106 | const workspace = uri.workspaceDirectory(); 107 | if (workspace) { 108 | const key = workspace.fsPath(); 109 | const backend = this.previewBackends.get(key); 110 | if (backend) { 111 | this.currentBackend = backend; 112 | this.webviewPanel.webview.html = this.webviewHtml(backend); 113 | } else { 114 | this.webviewPanel.webview.html = this.webviewLoadingHtml(); 115 | const document = PreviewDocument.create(uri); 116 | const newBackend = await PreviewBackend.start(document, this.resource); 117 | this.previewBackends.set(key, newBackend); 118 | this.webviewPanel.onDidDispose(() => newBackend.stop()); 119 | this.currentBackend = newBackend; 120 | this.webviewPanel.webview.html = this.webviewHtml(newBackend); 121 | } 122 | } 123 | } 124 | 125 | public onDidClose(listener: () => void): void { 126 | this.webviewPanel.onDidDispose(listener); 127 | } 128 | 129 | public reveal(): void { 130 | this.webviewPanel.reveal(); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/extension/preview/previewViewManager.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import ImageUploaderItem from '../statusBar/imageUploaderItem'; 3 | import PreviewView from './previewView'; 4 | import Uri from '../util/uri'; 5 | 6 | export default class PreviewViewManager { 7 | 8 | public static create(): PreviewViewManager { 9 | return new PreviewViewManager(); 10 | } 11 | 12 | private imageUploaderStatusbarItem = ImageUploaderItem.create(); 13 | 14 | private previewView: PreviewView | undefined; 15 | 16 | private constructor() {} 17 | 18 | public async openPreview(uri: Uri, context: vscode.ExtensionContext): Promise { 19 | if (this.previewView) { 20 | this.previewView.open(uri); 21 | this.previewView.reveal(); 22 | } else { 23 | this.previewView = await PreviewView.create(context); 24 | this.previewView.open(uri); 25 | this.previewView.onDidClose(() => { 26 | this.previewView = undefined; 27 | this.imageUploaderStatusbarItem.hide(); 28 | }); 29 | this.imageUploaderStatusbarItem.show(); 30 | } 31 | } 32 | 33 | public async changePreviewDocument(document: vscode.TextDocument): Promise { 34 | if (this.previewView) { 35 | this.previewView.changePreviewDocument(document); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/extension/resource/extensionResource.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | 4 | export default class ExtensionResource { 5 | 6 | private readonly context: vscode.ExtensionContext; 7 | 8 | constructor(context: vscode.ExtensionContext) { 9 | this.context = context; 10 | } 11 | 12 | public uri(...pathElements: string[]): vscode.Uri { 13 | const onDiskPath = vscode.Uri.file( 14 | path.join(this.context.extensionPath, path.join(...pathElements)) 15 | ); 16 | return onDiskPath; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/extension/statusBar/imageUploaderItem.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | 3 | export default class ImageUploaderItem { 4 | 5 | public static create(): ImageUploaderItem { 6 | return new ImageUploaderItem(); 7 | } 8 | 9 | private readonly statusBarItem: vscode.StatusBarItem; 10 | 11 | private constructor() { 12 | this.statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right); 13 | this.statusBarItem.text = '$(cloud-upload) Upload Image'; 14 | this.statusBarItem.tooltip = 'Zenn Editor: 画像のアップロード'; 15 | this.statusBarItem.command = 'zenn-editor.open-image-uploader'; 16 | } 17 | 18 | public show(): void { 19 | this.statusBarItem.show(); 20 | } 21 | 22 | public hide(): void { 23 | this.statusBarItem.hide(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/extension/treeView/articles.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { promises as fs } from 'fs'; 3 | import * as path from 'path'; 4 | import Uri from '../util/uri'; 5 | import ExtensionResource from '../resource/extensionResource'; 6 | import { ZennTreeItem } from "./zennTreeItem"; 7 | import { OpenZennTreeViewItemCommand } from './openZennTreeViewItemCommand'; 8 | import MarkdownMeta from './markdownMeta'; 9 | 10 | export class Articles extends ZennTreeItem { 11 | 12 | readonly uri: Uri; 13 | 14 | readonly parent: ZennTreeItem | undefined = undefined; 15 | 16 | private readonly resources: ExtensionResource; 17 | 18 | private children: Promise; 19 | 20 | constructor(uri: Uri, resources: ExtensionResource) { 21 | super("articles", vscode.TreeItemCollapsibleState.Expanded); 22 | this.uri = uri; 23 | this.resources = resources; 24 | this.resourceUri = uri.underlying; 25 | this.children = this.internalLoadChildren(); 26 | } 27 | 28 | async getChildren(): Promise { 29 | return this.children; 30 | } 31 | 32 | async loadChildren(): Promise { 33 | this.children = this.internalLoadChildren(); 34 | return this.children; 35 | } 36 | 37 | async reload(): Promise { 38 | return new Articles(this.uri, this.resources); 39 | } 40 | 41 | private async internalLoadChildren(): Promise { 42 | const files = await fs.readdir(this.uri.fsPath()); 43 | const loadedArticles = files 44 | .filter(f => path.extname(f) === '.md') 45 | .map(f => Article.load(this, this.uri.resolve(f), this.resources)); 46 | return Promise.all(loadedArticles).then(articles => articles.sort((a, b) => a.compare(b))); 47 | } 48 | } 49 | 50 | class Article extends ZennTreeItem { 51 | 52 | static async load(parent: ZennTreeItem, uri: Uri, resources: ExtensionResource): Promise
{ 53 | const fsStat = fs.stat(uri.fsPath()); 54 | const meta = await MarkdownMeta.loadMeta(uri); 55 | return new Article( 56 | parent, 57 | uri, 58 | meta.title ? meta.title : uri.basename(), 59 | meta.emoji ? meta.emoji : "📃", 60 | meta.published ? meta.published : false, 61 | (await fsStat).mtime, 62 | resources, 63 | ); 64 | } 65 | 66 | readonly parent: ZennTreeItem; 67 | 68 | readonly uri: Uri; 69 | 70 | readonly resources: ExtensionResource; 71 | 72 | private readonly published: boolean; 73 | 74 | private readonly lastModifiedTime: Date; 75 | 76 | private constructor(parent: ZennTreeItem, uri: Uri, title: string, emoji: string, published: boolean, lastModifiedTime: Date, resources: ExtensionResource) { 77 | super(`${emoji} ${title}`, vscode.TreeItemCollapsibleState.None); 78 | this.parent = parent; 79 | this.uri = uri; 80 | this.resources = resources; 81 | this.published = published; 82 | this.lastModifiedTime = lastModifiedTime; 83 | this.tooltip = uri.basename(); 84 | this.command = new OpenZennTreeViewItemCommand(this.uri); 85 | this.resourceUri = uri.underlying; 86 | this.iconPath = 87 | published 88 | ? resources.uri('media', 'icon', 'published.svg').fsPath 89 | : resources.uri('media', 'icon', 'draft.svg').fsPath; 90 | 91 | } 92 | 93 | async reload(): Promise
{ 94 | return Article.load(this.parent, this.uri, this.resources); 95 | } 96 | 97 | public compare(other: Article): number { 98 | if (!this.published && other.published) { 99 | // 下書きを上位に表示 100 | return -1; 101 | } else { 102 | // どちらも公開済みか、どちらも下書きの場合は最終更新日時が新しいものを上位に表示 103 | return other.lastModifiedTime.getTime() - this.lastModifiedTime.getTime(); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/extension/treeView/books.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import { promises as fs } from 'fs'; 3 | import * as path from 'path'; 4 | import * as YAML from 'yaml'; 5 | import Uri from '../util/uri'; 6 | import ExtensionResource from '../resource/extensionResource'; 7 | import MarkdownMeta from './markdownMeta'; 8 | import { OpenZennTreeViewItemCommand } from './openZennTreeViewItemCommand'; 9 | import { ZennTreeItem } from "./zennTreeItem"; 10 | 11 | export class Books extends ZennTreeItem { 12 | 13 | readonly parent: ZennTreeItem | undefined = undefined; 14 | 15 | readonly uri: Uri; 16 | 17 | private readonly resources: ExtensionResource; 18 | 19 | private children: Promise; 20 | 21 | constructor(uri: Uri, resources: ExtensionResource) { 22 | super("books", vscode.TreeItemCollapsibleState.Expanded); 23 | this.uri = uri; 24 | this.resources = resources; 25 | this.resourceUri = uri.underlying; 26 | this.children = this.internalLoadChildren(); 27 | } 28 | 29 | async getChildren(): Promise { 30 | return this.children; 31 | } 32 | 33 | async loadChildren(): Promise { 34 | this.children = this.internalLoadChildren(); 35 | return this.children; 36 | } 37 | 38 | async reload(): Promise { 39 | return new Books(this.uri, this.resources); 40 | } 41 | 42 | private async internalLoadChildren(): Promise { 43 | const files = await fs.readdir(this.uri.fsPath()); 44 | const fileWithStats = await Promise.all( 45 | files.map(async (file) => { 46 | const filePath = this.uri.resolve(file); 47 | return { 48 | uri: filePath, 49 | stat: await fs.stat(filePath.fsPath()), 50 | }; 51 | }) 52 | ); 53 | const loadedBooks = fileWithStats 54 | .filter(f => f.stat.isDirectory()) 55 | .map(d => Book.load(this, d.uri, this.resources)); 56 | return Promise.all(loadedBooks) 57 | .then(books => books.sort((a, b) => a.compare(b))); 58 | } 59 | } 60 | 61 | 62 | class Book extends ZennTreeItem { 63 | 64 | static async load(parent: ZennTreeItem, uri: Uri, resources: ExtensionResource): Promise { 65 | const fsStat = fs.stat(uri.fsPath()); 66 | const config = await Book.loadConfig(uri); 67 | return new Book( 68 | parent, 69 | uri, 70 | config.title ? config.title : uri.basename(), 71 | config.published ? config.published : false, 72 | config.chapters, 73 | (await fsStat).mtime, 74 | resources, 75 | ); 76 | } 77 | 78 | readonly parent: ZennTreeItem; 79 | 80 | readonly uri: Uri; 81 | 82 | private readonly published: boolean; 83 | 84 | private readonly chapters: string[] | undefined; 85 | 86 | private readonly lastModifiedTime: Date; 87 | 88 | private readonly resources: ExtensionResource; 89 | 90 | private children: Promise; 91 | 92 | private constructor(parent: ZennTreeItem, uri: Uri, title: string, published: boolean, chapters: string[] | undefined, lastModifiedTime: Date, resources: ExtensionResource) { 93 | super(`📕 ${title}`, vscode.TreeItemCollapsibleState.Collapsed); 94 | this.parent = parent; 95 | this.uri = uri; 96 | this.published = published; 97 | this.chapters = chapters; 98 | this.lastModifiedTime = lastModifiedTime; 99 | this.resources = resources; 100 | this.tooltip = uri.basename(); 101 | this.resourceUri = uri.underlying; 102 | this.iconPath = 103 | published 104 | ? resources.uri('media', 'icon', 'published.svg').fsPath 105 | : resources.uri('media', 'icon', 'draft.svg').fsPath; 106 | this.children = this.internalLoadChildren(); 107 | } 108 | 109 | async getChildren(): Promise { 110 | return this.children; 111 | } 112 | 113 | async loadChildren(): Promise { 114 | this.children = this.internalLoadChildren(); 115 | return this.children; 116 | } 117 | 118 | async reload(): Promise { 119 | return Book.load(this.parent, this.uri, this.resources); 120 | } 121 | 122 | private async internalLoadChildren(): Promise { 123 | const files = await fs.readdir(this.uri.fsPath()); 124 | const loadedSections = Promise.all( 125 | this.chapters 126 | ? this.chapters 127 | .map(c => this.uri.resolve(c + (c.endsWith('.md') ? '' : '.md'))) 128 | .filter(async f => { 129 | try { 130 | const stat = await fs.stat(f.fsPath()); 131 | return stat.isFile(); 132 | } catch (e) { 133 | console.error("Loading sections failed", e); 134 | return false; 135 | } 136 | }) 137 | .map(f => BookSection.load(this, f, this.resources)) 138 | : files 139 | .filter(f => path.extname(f) === '.md') 140 | .map(f => BookSection.load(this, this.uri.resolve(f), this.resources)) 141 | ).then(sections => sections.sort((a, b) => a.compare(b))); 142 | const bookCover = 143 | files 144 | .filter(f => f === 'cover.png' || f === 'cover.jpeg') 145 | .map(f => BookCover.load(this, this.uri.resolve(f))); 146 | const bookConfig = 147 | files 148 | .filter(f => f === 'config.yaml') 149 | .map(f => BookConfig.load(this, this.uri.resolve(f))); 150 | 151 | const result: Promise[] = []; 152 | return (await loadedSections as ZennTreeItem[]).concat(await Promise.all(result.concat(bookCover).concat(bookConfig))); 153 | } 154 | 155 | public compare(other: Book): number { 156 | if (!this.published && other.published) { 157 | // 下書きを上位に表示 158 | return -1; 159 | } else { 160 | // どちらも公開済みか、どちらも下書きの場合は最終更新日時が新しいものを上位に表示 161 | return other.lastModifiedTime.getTime() - this.lastModifiedTime.getTime(); 162 | } 163 | } 164 | 165 | private static async loadConfig(bookUri: Uri): Promise { 166 | try { 167 | const file = await fs.readFile(bookUri.resolve('config.yaml').fsPath(), 'utf-8'); 168 | return YAML.parse(await file); 169 | } catch (e) { 170 | return {}; 171 | } 172 | } 173 | } 174 | 175 | class BookSection extends ZennTreeItem { 176 | 177 | static async load(parent: ZennTreeItem, uri: Uri, resources: ExtensionResource): Promise { 178 | const meta = await MarkdownMeta.loadMeta(uri); 179 | return new BookSection( 180 | parent, 181 | uri, 182 | meta.title ? meta.title : uri.basename(), 183 | meta.free ? meta.free : false, 184 | resources, 185 | ); 186 | } 187 | 188 | readonly parent: ZennTreeItem; 189 | 190 | readonly uri: Uri; 191 | 192 | readonly resources: ExtensionResource; 193 | 194 | private readonly sectionNo: number; 195 | 196 | private constructor(parent: ZennTreeItem, uri: Uri, title: string, free: boolean, resources: ExtensionResource) { 197 | super(`📄 ${title}`, vscode.TreeItemCollapsibleState.None); 198 | this.parent = parent; 199 | this.uri = uri; 200 | this.resources = resources; 201 | this.sectionNo = BookSection.extractSectionNo(uri); 202 | this.tooltip = uri.basename(); 203 | this.command = new OpenZennTreeViewItemCommand(this.uri); 204 | this.resourceUri = uri.underlying; 205 | this.iconPath = 206 | free 207 | ? resources.uri('media', 'icon', 'unlock.svg').fsPath 208 | : resources.uri('media', 'icon', 'lock.svg').fsPath; 209 | } 210 | 211 | async reload(): Promise { 212 | return BookSection.load(this.parent, this.uri, this.resources); 213 | } 214 | 215 | public compare(other: BookSection): number { 216 | return this.sectionNo - other.sectionNo; 217 | } 218 | 219 | private static extractSectionNo(uri: Uri): number { 220 | const basenameElements = uri.basename().split('.'); 221 | if (basenameElements.length > 0) { 222 | try { 223 | return parseInt(basenameElements[0]); 224 | } catch (e) { 225 | return 0; 226 | } 227 | } else { 228 | return 0; 229 | } 230 | } 231 | } 232 | 233 | class BookConfig extends ZennTreeItem { 234 | 235 | static async load(parent: ZennTreeItem, uri: Uri): Promise { 236 | return new BookConfig(parent, uri); 237 | } 238 | 239 | readonly parent: ZennTreeItem; 240 | 241 | readonly uri: Uri; 242 | 243 | private constructor(parent: ZennTreeItem, uri: Uri) { 244 | super(uri.basename(), vscode.TreeItemCollapsibleState.None); 245 | this.parent = parent; 246 | this.uri = uri; 247 | this.tooltip = uri.basename(); 248 | this.command = new OpenZennTreeViewItemCommand(this.uri); 249 | this.resourceUri = this.uri.underlying; 250 | } 251 | 252 | async reload(): Promise { 253 | return BookConfig.load(this.parent, this.uri); 254 | } 255 | 256 | itemNeedToReload(): ZennTreeItem { 257 | return this.parent; 258 | } 259 | } 260 | 261 | class BookCover extends ZennTreeItem { 262 | 263 | static async load(parent: ZennTreeItem, uri: Uri): Promise { 264 | return new BookCover(parent, uri); 265 | } 266 | 267 | readonly parent: ZennTreeItem; 268 | 269 | readonly uri: Uri; 270 | 271 | private constructor(parent: ZennTreeItem, uri: Uri) { 272 | super(uri.basename(), vscode.TreeItemCollapsibleState.None); 273 | this.parent = parent; 274 | this.uri = uri; 275 | this.tooltip = uri.basename(); 276 | this.command = new OpenZennTreeViewItemCommand(this.uri); 277 | this.resourceUri = this.uri.underlying; 278 | } 279 | 280 | async reload(): Promise { 281 | return BookCover.load(this.parent, this.uri); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/extension/treeView/markdownMeta.ts: -------------------------------------------------------------------------------- 1 | import * as YAML from 'yaml'; 2 | import * as readline from 'readline'; 3 | import * as fs from 'fs'; 4 | import Uri from '../util/uri'; 5 | 6 | export default class MarkdownMeta { 7 | 8 | public static async loadMeta(uri: Uri): Promise { 9 | return new Promise((resolve, reject) => { 10 | const stream = fs.createReadStream(uri.fsPath()); 11 | const readlineIF = readline.createInterface(stream); 12 | var yaml: string = ""; 13 | var onMeta = false; 14 | var complete = false; 15 | readlineIF.on('line', line => { 16 | if (line.match(/^---$/)) { 17 | if (onMeta) { 18 | complete = true; 19 | onMeta = false; 20 | readlineIF.close(); 21 | } else { 22 | onMeta = true; 23 | } 24 | } else { 25 | if (onMeta) { 26 | yaml = yaml + line + "\n"; 27 | } 28 | } 29 | }); 30 | readlineIF.on("close", () => { 31 | stream.close(); 32 | if (complete) { 33 | try { 34 | resolve(YAML.parse(yaml)); 35 | } catch (e) { 36 | resolve({}); 37 | } 38 | } else { 39 | resolve({}); 40 | } 41 | }); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/extension/treeView/openZennTreeViewItemCommand.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import Uri from '../util/uri'; 3 | 4 | export class OpenZennTreeViewItemCommand implements vscode.Command { 5 | 6 | public readonly title: string = ""; 7 | public readonly command: string = "zenn-editor.open-tree-view-item"; 8 | public readonly arguments: any[]; 9 | 10 | constructor(uri: Uri) { 11 | this.arguments = [uri.underlying]; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/extension/treeView/workspace.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import Uri from '../util/uri'; 3 | import ExtensionResource from '../resource/extensionResource'; 4 | import { ZennTreeItem } from "./zennTreeItem"; 5 | import { ZennWorkspace } from "../util/zennWorkspace"; 6 | import { Articles } from './articles'; 7 | import { Books } from './books'; 8 | 9 | export class Workspace extends ZennTreeItem { 10 | 11 | readonly uri: Uri; 12 | 13 | readonly workspace: ZennWorkspace; 14 | 15 | readonly parent: ZennTreeItem | undefined = undefined; 16 | 17 | private readonly resources: ExtensionResource; 18 | 19 | private children: Promise; 20 | 21 | constructor(workspace: ZennWorkspace, resources: ExtensionResource) { 22 | super(workspace.rootDirectory.basename(), vscode.TreeItemCollapsibleState.Expanded); 23 | this.uri = workspace.rootDirectory; 24 | this.workspace = workspace; 25 | this.resources = resources; 26 | this.resourceUri = workspace.rootDirectory.underlying; 27 | this.children = this.internalLoadChildren(); 28 | } 29 | 30 | async getChildren(): Promise { 31 | return this.children; 32 | } 33 | 34 | async loadChildren(): Promise { 35 | this.children = this.internalLoadChildren(); 36 | return this.children; 37 | } 38 | 39 | async reload(): Promise { 40 | return new Workspace(this.workspace, this.resources); 41 | } 42 | 43 | private async internalLoadChildren(): Promise { 44 | const items: ZennTreeItem[] = []; 45 | if (this.workspace.articlesDirectory) { 46 | items.push(new Articles(this.workspace.articlesDirectory, this.resources)); 47 | } 48 | if (this.workspace.booksDirectory) { 49 | items.push(new Books(this.workspace.booksDirectory, this.resources)); 50 | } 51 | return Promise.resolve(items); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/extension/treeView/zennTreeItem.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import Uri from '../util/uri'; 3 | 4 | 5 | export abstract class ZennTreeItem extends vscode.TreeItem { 6 | 7 | abstract uri: Uri; 8 | 9 | abstract parent: ZennTreeItem | undefined; 10 | 11 | async getChildren(): Promise { 12 | return Promise.resolve([]); 13 | } 14 | 15 | async loadChildren(): Promise { 16 | return Promise.resolve([]); 17 | } 18 | 19 | async findItem(uri: Uri): Promise { 20 | const children = await this.getChildren(); 21 | if (uri.contains(this.uri)) { 22 | if (uri.fsPath() === this.uri.fsPath()) { 23 | return this; 24 | } else if (children.length > 0) { 25 | const childResults = 26 | await Promise.all(children.map(child => child.findItem(uri))); 27 | return childResults.find(v => v !== undefined); 28 | } else { 29 | return undefined; 30 | } 31 | } else { 32 | return undefined; 33 | } 34 | } 35 | 36 | abstract reload(): Promise; 37 | 38 | /** 39 | * このアイテムが変更されたときにリロードするアイテムを指定する。 40 | * デフォルトは自分自身をリロード。 41 | */ 42 | itemNeedToReload(): ZennTreeItem { 43 | return this; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/extension/treeView/zennTreeViewManager.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | import { ZennTreeViewProvider } from "./zennTreeViewProvider"; 3 | import { ZennTreeItem } from "./zennTreeItem"; 4 | import Uri from "../util/uri"; 5 | import ExtensionResource from "../resource/extensionResource"; 6 | import { ZennWorkspace } from '../util/zennWorkspace'; 7 | 8 | export class ZennTeeViewManager { 9 | 10 | public static create(): ZennTeeViewManager { 11 | return new ZennTeeViewManager(); 12 | } 13 | 14 | private constructor() {} 15 | 16 | private treeView: vscode.TreeView | undefined; 17 | 18 | private treeViewProvider: ZennTreeViewProvider | undefined; 19 | 20 | public openTreeView(context: vscode.ExtensionContext): vscode.TreeView { 21 | if (this.treeView) { 22 | return this.treeView; 23 | } else { 24 | this.treeViewProvider = new ZennTreeViewProvider(new ExtensionResource(context)); 25 | this.treeView = vscode.window.createTreeView('zenn', { 26 | treeDataProvider: this.treeViewProvider, 27 | }); 28 | this.treeView.onDidExpandElement(e => { 29 | e.element.collapsibleState = vscode.TreeItemCollapsibleState.Expanded; 30 | }); 31 | this.treeView.onDidCollapseElement(e => { 32 | e.element.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed; 33 | }); 34 | this.treeView.onDidChangeVisibility(e => { 35 | if (e.visible && vscode.window.activeTextEditor) { 36 | const uri = Uri.of(vscode.window.activeTextEditor.document.uri); 37 | this.selectItem(uri); 38 | } 39 | }); 40 | if (vscode.window.activeTextEditor) { 41 | const uri = Uri.of(vscode.window.activeTextEditor.document.uri); 42 | this.selectItem(uri); 43 | } 44 | return this.treeView; 45 | } 46 | } 47 | 48 | public async refresh(uri?: Uri): Promise { 49 | if (this.treeViewProvider) { 50 | return this.treeViewProvider.refresh(uri); 51 | } else { 52 | return Promise.resolve(); 53 | } 54 | } 55 | 56 | public async selectItem(uri: Uri, attemptLimit = 10): Promise { 57 | return new Promise((resolve) => this.innerSelectItem(uri, resolve, attemptLimit)); 58 | } 59 | 60 | private async innerSelectItem(uri: Uri, resolve: (value: ZennTreeItem | undefined) => void, remain: number): Promise { 61 | if (remain === 0) { 62 | resolve(undefined); 63 | } else { 64 | if (this.treeViewProvider && this.treeView) { 65 | const item = await this.treeViewProvider.findItem(uri); 66 | if (item) { 67 | if (this.treeView.visible) { 68 | this.treeView.reveal(item, { 69 | select: true, 70 | focus: false, 71 | expand: true, 72 | }); 73 | } 74 | resolve(item); 75 | } else { 76 | setTimeout(() => this.innerSelectItem(uri, resolve, remain - 1), 100/*ms*/); 77 | } 78 | } else { 79 | resolve(undefined); 80 | } 81 | } 82 | } 83 | 84 | public async activeWorkspace(): Promise { 85 | if (this.treeView) { 86 | if (this.treeView.selection.length > 0) { 87 | const selection = this.treeView.selection[0]; 88 | const uri = selection.uri.workspaceDirectory(); 89 | if (uri) { 90 | return (await ZennWorkspace.resolveWorkspace(uri))[0]; 91 | } 92 | } 93 | } 94 | return ZennWorkspace.findActiveWorkspace(); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/extension/treeView/zennTreeViewProvider.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import ExtensionResource from '../resource/extensionResource'; 3 | import Uri from '../util/uri'; 4 | import { Workspace } from './workspace'; 5 | import { ZennTreeItem } from './zennTreeItem'; 6 | import { ZennWorkspace } from '../util/zennWorkspace'; 7 | 8 | // [Tree View API | Visual Studio Code Extension API](https://code.visualstudio.com/api/extension-guides/tree-view) 9 | export class ZennTreeViewProvider implements vscode.TreeDataProvider { 10 | 11 | private readonly resources: ExtensionResource; 12 | 13 | private _onDidChangeTreeData: vscode.EventEmitter = 14 | new vscode.EventEmitter(); 15 | 16 | readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; 17 | 18 | private workspaces: Promise; 19 | 20 | private rootItems: Promise; 21 | 22 | constructor(resources: ExtensionResource) { 23 | this.resources = resources; 24 | this.workspaces = ZennWorkspace.findWorkspaces(); 25 | this.rootItems = this.loadRootItems(); 26 | } 27 | 28 | public async refresh(uri?: Uri): Promise { 29 | if (uri) { 30 | const item = await this.findClosestItem(uri); 31 | this._onDidChangeTreeData.fire(item?.itemNeedToReload()); 32 | } else { 33 | this._onDidChangeTreeData.fire(undefined); 34 | } 35 | } 36 | 37 | async getTreeItem(element: ZennTreeItem): Promise { 38 | // 開閉状態を維持する 39 | const currentCollapsibleState = element.collapsibleState; 40 | const reloadedElement = await element.reload(); 41 | reloadedElement.collapsibleState = currentCollapsibleState; 42 | return reloadedElement; 43 | } 44 | 45 | async getChildren(element?: ZennTreeItem): Promise { 46 | if (element) { 47 | return element.loadChildren(); 48 | } else { 49 | this.rootItems = this.loadRootItems(); 50 | return this.rootItems; 51 | } 52 | } 53 | 54 | async getParent(element: ZennTreeItem): Promise { 55 | return element.parent; 56 | } 57 | 58 | private async findClosestItem(uri: Uri): Promise { 59 | const workspaceOfItem = (await this.workspaces).find(w => uri.contains(w.rootDirectory)); 60 | if (!workspaceOfItem) { 61 | // out of workspace 62 | return undefined; 63 | } 64 | const foundItem = await this.findItem(uri); 65 | if (foundItem) { 66 | return foundItem; 67 | } else { 68 | return this.findClosestItem(uri.parentDirectory()); 69 | } 70 | } 71 | 72 | public async findItem(uri: Uri): Promise { 73 | const results = 74 | await Promise.all((await this.rootItems).map(i => i.findItem(uri))); 75 | return results.find(r => r !== undefined); 76 | } 77 | 78 | private async loadRootItems(): Promise { 79 | return Promise.all( 80 | (await this.workspaces).map(w => new Workspace(w, this.resources)) 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/extension/util/uri.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as path from 'path'; 3 | 4 | export default class Uri { 5 | 6 | public static file(path: string): Uri { 7 | return new Uri(vscode.Uri.file(path)); 8 | } 9 | 10 | public static of(uri: vscode.Uri): Uri { 11 | return new Uri(uri); 12 | } 13 | 14 | public readonly underlying: vscode.Uri 15 | 16 | private constructor(underlying: vscode.Uri) { 17 | this.underlying = underlying; 18 | } 19 | 20 | public fsPath(): string { 21 | return this.underlying.fsPath; 22 | } 23 | 24 | public basename(): string { 25 | return path.basename(this.fsPath()); 26 | } 27 | 28 | public resolve(...pathSegments: string[]): Uri { 29 | return Uri.file(path.resolve.apply(null, [this.underlying.fsPath].concat(pathSegments))); 30 | } 31 | 32 | public relativePathFrom(from: Uri): string { 33 | return this.underlying.path.substr(from.underlying.path.length + 1); 34 | } 35 | 36 | public workspaceDirectory(): Uri | undefined { 37 | const workspace = vscode.workspace.getWorkspaceFolder(this.underlying); 38 | if (workspace) { 39 | return Uri.of(workspace.uri); 40 | } else { 41 | return undefined; 42 | } 43 | } 44 | 45 | public parentDirectory(): Uri { 46 | return Uri.file(path.dirname(this.fsPath())); 47 | } 48 | 49 | public contains(uri: Uri): boolean { 50 | const self = this.fsPath().split(path.sep); 51 | const other = uri.fsPath().split(path.sep); 52 | if (self.length < other.length) { 53 | return false; 54 | } 55 | for(let i = 0; i < other.length; i++) { 56 | if (self[i] !== other[i]) { 57 | return false; 58 | } 59 | } 60 | return true; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/extension/util/zennWorkspace.ts: -------------------------------------------------------------------------------- 1 | import Uri from './uri'; 2 | import * as vscode from 'vscode'; 3 | import { promises as fs } from 'fs'; 4 | 5 | export class ZennWorkspace { 6 | 7 | public static async findActiveWorkspace(): Promise { 8 | if (vscode.window.activeTextEditor) { 9 | const activeWorkspace = 10 | vscode.workspace.getWorkspaceFolder(vscode.window.activeTextEditor.document.uri); 11 | if (activeWorkspace) { 12 | const result = await this.resolveWorkspace(Uri.of(activeWorkspace.uri)); 13 | if (result.length > 0) { 14 | return result[0]; 15 | } 16 | } 17 | } 18 | return (await this.findWorkspaces())[0]; // Zenn の Workspace があるときだけ拡張が有効になる 19 | } 20 | 21 | public static async findWorkspaces(): Promise { 22 | if (vscode.workspace.workspaceFolders) { 23 | const zennWorkspaces = 24 | Promise.all( 25 | vscode.workspace.workspaceFolders 26 | .map(async folder => this.resolveWorkspace(Uri.of(folder.uri))) 27 | ); 28 | return (await zennWorkspaces).flatMap(i => i /*identity*/); 29 | } 30 | return Promise.reject(new Error("ワークスペースが必要です")); 31 | } 32 | 33 | public static async resolveWorkspace(workspace: Uri): Promise { 34 | const articles = workspace.resolve('articles'); 35 | const articlesStatPromise = fs.stat(articles.fsPath()).catch(() => undefined); 36 | const books = workspace.resolve('books'); 37 | const booksStatPromise = fs.stat(books.fsPath()).catch(() => undefined); 38 | const articlesStat = await articlesStatPromise; 39 | const booksStat = await booksStatPromise; 40 | const articlesDir = articlesStat && articlesStat.isDirectory() ? articles : undefined; 41 | const booksDir = booksStat && booksStat.isDirectory() ? books : undefined; 42 | return (articlesDir || booksDir) ? [new ZennWorkspace(articlesDir, booksDir)] : []; 43 | } 44 | 45 | public readonly rootDirectory: Uri; 46 | 47 | public readonly articlesDirectory: Uri | undefined; 48 | 49 | public readonly booksDirectory: Uri | undefined; 50 | 51 | constructor(articlesDirectory: Uri | undefined, booksDirectory: Uri | undefined) { 52 | this.articlesDirectory = articlesDirectory; 53 | this.booksDirectory = booksDirectory; 54 | if (articlesDirectory) { 55 | this.rootDirectory = articlesDirectory.parentDirectory(); 56 | } else if(booksDirectory) { 57 | this.rootDirectory = booksDirectory.parentDirectory(); 58 | } else { 59 | throw new Error(`Could not resolve root directory { articles: ${articlesDirectory}, books: ${booksDirectory} }`); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/extension/zenncli/zennCli.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from 'vscode'; 2 | import * as childProcess from 'child_process'; 3 | import ZennPreview from './zennPreview'; 4 | import * as process from 'process'; 5 | import * as which from 'which'; 6 | import ZennNewArticle from './zennNewArticle'; 7 | import ZennNewBook from './zennNewBook'; 8 | import Uri from '../util/uri'; 9 | import ZennVersion from './zennVersion'; 10 | 11 | export class ZennCli { 12 | 13 | public static async create(workingDirectory: Uri): Promise { 14 | try { 15 | const zennCliPath = await ZennCli.findZennCliPath(workingDirectory); 16 | return new ZennCli(workingDirectory, zennCliPath); 17 | } catch(e) { 18 | vscode.window.showErrorMessage('zenn-cli が見つかりませんでした。インストールしてください。\nhttps://zenn.dev/zenn/articles/install-zenn-cli'); 19 | throw e; 20 | } 21 | } 22 | 23 | private static async findZennCliPath(workingDirectory: Uri): Promise { 24 | const env = Object.assign({}, process.env); 25 | const paths: string[] = new (require('path-array'))(env); 26 | for (let i = 0; i < paths.length; i++) { 27 | paths[i] = workingDirectory.resolve(paths[i]).fsPath(); 28 | } 29 | // additional paths 30 | paths.unshift( 31 | workingDirectory.resolve('node_modules', '.bin').fsPath(), 32 | ); 33 | return Uri.file(await which('zenn', { path: env.PATH })); 34 | } 35 | 36 | readonly workingDirectory: Uri; 37 | 38 | readonly zennCliPath: Uri; 39 | 40 | private constructor(workingDirectory: Uri, zennCliPath: Uri) { 41 | this.workingDirectory = workingDirectory; 42 | this.zennCliPath = zennCliPath; 43 | } 44 | 45 | public preview(port: number): Promise { 46 | const cliProcess = this.spawn(['preview', '--port', port.toString()]); 47 | return ZennPreview.create(port, cliProcess, this.workingDirectory); 48 | } 49 | 50 | public createNewArticle(): Promise { 51 | const childProcess = this.spawn(['new:article', '--machine-readable']); 52 | return ZennNewArticle.resolve(childProcess, this.workingDirectory); 53 | } 54 | 55 | public createNewBook(): Promise { 56 | const childProcess = this.spawn(['new:book']); 57 | return ZennNewBook.resolve(childProcess, this.workingDirectory); 58 | } 59 | 60 | public version(): Promise { 61 | const childProcess = this.spawn(['--version']); 62 | return ZennVersion.resolve(childProcess); 63 | } 64 | 65 | private spawn(args: string[]): childProcess.ChildProcessWithoutNullStreams { 66 | const zennProcess = childProcess.spawn(this.zennCliPath.fsPath(), args, { 67 | cwd: this.workingDirectory.fsPath(), 68 | // Windows 環境で発生するエラー「A system error occurred (spawn EINVAL)」を回避する 69 | // see: https://github.com/negokaz/vscode-zenn-editor/issues/41 70 | shell: process.platform === 'win32', 71 | }); 72 | zennProcess.on('error', err => { 73 | vscode.window.showErrorMessage(`zenn-cli の起動に失敗しました: ${err.message}`); 74 | }); 75 | return zennProcess; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/extension/zenncli/zennNewArticle.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from "child_process"; 2 | import Uri from '../util/uri'; 3 | 4 | export default class ZennNewArticle { 5 | 6 | public static resolve(process: ChildProcessWithoutNullStreams, workingDirectory: Uri): Promise { 7 | return new Promise((resolve, reject) => { 8 | var stdout = ''; 9 | var stderr = ''; 10 | process.stdout.on('data', data => { 11 | stdout = stdout + data; 12 | }); 13 | process.stderr.on('data', data => { 14 | stderr = stderr + data; 15 | }); 16 | process.on('close', code => { 17 | if (code === 0) { 18 | const newArticlePath = stdout.trim(); 19 | if (newArticlePath.startsWith('article')) { 20 | // 0.1.86 以降 21 | resolve(new ZennNewArticle(workingDirectory.resolve(newArticlePath))); 22 | } else { 23 | // 0.1.85 以前 24 | resolve(new ZennNewArticle(workingDirectory.resolve('articles', newArticlePath))); 25 | } 26 | } else { 27 | reject(new Error(`Article creation failed (exit code: ${code}): ${stderr}`)); 28 | } 29 | }); 30 | }); 31 | } 32 | 33 | public readonly articleUri: Uri; 34 | 35 | private constructor(articleUri: Uri) { 36 | this.articleUri = articleUri; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/extension/zenncli/zennNewBook.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from "child_process"; 2 | import Uri from '../util/uri'; 3 | 4 | export default class ZennNewBook { 5 | 6 | public static resolve(process: ChildProcessWithoutNullStreams, workingDirectory: Uri): Promise { 7 | return new Promise((resolve, reject) => { 8 | var stdout = ''; 9 | var stderr = ''; 10 | process.stdout.on('data', data => { 11 | stdout = stdout + data; 12 | }); 13 | process.stderr.on('data', data => { 14 | stderr = stderr + data; 15 | }); 16 | process.on('close', code => { 17 | if (code === 0) { 18 | const configRelativePath = stdout.match(/books\/[^/]+\/config.yaml/); 19 | if (configRelativePath) { 20 | const path = configRelativePath[0]; 21 | resolve(new ZennNewBook(workingDirectory.resolve(path))); 22 | } else { 23 | reject(new Error(`Book creation failed [unexpected stdout]: ${stdout}`)); 24 | } 25 | } else { 26 | reject(new Error(`Book creation failed (exit code: ${code}): ${stderr}`)); 27 | } 28 | }); 29 | }); 30 | } 31 | 32 | public readonly configUri: Uri; 33 | 34 | private constructor(configUri: Uri) { 35 | this.configUri = configUri; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/extension/zenncli/zennPreview.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from "child_process"; 2 | import * as readline from 'readline'; 3 | import * as psTree from 'ps-tree'; 4 | import * as process from 'process'; 5 | import Uri from '../util/uri'; 6 | 7 | export default class ZennPreview { 8 | 9 | public static create(port: number, process: ChildProcessWithoutNullStreams, workingDirectory: Uri): Promise { 10 | const stdout = readline.createInterface(process.stdout); 11 | const stderr = readline.createInterface(process.stderr); 12 | return new Promise((resolve, reject) => { 13 | const timeout = setTimeout(() => { 14 | try { 15 | this.kill(process); 16 | } finally { 17 | reject(new Error("preview timeout")); 18 | } 19 | }, 10000 /*ms*/); 20 | 21 | stdout.on('line', line => { 22 | console.log(line.toString()); 23 | if (line.includes(`http://localhost:${port}`)) { 24 | clearTimeout(timeout); 25 | resolve(new ZennPreview(port, process, workingDirectory)); 26 | } 27 | }); 28 | stderr.on('line', line => { 29 | console.log(line.toString()); 30 | }); 31 | }); 32 | } 33 | 34 | private static kill(childProcess: ChildProcessWithoutNullStreams) { 35 | psTree(childProcess.pid, (error, children) => { 36 | if (error) { 37 | console.error(error); 38 | } else { 39 | children.forEach(child => { 40 | process.kill(parseInt(child.PID)); 41 | }); 42 | } 43 | }); 44 | } 45 | 46 | public readonly host: string; 47 | 48 | public readonly port: number; 49 | 50 | public readonly workingDirectory: Uri; 51 | 52 | private readonly process: ChildProcessWithoutNullStreams; 53 | 54 | private constructor(port: number, process: ChildProcessWithoutNullStreams, workingDirectory: Uri) { 55 | this.host = '127.0.0.1'; 56 | this.port = port; 57 | this.process = process; 58 | this.workingDirectory = workingDirectory; 59 | } 60 | 61 | public onClose(listener: () => void): void { 62 | this.process.on('close', listener); 63 | } 64 | 65 | public close(): void { 66 | ZennPreview.kill(this.process); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/extension/zenncli/zennPreviewProxyServer.ts: -------------------------------------------------------------------------------- 1 | import * as http from 'http'; 2 | import * as httpProxy from 'http-proxy'; 3 | import ExtensionResource from '../resource/extensionResource'; 4 | import * as fs from 'fs'; 5 | 6 | export class ZennPreviewProxyServer { 7 | 8 | public static INDEX_PATH = '__vscode_zenn_editor_preview_proxy_index' 9 | 10 | public static start(host: string, port: number, backendPort: number, iframeEntrypointPath: string, resource: ExtensionResource): Promise { 11 | return new Promise((resolve, reject) => { 12 | const proxy = httpProxy.createProxyServer({ 13 | target: { 14 | host: host, 15 | port: backendPort, 16 | }, 17 | ws: true, 18 | }); 19 | const proxyServer = http.createServer((req, res) => { 20 | const indexPathPrefix = `/${ZennPreviewProxyServer.INDEX_PATH}/` 21 | if (req.url && req.url.startsWith(indexPathPrefix)) { 22 | const path = req.url.substr(indexPathPrefix.length); 23 | switch (path) { 24 | case 'proxyView.js': 25 | fs.readFile(resource.uri('dist', 'proxyView.js').fsPath, (error, content) => { 26 | res.writeHead(200, { 'Content-Type': 'text/javascript' }); 27 | res.end(content); 28 | }); 29 | break; 30 | default: 31 | res.end(this.handleProxyIndex(iframeEntrypointPath)); 32 | } 33 | } else { 34 | proxy.web(req, res); 35 | } 36 | }); 37 | 38 | const timeout = setTimeout(() => { 39 | try { 40 | proxyServer.close(); 41 | } finally { 42 | reject(new Error("starting proxy server timeout")); 43 | } 44 | }, 10000 /*ms*/); 45 | 46 | proxyServer.on('upgrade', (req, socket, head) => { 47 | proxy.ws(req, socket, head); 48 | }); 49 | proxyServer.on('listening', () => { 50 | console.log(`Local proxy server started on http://${host}:${port}`); 51 | clearTimeout(timeout); 52 | resolve(new ZennPreviewProxyServer(host, port, backendPort, iframeEntrypointPath, proxyServer)); 53 | }); 54 | proxyServer.listen(port); 55 | }); 56 | } 57 | 58 | private static handleProxyIndex(iframeRelativePath: string): string { 59 | return ` 60 | 61 | 62 | 63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 | ` 71 | } 72 | 73 | readonly host: string; 74 | 75 | readonly port: number; 76 | 77 | readonly backendPort: number; 78 | 79 | readonly iframeEntrypointPath: string; 80 | 81 | private readonly server: http.Server; 82 | 83 | private constructor(host: string, port: number, backendPort: number, iframeEntrypointPath: string, server: http.Server) { 84 | this.host = host; 85 | this.port = port; 86 | this.backendPort = backendPort; 87 | this.iframeEntrypointPath = iframeEntrypointPath; 88 | this.server = server; 89 | } 90 | 91 | public entrypointUrl(): string { 92 | return `http://${this.host}:${this.port}/${ZennPreviewProxyServer.INDEX_PATH}/${this.iframeEntrypointPath}` 93 | } 94 | 95 | public stop(): void { 96 | this.currentServer().close(); 97 | } 98 | 99 | private currentServer(): http.Server { 100 | if (this.server) { 101 | return this.server; 102 | } else { 103 | throw new Error("Server has not started") 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/extension/zenncli/zennVersion.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from "child_process"; 2 | 3 | export default class ZennVersion { 4 | 5 | public static create(version: string): ZennVersion { 6 | return new ZennVersion(version); 7 | } 8 | 9 | public static resolve(process: ChildProcessWithoutNullStreams): Promise { 10 | return new Promise((resolve, reject) => { 11 | var stdout = ''; 12 | var stderr = ''; 13 | process.stdout.on('data', data => { 14 | stdout = stdout + data; 15 | }); 16 | process.stderr.on('data', data => { 17 | stderr = stderr + data; 18 | }); 19 | process.on('close', code => { 20 | if (code === 0) { 21 | resolve(new ZennVersion(stdout.trim())); 22 | } else { 23 | reject(new Error(`Cannot resolve version (exit code: ${code}): ${stderr}`)); 24 | } 25 | }); 26 | }); 27 | } 28 | 29 | public readonly major: number; 30 | 31 | public readonly minor: number; 32 | 33 | public readonly patch: number; 34 | 35 | public readonly displayVersion: string; 36 | 37 | private constructor(version: string) { 38 | const normalized = version.match(/[0-9]+\.[0-9]+\.[0-9]+/); 39 | this.displayVersion = (normalized && normalized[0] ? normalized[0] : "0.0.0"); 40 | const [major, minor, patch] = this.displayVersion.split('.'); 41 | this.major = parseInt(major); 42 | this.minor = parseInt(minor); 43 | this.patch = parseInt(patch); 44 | } 45 | 46 | compare(other: ZennVersion): number { 47 | if (this.major > other.major) { 48 | return 1; 49 | } else if (this.major < other.major) { 50 | return -1; 51 | } else { // common major 52 | if (this.minor > other.minor) { 53 | return 1; 54 | } else if (this.minor < other.minor) { 55 | return -1; 56 | } else { // common minor 57 | if (this.patch > other.patch) { 58 | return 1; 59 | } else if (this.patch < other.patch) { 60 | return -1; 61 | } else { // common patch 62 | return 0; 63 | } 64 | } 65 | } 66 | } 67 | 68 | toString(): string { 69 | return this.displayVersion; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from 'vscode-test'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests'); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from 'vscode'; 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)); 13 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/webview/full-page-iframe.css: -------------------------------------------------------------------------------- 1 | body, html, div { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | background-color: #fff; 8 | } 9 | -------------------------------------------------------------------------------- /src/webview/proxyView/proxyView.ts: -------------------------------------------------------------------------------- 1 | import '../full-page-iframe.css'; 2 | 3 | window.addEventListener('DOMContentLoaded', () => { 4 | const zenn = document.querySelector('iframe#zenn'); 5 | if (zenn && zenn instanceof HTMLIFrameElement) { 6 | activate(zenn); 7 | } else { 8 | console.error("iframe not available") 9 | } 10 | }); 11 | 12 | function activate(zenn: HTMLIFrameElement) { 13 | const zennWindow = zenn.contentWindow; 14 | if (zennWindow) { 15 | watchZennWindow(zennWindow); 16 | window.addEventListener('message', event => { 17 | const message = event.data; 18 | switch(message.command) { 19 | case 'change_path': 20 | const newPath = '/' + message.relativePath; 21 | if (zennWindow.location.pathname !== newPath) 22 | zennWindow.location.replace(newPath); 23 | break; 24 | default: 25 | console.warn("unhandled message", message); 26 | } 27 | }); 28 | } 29 | } 30 | 31 | function watchZennWindow(zennWindow: Window) { 32 | zennWindow.addEventListener('unload', () => setTimeout(() => { 33 | // This block is called after unload 34 | const observer = 35 | new MutationObserver(() => { 36 | if (zennWindow.document.head && zennWindow.document.body) { 37 | watchZennWindow(zennWindow); 38 | onLoadZennWindow(zennWindow); 39 | onChangeZennWindow(zennWindow); 40 | observer.disconnect(); 41 | } 42 | }); 43 | observer.observe(zennWindow.document, { childList: true, subtree: true }); 44 | }, 0)); 45 | zennWindow.addEventListener('load', () => { 46 | new MutationObserver(() => { 47 | onChangeZennWindow(zennWindow); 48 | }).observe(zennWindow.document, { childList: true, subtree: true }); 49 | }); 50 | } 51 | 52 | /** 53 | * 子 iframe の読み込みが完了して描画される直前に呼ばれる。 54 | * ページ遷移したときにも呼ばれる。 55 | * 重複して呼ばれる可能性があることに注意。 56 | */ 57 | function onLoadZennWindow(zennWindow: Window) { 58 | // hide sidebar 59 | const style = document.createElement('style'); 60 | style.textContent = ` 61 | /* for zenn-cli <= 0.1.85 */ 62 | .main-sidebar { display: none } 63 | /* for zenn-cli >= 0.1.86 */ 64 | .layout__sidebar { display: none } 65 | .chapter-header__book { display: none } 66 | `; 67 | zennWindow.document.head.appendChild(style); 68 | } 69 | 70 | /** 71 | * 子 iframe が変化したときに呼ばれる。 72 | * ページ遷移したときにも呼ばれる。 73 | * 重複して呼ばれる可能性があることに注意。 74 | */ 75 | function onChangeZennWindow(zennWindow: Window) { 76 | // handle click event 77 | const listenerAddedMarkDataName = '__vscode_zenn_editor_handle_a_click_event'; 78 | zennWindow.document.querySelectorAll('a').forEach(e => { 79 | const url = new window.URL(e.href); 80 | if (url.origin !== '' && url.origin !== zennWindow.location.origin && !e.dataset[listenerAddedMarkDataName]) { 81 | e.addEventListener('click', event => { 82 | event.preventDefault(); 83 | window.parent.postMessage({ source: 'proxy', command: 'open-link', link: e.href }, '*'); 84 | }); 85 | e.dataset[listenerAddedMarkDataName] = 'true'; 86 | } 87 | }); 88 | // handle link card click events 89 | zennWindow.document.querySelectorAll('.zenn-embedded-card > iframe').forEach(e => { 90 | const contentUrl = e.dataset.content; 91 | if (contentUrl && !e.dataset[listenerAddedMarkDataName]) { 92 | const linkUrl = decodeURIComponent(contentUrl); 93 | // How to detect a click event on a cross domain iframe 94 | // https://gist.github.com/jaydson/1780598 95 | let iframeMouseOver = false; 96 | zennWindow.addEventListener('blur', () => { 97 | if (iframeMouseOver) { 98 | iframeMouseOver = false; 99 | window.parent.postMessage({ source: 'proxy', command: 'open-link', link: linkUrl }, '*'); 100 | } 101 | }); 102 | e.addEventListener('mouseover', () => { 103 | // blur イベントを確実に起こすためフォーカスを移しておく 104 | zennWindow.focus(); 105 | iframeMouseOver = true; 106 | }); 107 | e.addEventListener('mouseout', () => { 108 | iframeMouseOver = false; 109 | }); 110 | e.dataset[listenerAddedMarkDataName] = 'true'; 111 | } 112 | }); 113 | } 114 | -------------------------------------------------------------------------------- /src/webview/webview/webview.ts: -------------------------------------------------------------------------------- 1 | import '../full-page-iframe.css'; 2 | 3 | // This API provided by vscode: 4 | // https://code.visualstudio.com/api/extension-guides/webview#passing-messages-from-a-webview-to-an-extension 5 | declare let acquireVsCodeApi: any; 6 | 7 | window.addEventListener('DOMContentLoaded', () => { 8 | const proxy = document.querySelector('iframe#zenn-proxy'); 9 | if (proxy && proxy instanceof HTMLIFrameElement && proxy.contentWindow) { 10 | activate(proxy.contentWindow); 11 | } else { 12 | console.error("iframe not available"); 13 | } 14 | }); 15 | 16 | function activate(proxyWindow: Window) { 17 | 18 | const vscode = acquireVsCodeApi(); 19 | 20 | window.addEventListener('message', event => { 21 | // forward message 22 | const message = event.data; 23 | if (message.source === 'proxy') { 24 | vscode.postMessage(message); 25 | } else { 26 | proxyWindow.postMessage(message, '*'); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.extension.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "target": "es6", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "rootDir": "src/extension", 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "out", 4 | "sourceMap": true, 5 | "rootDir": "src", 6 | "strict": true /* enable all strict type-checking options */ 7 | /* Additional Checks */ 8 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 9 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 10 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 11 | }, 12 | "exclude": [ 13 | "node_modules", 14 | ".vscode-test" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.webview.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "umd", 5 | "target": "es5", 6 | "lib": [ 7 | "dom" 8 | ], 9 | "rootDir": "src/webview", 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | * This folder contains all of the files necessary for your extension. 6 | * `package.json` - this is the manifest file in which you declare your extension and command. 7 | * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. 8 | * `src/extension.ts` - this is the main file where you will provide the implementation of your command. 9 | * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. 10 | * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. 11 | 12 | ## Get up and running straight away 13 | 14 | * Press `F5` to open a new window with your extension loaded. 15 | * Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. 16 | * Set breakpoints in your code inside `src/extension.ts` to debug your extension. 17 | * Find output from your extension in the debug console. 18 | 19 | ## Make changes 20 | 21 | * You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. 22 | * You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 23 | 24 | 25 | ## Explore the API 26 | 27 | * You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. 28 | 29 | ## Run tests 30 | 31 | * Open the debug viewlet (`Ctrl+Shift+D` or `Cmd+Shift+D` on Mac) and from the launch configuration dropdown pick `Extension Tests`. 32 | * Press `F5` to run the tests in a new window with your extension loaded. 33 | * See the output of the test result in the debug console. 34 | * Make changes to `src/test/suite/extension.test.ts` or create new test files inside the `test/suite` folder. 35 | * The provided test runner will only consider files matching the name pattern `**.test.ts`. 36 | * You can create folders inside the `test` folder to structure your tests any way you want. 37 | 38 | ## Go further 39 | 40 | * Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). 41 | * [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VSCode extension marketplace. 42 | * Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). 43 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | 'use strict'; 4 | 5 | const path = require('path'); 6 | 7 | /**@type {import('webpack').Configuration}*/ 8 | const extension = { 9 | target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/extensionuration/node/ 10 | mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 11 | 12 | entry: './src/extension/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/ 13 | output: { 14 | // the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/ 15 | path: path.resolve(__dirname, 'dist'), 16 | filename: 'extension.js', 17 | libraryTarget: 'commonjs2' 18 | }, 19 | devtool: 'nosources-source-map', 20 | externals: { 21 | vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/ 22 | }, 23 | resolve: { 24 | // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader 25 | extensions: ['.ts', '.js'] 26 | }, 27 | module: { 28 | rules: [ 29 | { 30 | test: /\.ts$/, 31 | exclude: /node_modules/, 32 | use: [ 33 | { 34 | loader: 'ts-loader' 35 | } 36 | ] 37 | } 38 | ] 39 | } 40 | }; 41 | 42 | const webview = { 43 | target: 'web', 44 | entry: './src/webview/webview/webview.ts', 45 | output: { 46 | path: path.resolve(__dirname, 'dist'), 47 | filename: 'webview.js', 48 | devtoolModuleFilenameTemplate: '../[resource-path]' 49 | }, 50 | devtool: 'source-map', 51 | resolve: { 52 | extensions: ['.ts', '.js'] 53 | }, 54 | module: { 55 | rules: [ 56 | { 57 | test: /\.ts$/, 58 | exclude: /node_modules/, 59 | use: [ 60 | { 61 | loader: 'ts-loader' 62 | } 63 | ] 64 | }, 65 | { 66 | test: /\.css$/, 67 | use: ['style-loader', 'css-loader'], 68 | } 69 | ] 70 | } 71 | }; 72 | 73 | const proxyView = { 74 | target: 'web', 75 | entry: './src/webview/proxyView/proxyView.ts', 76 | output: { 77 | path: path.resolve(__dirname, 'dist'), 78 | filename: 'proxyView.js', 79 | devtoolModuleFilenameTemplate: '../[resource-path]' 80 | }, 81 | devtool: 'source-map', 82 | resolve: { 83 | extensions: ['.ts', '.js'] 84 | }, 85 | module: { 86 | rules: [ 87 | { 88 | test: /\.ts$/, 89 | exclude: /node_modules/, 90 | use: [ 91 | { 92 | loader: 'ts-loader' 93 | } 94 | ] 95 | }, 96 | { 97 | test: /\.css$/, 98 | use: ['style-loader', 'css-loader'], 99 | } 100 | ] 101 | } 102 | }; 103 | 104 | module.exports = [ 105 | extension, 106 | webview, 107 | proxyView, 108 | ]; 109 | --------------------------------------------------------------------------------