├── .env.default ├── .github └── workflows │ ├── main.yaml │ └── tests.yaml ├── .gitignore ├── LICENSE ├── README.md ├── assets └── logo.png ├── docs └── index.md ├── manifest.json ├── package-lock.json ├── package.json ├── scripts ├── postversion.js └── pretest.js ├── src ├── components │ └── ExternalNotebookReference.tsx ├── main.ts ├── protocols │ ├── notebookQuerying.ts │ └── sharePageWithNotebook.ts ├── styles.css └── utils │ ├── atJsonToObsidian.ts │ ├── leafParser.ts │ └── renderOverlay.ts ├── tests ├── config-todo.ts ├── extension.test.ts ├── leafParser.test.ts └── mockObsidianEnvironment.ts └── tsconfig.json /.env.default: -------------------------------------------------------------------------------- 1 | # OBSIDIAN_PLUGIN_PATH="C:\Users\YourUserName\ObsidianDirectory\.obsidian\plugins\samepage-dev" -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: main 6 | paths: 7 | - "src/**" 8 | - "package.json" 9 | - ".github/workflows/main.yaml" 10 | 11 | env: 12 | AWS_ACCESS_KEY_ID: ${{ secrets.SAMEPAGE_AWS_ACCESS_KEY }} 13 | AWS_SECRET_ACCESS_KEY: ${{ secrets.SAMEPAGE_AWS_ACCESS_SECRET }} 14 | AWS_REGION: us-east-1 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | jobs: 18 | deploy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: install 23 | run: npm install 24 | - name: build 25 | run: npx samepage build 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Test Extension 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | push: 6 | branches: main 7 | 8 | env: 9 | API_URL: https://api.samepage.network 10 | AWS_ACCESS_KEY_ID: ${{ secrets.SAMEPAGE_AWS_ACCESS_KEY }} 11 | AWS_SECRET_ACCESS_KEY: ${{ secrets.SAMEPAGE_AWS_ACCESS_SECRET }} 12 | AWS_REGION: us-east-1 13 | PLAYWRIGHT_HTML_REPORT: playwright-report 14 | SAMEPAGE_TEST_UUID: ${{ secrets.SAMEPAGE_TEST_UUID }}, 15 | SAMEPAGE_TEST_TOKEN: ${{ secrets.SAMEPAGE_TEST_TOKEN }}, 16 | WEB_SOCKET_URL: wss://ws.samepage.network 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: install 24 | run: npm install 25 | - name: install playwright 26 | run: npx playwright install chromium 27 | - name: test 28 | run: npm t 29 | - name: Upload Integration Test Coverage to Codecov 30 | uses: codecov/codecov-action@v3 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .env 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Vargas 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SamePage 2 | 3 | [![Built by SamePage](https://img.shields.io/badge/Ξ-Built_by_SamePage-blue.svg)](https://github.com/samepage-network/samepage.network) [![Install](https://img.shields.io/github/v/release/samepage-network/obsidian-samepage)](https://samepage.network/install?id=obsidian) [![Twitter follow](https://img.shields.io/badge/follow-%40samepagenetwork-blue.svg?style=flat&logo=twitter)](https://twitter.com/samepagenetwork) [![Discord](https://img.shields.io/discord/1042590270849568788.svg)](https://discord.gg/UpKAfUvUPd) [![Test coverage](https://codecov.io/gh/samepage-network/obsidian-samepage/branch/main/graph/badge.svg)](https://codecov.io/gh/samepage-network/obsidian-samepage) 4 | 5 | Official Obsidian client into [SamePage](https://samepage.network) - the inter-tool protocol for thought. 6 | 7 | Use SamePage to connect your Obsidian Vault to other notebooks to sync changes across them, perform cross notebook queries, and more! If you need help getting started, check out our docs at [https://samepage.network/docs](https://samepage.network/docs)! 8 | 9 | ## Demo 10 | 11 | 12 | 13 | ## WARNING 14 | 15 | The SamePage family of extensions are still **in beta** and are not considered stable yet for real or sensitive data. All data shared on SamePage is considered public and probability of data loss is high. 16 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samepage-network/obsidian-samepage/9418e6d9f09bcf907b8ed9e1466ae64f3bf3ac9b/assets/logo.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installing on Obsidian 3 | description: Install SamePage into your Obsidian Vault 4 | --- 5 | 6 | The SamePage Obsidian extension allows you to connect your Obsidian Vault to other notebooks using the SamePage Network. 7 | 8 | A **Notebook** in Obsidian is a vault. Your personal vault is an example of a **Notebook**. 9 | 10 | ## Installing the Live Version 11 | 12 | The Live version of the SamePage extension is available in the Obsidian Community Plugins Store! 13 | 14 | First, open your Obsidian Settings by clicking on the settings gear on the bottom left: 15 | 16 | ![](/images/install/obsidian-live-1.png) 17 | 18 | Next, on the `Community plugins` tab, click the Browse button towards the right: 19 | 20 | ![](/images/install/obsidian-live-2.png) 21 | 22 | Finally, search for "SamePage" and when you arrive to the extension's page, click on the Install and Enable buttons! 23 | 24 | ![](/images/install/obsidian-live-3.png) 25 | 26 | Once installed and enabled, the extension should automatically kick off the onboarding flow behind the settings pages. 27 | 28 | ### Demo 29 | 30 |
31 | 32 | For help with onboarding, check out our [Onboarding docs.](../../getting_started/install#onboarding) 33 | 34 | ## Installing the Development version 35 | 36 | Note that this version is only intended for contributors to SamePage. Most users will never need to install the development version. 37 | 38 | Click [this link](https://samepage.network/extensions/obsidian.zip) to download the latest development version of the SamePage extension for Obsidian. 39 | 40 | After downloading the extension from SamePage, extract the zip into a folder. You then have to move that folder within the folder that represents your vault, within the `.obsidian/plugins` directory: 41 | 42 | ![](/images/install/obsidian-2.png) 43 | 44 | In Obsidian, click on the settings icon on the bottom left and head to the `Community Plugins` tab: 45 | 46 | ![](/images/install/obsidian-3.png) 47 | 48 | Once you do, you should see SamePage appear in your community plugins section. Click on the toggle next to SamePage to enable the extension: 49 | 50 | ![](/images/install/obsidian-4.png) 51 | 52 | The extension should automatically load and connect to SamePage! 53 | 54 | ### Demo 55 | 56 |
57 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "samepage", 3 | "name": "SamePage", 4 | "version": "2.7.2", 5 | "minAppVersion": "0.15.0", 6 | "description": "Official Obsidian client into the inter-TFT-protocol", 7 | "author": "samepage-network", 8 | "authorUrl": "https://samepage.network", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-samepage", 3 | "version": "2.7.2", 4 | "description": "Official Obsidian client into the intra tool-for-thought protocol.", 5 | "main": "./dist/index.js", 6 | "scripts": { 7 | "postversion": "node scripts/postversion.js", 8 | "postinstall": "patch-package --patch-dir node_modules/samepage/patches", 9 | "start": "samepage start", 10 | "pretest": "node scripts/pretest.js", 11 | "test": "samepage test" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/samepage-network/obsidian-samepage.git" 16 | }, 17 | "keywords": [ 18 | "Obsidian", 19 | "SamePage" 20 | ], 21 | "author": { 22 | "name": "SamePage", 23 | "email": "support@samepage.network", 24 | "url": "https://samepage.network" 25 | }, 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/samepage-network/obsidian-samepage/issues" 29 | }, 30 | "homepage": "https://github.com/samepage-network/obsidian-samepage#readme", 31 | "devDependencies": { 32 | "@types/crypto-js": "^4.1.1", 33 | "obsidian": "^0.16.0" 34 | }, 35 | "dependencies": { 36 | "axios": "^0.27.2", 37 | "crypto-js": "^4.1.1", 38 | "samepage": "^0.65.12" 39 | }, 40 | "samepage": { 41 | "external": "obsidian", 42 | "include": [ 43 | "manifest.json" 44 | ], 45 | "mirror": "$OBSIDIAN_PLUGIN_PATH", 46 | "format": "cjs", 47 | "install": { 48 | "steps": [ 49 | { 50 | "title": "Go to Settings", 51 | "children": "image" 52 | }, 53 | { 54 | "title": "Browse Community Plugins", 55 | "children": "image" 56 | }, 57 | { 58 | "title": "Enable SamePage!", 59 | "children": "image" 60 | } 61 | ] 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /scripts/postversion.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const execSync = require("child_process").execSync; 3 | 4 | const { version } = JSON.parse(fs.readFileSync("package.json").toString()); 5 | fs.writeFileSync( 6 | "manifest.json", 7 | fs 8 | .readFileSync("manifest.json") 9 | .toString() 10 | .replace(/"version": "[\d.-]+",/, `"version": "${version}",`) 11 | ); 12 | execSync("git add --all", { stdio: "inherit" }); 13 | execSync("git commit --amend --no-edit", { stdio: "inherit" }); 14 | -------------------------------------------------------------------------------- /scripts/pretest.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const esbuild = require("esbuild").build; 3 | 4 | const packageJson = JSON.parse( 5 | fs.readFileSync("node_modules/obsidian/package.json").toString() 6 | ); 7 | packageJson.main = packageJson.main || "index.js"; 8 | fs.writeFileSync( 9 | "node_modules/obsidian/package.json", 10 | JSON.stringify(packageJson, null, 2) 11 | ); 12 | 13 | esbuild({ 14 | entryPoints: ["./tests/mockObsidianEnvironment.ts"], 15 | outfile: `node_modules/obsidian/${packageJson.main}`, 16 | bundle: true, 17 | platform: "node", 18 | external: ["./node_modules/jsdom/*"], 19 | allowOverwrite: true, 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/ExternalNotebookReference.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useCallback } from "react"; 2 | import { Classes, Dialog } from "@blueprintjs/core"; 3 | import type { InitialSchema, OverlayProps } from "samepage/internal/types"; 4 | import apiClient from "samepage/internal/apiClient"; 5 | import atJsonToObsidian from "../utils/atJsonToObsidian"; 6 | import type { default as SamePagePlugin } from "../main"; 7 | 8 | export const references: Record> = {}; 9 | 10 | const ExternalNotebookReference = ({ 11 | notebookUuid, 12 | notebookPageId, 13 | isOpen, 14 | onClose, 15 | }: OverlayProps<{ 16 | notebookUuid: string; 17 | notebookPageId: string; 18 | }>) => { 19 | const [data, setData] = useState( 20 | references[notebookUuid]?.[notebookPageId] || { 21 | content: `Loading reference from external notebook...`, 22 | annotations: [], 23 | } 24 | ); 25 | const setReferenceData = useCallback( 26 | (data: InitialSchema) => { 27 | if (!references[notebookUuid]) references[notebookUuid] = {}; 28 | setData((references[notebookUuid][notebookPageId] = data)); 29 | }, 30 | [notebookPageId, notebookUuid] 31 | ); 32 | useEffect(() => { 33 | apiClient<{ 34 | found: boolean; 35 | data: InitialSchema; 36 | }>({ 37 | method: "query", 38 | request: `${notebookUuid}:${notebookPageId}`, 39 | }).then((e) => { 40 | const { found, data } = e; 41 | const newData = found 42 | ? data 43 | : { content: "Notebook reference not found", annotations: [] }; 44 | setReferenceData(newData); 45 | }); 46 | const queryResponseListener = ((e: CustomEvent) => { 47 | const { request, data } = e.detail as { 48 | request: string; 49 | data: InitialSchema; 50 | }; 51 | if (request === `${notebookUuid}:${notebookPageId}`) { 52 | setReferenceData( 53 | data || { content: "Notebook reference not found", annotations: [] } 54 | ); 55 | } 56 | }) as EventListener; 57 | document.body.addEventListener( 58 | "samepage:reference:response", 59 | queryResponseListener 60 | ); 61 | return () => 62 | document.body.removeEventListener( 63 | "samepage:reference:response", 64 | queryResponseListener 65 | ); 66 | }, [setReferenceData, notebookUuid, notebookPageId]); 67 | return ( 68 | 74 |
75 | {atJsonToObsidian(data)} 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default ExternalNotebookReference; 82 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Plugin, PluginSettingTab, Setting } from "obsidian"; 2 | import type { App } from "obsidian"; 3 | import defaultSettings, { 4 | DefaultSetting, 5 | } from "samepage/utils/defaultSettings"; 6 | import setupSamePageClient from "samepage/protocols/setupSamePageClient"; 7 | import setupSharePageWithNotebook from "./protocols/sharePageWithNotebook"; 8 | import renderOverlay from "./utils/renderOverlay"; 9 | import setupNotebookQuerying from "./protocols/notebookQuerying"; 10 | 11 | const defaultTypeById = Object.fromEntries( 12 | defaultSettings.map((s) => [s.id, s.type]) 13 | ); 14 | 15 | class SamePageSettingTab extends PluginSettingTab { 16 | plugin: SamePagePlugin; 17 | 18 | constructor(app: App, plugin: SamePagePlugin) { 19 | super(app, plugin); 20 | this.plugin = plugin; 21 | } 22 | 23 | display() { 24 | const { containerEl } = this; 25 | containerEl.empty(); 26 | containerEl.createEl("h2", { text: "SamePage Settings" }); 27 | defaultSettings.forEach((s) => { 28 | const setting = new Setting(containerEl) 29 | .setName(s.name) 30 | .setDesc(s.description); 31 | /* if (s.type === "boolean") { 32 | setting.addToggle((toggle) => { 33 | const saved = this.plugin.data.settings[s.id]; 34 | toggle 35 | .setValue(typeof saved !== "boolean" ? s.default : saved) 36 | .onChange((value) => { 37 | this.plugin.data.settings[s.id] = value; 38 | this.plugin.save(); 39 | }); 40 | }); 41 | } else*/ if (s.type === "string") { 42 | setting.addText((text) => { 43 | const saved = this.plugin.data.settings[s.id]; 44 | text 45 | .setValue(typeof saved !== "string" ? s.default : saved) 46 | .onChange((value) => { 47 | this.plugin.data.settings[s.id] = value; 48 | this.plugin.save(); 49 | }); 50 | }); 51 | } 52 | }); 53 | } 54 | } 55 | 56 | type Settings = { 57 | [k in DefaultSetting as k["id"]]?: k["type"] extends "boolean" 58 | ? boolean 59 | : k["type"] extends "string" 60 | ? string 61 | : never; 62 | }; 63 | 64 | type PluginData = { 65 | settings: Settings; 66 | }; 67 | 68 | type RawPluginData = { 69 | settings?: Settings; 70 | } | null; 71 | 72 | class SamePagePlugin extends Plugin { 73 | data: PluginData = { 74 | settings: {}, 75 | }; 76 | 77 | async setupUserSettings() { 78 | const { settings = {} } = ((await this.loadData()) as RawPluginData) || {}; 79 | this.data = { 80 | settings: { 81 | ...Object.fromEntries(defaultSettings.map((s) => [s.id, s.default])), 82 | ...settings, 83 | }, 84 | }; 85 | 86 | const settingTab = new SamePageSettingTab(this.app, this); 87 | this.addSettingTab(settingTab); 88 | } 89 | 90 | setupClient() { 91 | const self = this; 92 | const checkCallback: Record = {}; 93 | const { unload } = setupSamePageClient({ 94 | getSetting: (s) => this.data.settings[s] as string, 95 | setSetting: (s, v) => { 96 | // TODO - fix this typing 97 | if (defaultTypeById[s] === "string") 98 | this.data.settings[s as "uuid" | "token"] = v; 99 | /* else if (defaultTypeById[s] === "boolean") 100 | this.data.settings[s] = 101 | v === "true";*/ 102 | this.save(); 103 | }, 104 | addCommand: ({ label, callback }) => { 105 | if (label in checkCallback) checkCallback[label] = true; 106 | else { 107 | checkCallback[label] = true; 108 | self.addCommand({ 109 | id: label, 110 | name: label, 111 | checkCallback: (checking) => { 112 | if (checkCallback[label]) { 113 | if (!checking) { 114 | callback(); 115 | } 116 | return true; 117 | } 118 | return false; 119 | }, 120 | }); 121 | } 122 | }, 123 | removeCommand: ({ label }) => { 124 | if (label in checkCallback) checkCallback[label] = false; 125 | }, 126 | app: "Obsidian", 127 | workspace: this.app.vault.getName(), 128 | renderOverlay, 129 | onAppLog: (evt) => evt.intent !== "debug" && new Notice(evt.content), 130 | notificationContainerPath: `.mod-root .workspace-tabs .workspace-tab-header-container .workspace-tab-header-tab-list`, 131 | }); 132 | return unload; 133 | } 134 | 135 | setupProtocols() { 136 | const unloadSharePage = setupSharePageWithNotebook(this); 137 | const unloadNotebookQuerying = setupNotebookQuerying(this); 138 | return () => { 139 | unloadNotebookQuerying(); 140 | unloadSharePage(); 141 | }; 142 | } 143 | 144 | async onload() { 145 | await this.setupUserSettings(); 146 | const unloadSamePageClient = this.setupClient(); 147 | const unloadProtocols = this.setupProtocols(); 148 | this.onunload = () => { 149 | unloadProtocols(); 150 | unloadSamePageClient(); 151 | }; 152 | } 153 | async save() { 154 | this.saveData(this.data); 155 | } 156 | } 157 | 158 | export default SamePagePlugin; 159 | -------------------------------------------------------------------------------- /src/protocols/notebookQuerying.ts: -------------------------------------------------------------------------------- 1 | import setupNotebookQuerying from "samepage/protocols/notebookQuerying"; 2 | import { TFile } from "obsidian"; 3 | import type SamePagePlugin from "../main"; 4 | import createHTMLObserver from "samepage/utils/createHTMLObserver"; 5 | import ExternalNotebookReference from "../components/ExternalNotebookReference"; 6 | import renderOverlay from "../utils/renderOverlay"; 7 | import leafParser from "../utils/leafParser"; 8 | 9 | const setup = (plugin: SamePagePlugin) => { 10 | const { unload } = setupNotebookQuerying({ 11 | onQuery: async (notebookPageId) => { 12 | const abstractFile = plugin.app.vault.getAbstractFileByPath( 13 | `${notebookPageId}.md` 14 | ); 15 | const content = 16 | abstractFile instanceof TFile 17 | ? await plugin.app.vault.cachedRead(abstractFile) 18 | : ""; 19 | return leafParser(content); 20 | }, 21 | onQueryResponse: async ({ data, request }) => { 22 | document.body.dispatchEvent( 23 | new CustomEvent("samepage:reference:response", { 24 | detail: { 25 | request, 26 | data, 27 | }, 28 | }) 29 | ); 30 | }, 31 | }); 32 | // can't use data attributes bc codemirror removes em 33 | const listeners: { 34 | el: HTMLSpanElement; 35 | listener: (e: MouseEvent) => void; 36 | }[] = []; 37 | const observer = createHTMLObserver({ 38 | callback: (s) => { 39 | const text = s.textContent; 40 | if (text && !listeners.some((l) => l.el === s)) { 41 | const [notebookUuid, notebookPageId] = text.split(":"); 42 | if (notebookPageId) { 43 | const listener = (e: MouseEvent) => { 44 | renderOverlay({ 45 | Overlay: ExternalNotebookReference, 46 | props: { notebookPageId, notebookUuid }, 47 | }); 48 | e.preventDefault(); 49 | e.stopPropagation(); 50 | }; 51 | s.addEventListener("mousedown", listener); 52 | listeners.push({ listener, el: s }); 53 | } 54 | } 55 | }, 56 | selector: "span.cm-hmd-internal-link", 57 | onRemove: (s) => { 58 | const index = listeners.findIndex((l) => l.el === s); 59 | if (index >= 0) { 60 | s.removeEventListener("mousedown", listeners[index].listener); 61 | listeners.splice(index, 1); 62 | } 63 | }, 64 | observeClassName: true, 65 | }); 66 | return () => { 67 | observer.disconnect(); 68 | unload(); 69 | }; 70 | }; 71 | 72 | export default setup; 73 | -------------------------------------------------------------------------------- /src/protocols/sharePageWithNotebook.ts: -------------------------------------------------------------------------------- 1 | import type { SamePageSchema } from "samepage/internal/types"; 2 | import loadSharePageWithNotebook from "samepage/protocols/sharePageWithNotebook"; 3 | import type SamePagePlugin from "../main"; 4 | import { Keymap, MarkdownView, TFile } from "obsidian"; 5 | import { v4 } from "uuid"; 6 | import atJsonToObsidian from "../utils/atJsonToObsidian"; 7 | import sha256 from "crypto-js/sha256"; 8 | import { has as isShared } from "samepage/utils/localAutomergeDb"; 9 | import leafParser from "../utils/leafParser"; 10 | 11 | const hashes: Record = {}; 12 | const hashFn = (s: string) => sha256(s).toString(); 13 | 14 | const applyState = async ( 15 | notebookPageId: string, 16 | state: SamePageSchema, 17 | plugin: SamePagePlugin 18 | ) => { 19 | const expectedText = atJsonToObsidian({ 20 | content: state.content.toString(), 21 | annotations: state.annotations, 22 | }); 23 | const abstractFile = plugin.app.vault.getAbstractFileByPath( 24 | `${notebookPageId}.md` 25 | ); 26 | if (abstractFile instanceof TFile) { 27 | const hash = hashFn(expectedText); 28 | const mtime = new Date().valueOf(); 29 | hashes[mtime] = hash; 30 | return plugin.app.vault 31 | .modify(abstractFile, expectedText, { mtime }) 32 | .then(() => plugin.save()); 33 | } 34 | }; 35 | 36 | const calculateState = async ( 37 | notebookPageId: string, 38 | plugin: SamePagePlugin 39 | ) => { 40 | const abstractFile = plugin.app.vault.getAbstractFileByPath( 41 | `${notebookPageId}.md` 42 | ); 43 | const content = 44 | abstractFile instanceof TFile 45 | ? await plugin.app.vault.cachedRead(abstractFile) 46 | : ""; 47 | 48 | return leafParser(content); 49 | }; 50 | 51 | const getCurrentNotebookPageId = (plugin: SamePagePlugin) => 52 | (plugin.app.workspace.getActiveFile()?.path || "")?.replace(/\.md$/, ""); 53 | 54 | const sharedPagePaths: Record = {}; 55 | 56 | const setupSharePageWithNotebook = (plugin: SamePagePlugin) => { 57 | const { unload, refreshContent } = loadSharePageWithNotebook({ 58 | decodeState: (id, state) => applyState(id, state.$body, plugin), 59 | encodeState: (id) => 60 | calculateState(id, plugin).then(($body) => ({ $body })), 61 | getCurrentNotebookPageId: async () => getCurrentNotebookPageId(plugin), 62 | ensurePageByTitle: async (title) => { 63 | const notebookPageId = `${title.content}.md`; 64 | const exists = !!plugin.app.vault.getAbstractFileByPath(notebookPageId); 65 | if (exists) return { notebookPageId, preExisting: true }; 66 | const pathParts = title.content.split("/"); 67 | await Promise.all( 68 | pathParts.slice(0, -1).map((_, i, a) => { 69 | const path = a.slice(0, i + 1).join("/"); 70 | if (!plugin.app.vault.getAbstractFileByPath(path)) { 71 | return plugin.app.vault.createFolder(path); 72 | } 73 | }) 74 | ); 75 | await plugin.app.vault.create(notebookPageId, ""); 76 | return { notebookPageId, preExisting: false }; 77 | }, 78 | deletePage: async (title) => { 79 | const newFile = plugin.app.vault.getAbstractFileByPath(`${title}.md`); 80 | if (newFile instanceof TFile) { 81 | return plugin.app.vault.delete(newFile); 82 | } 83 | }, 84 | openPage: async (title) => { 85 | const active = plugin.app.workspace.getActiveViewOfType(MarkdownView); 86 | const newFile = plugin.app.vault.getAbstractFileByPath(`${title}.md`); 87 | if (newFile instanceof TFile) { 88 | if (active) { 89 | await active.leaf.openFile(newFile); 90 | } else { 91 | await app.workspace.openLinkText(title, title); 92 | } 93 | } 94 | return title; 95 | }, 96 | overlayProps: { 97 | viewSharedPageProps: { 98 | onLinkClick: (title, e) => { 99 | if (e.shiftKey) { 100 | app.workspace.getLeaf(Keymap.isModEvent(e)); 101 | } else { 102 | app.workspace.openLinkText(title, title); 103 | } 104 | }, 105 | linkNewPage: (_, title) => 106 | app.vault.create(title, "").then((f) => f.path.replace(/\.md$/, "")), 107 | }, 108 | sharedPageStatusProps: { 109 | getPaths: (notebookPageId) => { 110 | const leaf = plugin.app.workspace.getActiveViewOfType(MarkdownView); 111 | if (leaf && leaf.file.path.replace(/\.md/, "") === notebookPageId) { 112 | const sel = v4(); 113 | leaf.containerEl.setAttribute("data-samepage-shared", sel); 114 | sharedPagePaths[sel] = notebookPageId; 115 | return [`div[data-samepage-shared="${sel}"] .cm-contentContainer`]; 116 | } 117 | return []; 118 | }, 119 | observer({ onload, onunload }) { 120 | const ref = plugin.app.workspace.on("active-leaf-change", (leaf) => { 121 | if (!leaf) return; 122 | const { view } = leaf; 123 | if (!(view instanceof MarkdownView)) return; 124 | const notebookPageId = view.file.path.replace(/\.md$/, ""); 125 | const sel = view.containerEl.getAttribute("data-samepage-shared"); 126 | if (sel) { 127 | const existingNotebookPageId = sharedPagePaths[sel]; 128 | if (existingNotebookPageId === notebookPageId) return; 129 | if (existingNotebookPageId) { 130 | view.containerEl.removeAttribute("data-samepage-shared"); 131 | onunload(existingNotebookPageId); 132 | } 133 | } 134 | onload(notebookPageId); 135 | }); 136 | return () => plugin.app.workspace.offref(ref); 137 | }, 138 | }, 139 | }, 140 | onConnect: () => { 141 | plugin.registerEvent( 142 | plugin.app.vault.on("modify", async (file) => { 143 | const notebookPageId = file.path.replace(/\.md$/, ""); 144 | if (file instanceof TFile && (await isShared(notebookPageId))) { 145 | if ( 146 | hashes[file.stat.mtime] === 147 | hashFn(await plugin.app.vault.cachedRead(file)) 148 | ) { 149 | delete hashes[file.stat.mtime]; 150 | return; 151 | } 152 | refreshContent({ notebookPageId }); 153 | } 154 | }) 155 | ); 156 | return () => {}; 157 | }, 158 | }); 159 | return unload; 160 | }; 161 | 162 | export default setupSharePageWithNotebook; 163 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import "samepage/samepage.css"; 2 | 3 | body { 4 | font-size: 16px; 5 | color: var(--text-normal); 6 | } 7 | a.page-title:hover { 8 | text-decoration: none; 9 | } 10 | div.view-header { 11 | height: fit-content; 12 | } 13 | span.samepage-shared-page-status { 14 | margin-bottom: 0; 15 | background: var(--background-secondary); 16 | } 17 | .samepage-notification-container h4 { 18 | margin: 4px 0; 19 | } 20 | .samepage-notification-container h5 { 21 | margin: 8px 0; 22 | } 23 | .samepage-shared-page-status img { 24 | margin: 0; 25 | } 26 | #samepage-notification-container { 27 | z-index: 1; 28 | display: flex; 29 | flex-direction: column; 30 | justify-content: center; 31 | } 32 | .samepage-notification-container .border-b { 33 | border-top-width: 0; 34 | border-left-width: 0; 35 | border-right-width: 0; 36 | } 37 | .samepage-notification-container { 38 | height: 24px; 39 | } 40 | -------------------------------------------------------------------------------- /src/utils/atJsonToObsidian.ts: -------------------------------------------------------------------------------- 1 | import { getSetting } from "samepage/internal/registry"; 2 | import type { InitialSchema } from "samepage/internal/types"; 3 | import renderAtJson from "samepage/utils/renderAtJson"; 4 | 5 | const atJsonToObsidian = (state: InitialSchema) => { 6 | const firstBlockIndex = state.annotations.findIndex( 7 | (a) => a.type === "block" 8 | ); 9 | return renderAtJson({ 10 | state: { 11 | annotations: state.annotations, 12 | content: state.content.toString(), 13 | }, 14 | applyAnnotation: { 15 | bold: ({ content, attributes }) => { 16 | const validDelimiters = new Set(["**", "__"]); 17 | const delimiter = attributes?.delimiter || "**"; 18 | const prefix = validDelimiters.has(delimiter) ? delimiter : "**"; 19 | return { 20 | prefix, 21 | suffix: attributes?.open ? "" : prefix, 22 | replace: content === String.fromCharCode(0), 23 | }; 24 | }, 25 | italics: ({ content, attributes }) => { 26 | const validDelimiters = new Set(["*", "_"]); 27 | const delimiter = attributes?.delimiter || "**"; 28 | const prefix = validDelimiters.has(delimiter) ? delimiter : "**"; 29 | return { 30 | prefix, 31 | suffix: attributes?.open ? "" : prefix, 32 | replace: content === String.fromCharCode(0), 33 | }; 34 | }, 35 | strikethrough: ({ content, attributes }) => ({ 36 | prefix: "~~", 37 | suffix: attributes?.open ? "" : `~~`, 38 | replace: content === String.fromCharCode(0), 39 | }), 40 | link: ({ attributes: { href }, content }) => ({ 41 | prefix: "[", 42 | suffix: `](${href})`, 43 | replace: content === String.fromCharCode(0), 44 | }), 45 | image: ({ attributes: { src }, content }) => ({ 46 | prefix: "![", 47 | suffix: `](${src})`, 48 | replace: content === String.fromCharCode(0), 49 | }), 50 | block: ({ 51 | attributes: { level, viewType }, 52 | content, 53 | index, 54 | appAttributes: { spacing } = {}, 55 | }) => { 56 | const firstBlock = firstBlockIndex === index; 57 | return { 58 | suffix: content.replace(/\n$/, ""), 59 | prefix: `${firstBlock ? "" : "\n"}${ 60 | firstBlock || viewType !== "document" ? "" : "\n" 61 | }${level === 1 ? "" : spacing || "".padStart(level - 1, "\t")}${ 62 | viewType === "bullet" ? "- " : viewType === "numbered" ? "1. " : "" 63 | }`, 64 | replace: true, 65 | }; 66 | }, 67 | reference: ({ 68 | attributes: { notebookPageId, notebookUuid }, 69 | content, 70 | }) => ({ 71 | prefix: "[[", 72 | suffix: `${ 73 | notebookUuid === getSetting("uuid") 74 | ? notebookPageId 75 | : `${notebookUuid}:${notebookPageId}` 76 | }]]`, 77 | replace: content === String.fromCharCode(0), 78 | }), 79 | code: ({ attributes: { language, ticks = 3 } }) => { 80 | const ending = Array(ticks).fill("`").join(""); 81 | return { 82 | prefix: `${ending}${language}\n`, 83 | suffix: ending, 84 | }; 85 | }, 86 | }, 87 | }); 88 | }; 89 | 90 | export default atJsonToObsidian; 91 | -------------------------------------------------------------------------------- /src/utils/leafParser.ts: -------------------------------------------------------------------------------- 1 | import moo from "moo"; 2 | import type { Annotation, InitialSchema } from "samepage/internal/types"; 3 | import { getSetting } from "samepage/internal/registry"; 4 | import atJsonParser, { 5 | combineAtJsons, 6 | createEmptyAtJson, 7 | createTextAtJson, 8 | head, 9 | URL_REGEX, 10 | NULL_TOKEN, 11 | } from "samepage/utils/atJsonParser"; 12 | 13 | type Rule = Parameters[0]["grammarRules"][number]; 14 | 15 | const getLevel = (t?: moo.Token) => { 16 | if (!t) return 1; 17 | return t.text.split(/\t| /).length; 18 | }; 19 | 20 | const baseRules: Rule[] = [ 21 | { 22 | name: "initialParagraph", 23 | symbols: [], 24 | postprocess: () => ({ 25 | content: "\n", 26 | annotations: [ 27 | { 28 | type: "block", 29 | start: 0, 30 | end: 1, 31 | attributes: { 32 | level: 1, 33 | viewType: "document", 34 | }, 35 | }, 36 | ], 37 | }), 38 | }, 39 | { 40 | name: "initialParagraph", 41 | symbols: ["initialParagraph", { type: "tab" }], 42 | postprocess: (d, _, reject) => { 43 | const [atJson] = d as [InitialSchema, moo.Token]; 44 | const [block] = atJson.annotations; 45 | if (block.type !== "block") return reject; 46 | block.attributes.level++; 47 | return atJson; 48 | }, 49 | }, 50 | { 51 | name: "firstBlock", 52 | symbols: ["initialParagraph", "block"], 53 | postprocess: (d) => { 54 | const [initialAtJson, contentAtJson] = d as [ 55 | InitialSchema, 56 | InitialSchema 57 | ]; 58 | return { 59 | content: `${contentAtJson.content}${initialAtJson.content}`, 60 | annotations: initialAtJson.annotations 61 | .map((a) => ({ 62 | ...a, 63 | end: a.end + contentAtJson.content.length, 64 | })) 65 | .concat(contentAtJson.annotations), 66 | }; 67 | }, 68 | }, 69 | { 70 | name: "firstBlock", 71 | symbols: [{ type: "initialBullet" }, "block"], 72 | postprocess: (d) => { 73 | const [initialToken, contentAtJson] = d as [moo.Token, InitialSchema]; 74 | return { 75 | content: `${contentAtJson.content}\n`, 76 | annotations: ( 77 | [ 78 | { 79 | type: "block", 80 | start: 0, 81 | end: contentAtJson.content.length + 1, 82 | attributes: { 83 | level: getLevel(initialToken), 84 | viewType: "bullet", 85 | }, 86 | }, 87 | ] as InitialSchema["annotations"] 88 | ).concat(contentAtJson.annotations), 89 | }; 90 | }, 91 | }, 92 | { 93 | name: "firstBlock", 94 | symbols: [{ type: "initialNumbered" }, "block"], 95 | postprocess: (d) => { 96 | const [initialToken, contentAtJson] = d as [moo.Token, InitialSchema]; 97 | return { 98 | content: `${contentAtJson.content}\n`, 99 | annotations: ( 100 | [ 101 | { 102 | type: "block", 103 | start: 0, 104 | end: contentAtJson.content.length + 1, 105 | attributes: { 106 | level: getLevel(initialToken), 107 | viewType: "numbered", 108 | }, 109 | }, 110 | ] as InitialSchema["annotations"] 111 | ).concat(contentAtJson.annotations), 112 | }; 113 | }, 114 | }, 115 | { name: "additionalBlocks", symbols: [], postprocess: createEmptyAtJson }, 116 | { 117 | name: "additionalBlockType", 118 | symbols: [{ type: "paragraph" }, "block"], 119 | postprocess: (d) => { 120 | const [initialToken, contentAtJson] = d as [moo.Token, InitialSchema]; 121 | const level = getLevel(initialToken); 122 | return { 123 | content: `${contentAtJson.content}\n`, 124 | annotations: ( 125 | [ 126 | { 127 | type: "block", 128 | start: 0, 129 | end: contentAtJson.content.length + 1, 130 | attributes: { 131 | level, 132 | viewType: "document", 133 | }, 134 | ...(level > 1 135 | ? { 136 | appAttributes: { 137 | obsidian: { 138 | spacing: initialToken.text.replace(/^\n\n/s, ""), 139 | }, 140 | }, 141 | } 142 | : undefined), 143 | }, 144 | ] as InitialSchema["annotations"] 145 | ).concat(contentAtJson.annotations), 146 | }; 147 | }, 148 | }, 149 | { 150 | name: "additionalBlockType", 151 | symbols: [{ type: "bullet" }, "block"], 152 | postprocess: (d) => { 153 | const [initialToken, contentAtJson] = d as [moo.Token, InitialSchema]; 154 | const level = getLevel(initialToken); 155 | return { 156 | content: `${contentAtJson.content}\n`, 157 | annotations: ( 158 | [ 159 | { 160 | type: "block", 161 | start: 0, 162 | end: contentAtJson.content.length + 1, 163 | attributes: { 164 | level, 165 | viewType: "bullet", 166 | }, 167 | ...(level > 1 168 | ? { 169 | appAttributes: { 170 | obsidian: { 171 | spacing: initialToken.text 172 | .replace(/^\n/s, "") 173 | .replace(/- $/, ""), 174 | }, 175 | }, 176 | } 177 | : undefined), 178 | }, 179 | ] as InitialSchema["annotations"] 180 | ).concat(contentAtJson.annotations), 181 | }; 182 | }, 183 | }, 184 | { 185 | name: "additionalBlockType", 186 | symbols: [{ type: "numbered" }, "block"], 187 | postprocess: (d) => { 188 | const [initialToken, contentAtJson] = d as [moo.Token, InitialSchema]; 189 | const level = getLevel(initialToken); 190 | return { 191 | content: `${contentAtJson.content}\n`, 192 | annotations: ( 193 | [ 194 | { 195 | type: "block", 196 | start: 0, 197 | end: contentAtJson.content.length + 1, 198 | attributes: { 199 | level, 200 | viewType: "numbered", 201 | }, 202 | ...(level > 1 203 | ? { 204 | appAttributes: { 205 | obsidian: { 206 | spacing: initialToken.text 207 | .replace(/^\n/s, "") 208 | .replace(/\d+\. $/, ""), 209 | }, 210 | }, 211 | } 212 | : undefined), 213 | }, 214 | ] as InitialSchema["annotations"] 215 | ).concat(contentAtJson.annotations), 216 | }; 217 | }, 218 | }, 219 | { 220 | name: "additionalBlocks", 221 | symbols: ["additionalBlockType", "additionalBlocks"], 222 | postprocess: combineAtJsons, 223 | }, 224 | { 225 | name: "main", 226 | symbols: ["firstBlock", "additionalBlocks"], 227 | postprocess: combineAtJsons, 228 | }, 229 | { name: "block", symbols: [], postprocess: createEmptyAtJson }, 230 | { name: "block", symbols: ["blockElements"], postprocess: head }, 231 | { 232 | name: "block", 233 | symbols: ["blockElements", "lastElement"], 234 | postprocess: combineAtJsons, 235 | }, 236 | { 237 | name: "block", 238 | symbols: ["lastElement"], 239 | postprocess: head, 240 | }, 241 | 242 | { name: "blockElements", symbols: ["blockElement"], postprocess: head }, 243 | { 244 | name: "blockElements", 245 | symbols: ["blockElement", "blockElements"], 246 | postprocess: combineAtJsons, 247 | }, 248 | 249 | { 250 | name: "blockElement", 251 | symbols: [{ type: "openUnder" }, "noCloseUnders", { type: "closeUnder" }], 252 | postprocess: (d) => { 253 | const [_, first] = d as [moo.Token, InitialSchema, moo.Token]; 254 | return { 255 | content: first.content, 256 | annotations: ( 257 | [ 258 | { 259 | type: "italics", 260 | start: 0, 261 | end: first.content.length, 262 | attributes: { 263 | delimiter: "_", 264 | }, 265 | }, 266 | ] as InitialSchema["annotations"] 267 | ).concat(first.annotations), 268 | }; 269 | }, 270 | }, 271 | { 272 | name: "lastElement", 273 | symbols: [{ type: "openUnder" }, "noCloseUnders"], 274 | postprocess: (d) => { 275 | const [_, first] = d as [moo.Token, InitialSchema]; 276 | return { 277 | content: first.content, 278 | annotations: ( 279 | [ 280 | { 281 | type: "italics", 282 | start: 0, 283 | end: first.content.length, 284 | attributes: { 285 | delimiter: "_", 286 | open: true, 287 | }, 288 | }, 289 | ] as InitialSchema["annotations"] 290 | ).concat(first.annotations), 291 | }; 292 | }, 293 | }, 294 | { 295 | name: "noCloseUnders", 296 | symbols: ["noCloseUnder", "noCloseUnders"], 297 | postprocess: combineAtJsons, 298 | }, 299 | { 300 | name: "noCloseUnders", 301 | symbols: ["noCloseUnder"], 302 | postprocess: head, 303 | }, 304 | { 305 | name: "blockElement", 306 | symbols: [{ type: "closeUnder" }], 307 | postprocess: createTextAtJson, 308 | }, 309 | 310 | { 311 | name: "blockElement", 312 | symbols: [{ type: "star" }, "noStars", { type: "star" }], 313 | postprocess: (d) => { 314 | const [_, first] = d as [moo.Token, InitialSchema, moo.Token]; 315 | return { 316 | content: first.content, 317 | annotations: ( 318 | [ 319 | { 320 | type: "italics", 321 | start: 0, 322 | end: first.content.length, 323 | attributes: { 324 | delimiter: "*", 325 | }, 326 | }, 327 | ] as InitialSchema["annotations"] 328 | ).concat(first.annotations), 329 | }; 330 | }, 331 | }, 332 | { 333 | name: "noStars", 334 | symbols: ["noStar", "noStars"], 335 | postprocess: combineAtJsons, 336 | }, 337 | { 338 | name: "noStars", 339 | symbols: ["noStar"], 340 | postprocess: head, 341 | }, 342 | { 343 | name: "lastElement", 344 | symbols: [{ type: "star" }, "noStars"], 345 | postprocess: (data) => { 346 | const [_, first] = data as [moo.Token, InitialSchema]; 347 | return { 348 | content: first.content, 349 | annotations: ( 350 | [ 351 | { 352 | type: "italics", 353 | start: 0, 354 | end: first.content.length, 355 | attributes: { 356 | delimiter: "*", 357 | open: true, 358 | }, 359 | }, 360 | ] as InitialSchema["annotations"] 361 | ).concat(first.annotations), 362 | }; 363 | }, 364 | }, 365 | { 366 | name: "lastElement", 367 | symbols: [{ type: "star" }], 368 | postprocess: createTextAtJson, 369 | }, 370 | 371 | { 372 | name: "blockElement", 373 | symbols: [{ type: "strike" }, "noStrikes", { type: "strike" }], 374 | postprocess: (d, _, reject) => { 375 | const data = d as [moo.Token, InitialSchema, moo.Token]; 376 | const { content, annotations } = data[1]; 377 | if (annotations.some((a) => a.type === "strikethrough")) { 378 | return reject; 379 | } 380 | return { 381 | content, 382 | annotations: [ 383 | { 384 | type: "strikethrough", 385 | start: 0, 386 | end: content.length, 387 | attributes: { 388 | delimiter: "~~", 389 | }, 390 | } as Annotation, 391 | ].concat(annotations), 392 | }; 393 | }, 394 | }, 395 | { 396 | name: "noStrikes", 397 | symbols: ["noStrike", "noStrikes"], 398 | postprocess: combineAtJsons, 399 | }, 400 | { 401 | name: "noStrikes", 402 | symbols: ["noStrike"], 403 | postprocess: head, 404 | }, 405 | { 406 | name: "lastElement", 407 | symbols: [{ type: "strike" }, "noStrikes"], 408 | postprocess: (data) => { 409 | const [_, first] = data as [moo.Token, InitialSchema]; 410 | return { 411 | content: first.content, 412 | annotations: ( 413 | [ 414 | { 415 | type: "strikethrough", 416 | start: 0, 417 | end: first.content.length, 418 | attributes: { 419 | delimiter: "~~", 420 | open: true, 421 | }, 422 | }, 423 | ] as InitialSchema["annotations"] 424 | ).concat(first.annotations), 425 | }; 426 | }, 427 | }, 428 | { 429 | name: "lastElement", 430 | symbols: [{ type: "strike" }], 431 | postprocess: createTextAtJson, 432 | }, 433 | 434 | { 435 | name: "blockElement", 436 | symbols: [{ type: "boldUnder" }, "noBoldUnders", { type: "boldUnder" }], 437 | postprocess: (d) => { 438 | const [_, first] = d as [moo.Token, InitialSchema, InitialSchema]; 439 | return { 440 | content: first.content, 441 | annotations: ( 442 | [ 443 | { 444 | type: "bold", 445 | start: 0, 446 | end: first.content.length, 447 | attributes: { 448 | delimiter: "__", 449 | }, 450 | }, 451 | ] as InitialSchema["annotations"] 452 | ).concat(first.annotations), 453 | }; 454 | }, 455 | }, 456 | { 457 | name: "noBoldUnders", 458 | symbols: ["noBoldUnder", "noBoldUnders"], 459 | postprocess: combineAtJsons, 460 | }, 461 | { 462 | name: "noBoldUnders", 463 | symbols: ["noBoldUnder"], 464 | postprocess: head, 465 | }, 466 | { 467 | name: "noBoldUnders", 468 | symbols: ["noBoldUnder", "noBoldUnderLast"], 469 | postprocess: combineAtJsons, 470 | }, 471 | { 472 | name: "lastElement", 473 | symbols: [{ type: "boldUnder" }, "noBoldUnders"], 474 | postprocess: (data, __, reject) => { 475 | const [_, first] = data as [moo.Token, InitialSchema]; 476 | if (first.content.includes("__")) return reject; 477 | return { 478 | content: first.content, 479 | annotations: ( 480 | [ 481 | { 482 | type: "bold", 483 | start: 0, 484 | end: first.content.length, 485 | attributes: { 486 | delimiter: "__", 487 | open: true, 488 | }, 489 | }, 490 | ] as InitialSchema["annotations"] 491 | ).concat(first.annotations), 492 | }; 493 | }, 494 | }, 495 | { 496 | name: "lastElement", 497 | symbols: [{ type: "boldUnder" }], 498 | postprocess: createTextAtJson, 499 | }, 500 | 501 | { 502 | name: "blockElement", 503 | symbols: [{ type: "boldStar" }, "noBoldStars", { type: "boldStar" }], 504 | postprocess: (d) => { 505 | const [_, first] = d as [moo.Token, InitialSchema, InitialSchema]; 506 | return { 507 | content: first.content, 508 | annotations: ( 509 | [ 510 | { 511 | type: "bold", 512 | start: 0, 513 | end: first.content.length, 514 | attributes: { 515 | delimiter: "**", 516 | }, 517 | }, 518 | ] as InitialSchema["annotations"] 519 | ).concat(first.annotations), 520 | }; 521 | }, 522 | }, 523 | { 524 | name: "noBoldStars", 525 | symbols: ["noBoldStar", "noBoldStars"], 526 | postprocess: combineAtJsons, 527 | }, 528 | { 529 | name: "noBoldStars", 530 | symbols: ["noBoldStar"], 531 | postprocess: head, 532 | }, 533 | { 534 | name: "noBoldStars", 535 | symbols: ["noBoldStar", "noBoldStarLast"], 536 | postprocess: combineAtJsons, 537 | }, 538 | { 539 | name: "lastElement", 540 | symbols: [{ type: "boldStar" }, "noBoldStars"], 541 | postprocess: (data, __, reject) => { 542 | const [_, first] = data as [moo.Token, InitialSchema]; 543 | if (first.content.includes("**")) return reject; 544 | return { 545 | content: first.content, 546 | annotations: ( 547 | [ 548 | { 549 | type: "bold", 550 | start: 0, 551 | end: first.content.length, 552 | attributes: { 553 | delimiter: "**", 554 | open: true, 555 | }, 556 | }, 557 | ] as InitialSchema["annotations"] 558 | ).concat(first.annotations), 559 | }; 560 | }, 561 | }, 562 | { 563 | name: "lastElement", 564 | symbols: [{ type: "boldStar" }], 565 | postprocess: createTextAtJson, 566 | }, 567 | // CONTINUE HERE 568 | 569 | { 570 | name: "blockElement", 571 | symbols: [{ type: "alias" }], 572 | postprocess: (data) => { 573 | const [{ value }] = data as [moo.Token]; 574 | const arr = /\[([^\]]*)\]\(([^\)]*)\)/.exec(value); 575 | if (!arr) { 576 | return { 577 | content: "", 578 | annotations: [], 579 | }; 580 | } 581 | const [_, _content, href] = arr; 582 | const content = _content || NULL_TOKEN; 583 | return { 584 | content, 585 | annotations: [ 586 | { 587 | start: 0, 588 | end: content.length, 589 | type: "link", 590 | attributes: { 591 | href, 592 | }, 593 | }, 594 | ], 595 | }; 596 | }, 597 | }, 598 | { 599 | name: "blockElement", 600 | symbols: [{ type: "asset" }], 601 | postprocess: (data) => { 602 | const [{ value }] = data as [moo.Token]; 603 | const arr = /!\[([^\]]*)\]\(([^\)]*)\)/.exec(value); 604 | if (!arr) { 605 | return { 606 | content: "", 607 | annotations: [], 608 | }; 609 | } 610 | const [_, _content, src] = arr; 611 | const content = _content || NULL_TOKEN; 612 | return { 613 | content, 614 | annotations: [ 615 | { 616 | start: 0, 617 | end: content.length, 618 | type: "image", 619 | attributes: { 620 | src, 621 | }, 622 | }, 623 | ], 624 | }; 625 | }, 626 | }, 627 | { 628 | name: "blockElement", 629 | symbols: [{ type: "codeBlock" }], 630 | postprocess: (data) => { 631 | const { value } = (data as [moo.Token])[0]; 632 | const match = /^(`{3,})([\w -]*)\n/.exec(value); 633 | const ticks = 634 | match?.[1]?.length && match?.[1]?.length > 3 635 | ? match?.[1]?.length 636 | : undefined; 637 | const language = match?.[2] || ""; 638 | const content = value 639 | .replace(/^`{3,}[\w -]*\n/, "") 640 | .replace(/`{3,}$/, ""); 641 | return { 642 | content, 643 | annotations: [ 644 | { 645 | start: 0, 646 | end: content.length, 647 | type: "code", 648 | attributes: { 649 | language, 650 | ticks, 651 | }, 652 | }, 653 | ], 654 | }; 655 | }, 656 | }, 657 | { 658 | name: "blockElement", 659 | symbols: [{ type: "reference" }], 660 | postprocess: (_data) => { 661 | const [token] = _data as [moo.Token]; 662 | const value = token.value.slice(2, -2); 663 | const parsedNotebookUuid = value.match( 664 | /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}:/ 665 | )?.[0]; 666 | const notebookUuid = parsedNotebookUuid 667 | ? parsedNotebookUuid.slice(0, -1) 668 | : getSetting("uuid"); 669 | const notebookPageId = parsedNotebookUuid 670 | ? value.slice(parsedNotebookUuid.length) 671 | : value; 672 | return { 673 | content: NULL_TOKEN, 674 | annotations: [ 675 | { 676 | type: "reference", 677 | start: 0, 678 | end: 1, 679 | attributes: { 680 | notebookPageId, 681 | notebookUuid, 682 | }, 683 | }, 684 | ], 685 | }; 686 | }, 687 | }, 688 | { 689 | name: "blockElement", 690 | symbols: [{ type: "text" }], 691 | postprocess: createTextAtJson, 692 | }, 693 | { 694 | name: "blockElement", 695 | symbols: [{ type: "carot" }], 696 | postprocess: createTextAtJson, 697 | }, 698 | { 699 | name: "blockElement", 700 | symbols: [{ type: "tilde" }], 701 | postprocess: createTextAtJson, 702 | }, 703 | { 704 | name: "blockElement", 705 | symbols: [{ type: "under" }], 706 | postprocess: createTextAtJson, 707 | }, 708 | { 709 | name: "blockElement", 710 | symbols: [{ type: "leftParen" }], 711 | postprocess: createTextAtJson, 712 | }, 713 | { 714 | name: "blockElement", 715 | symbols: [{ type: "leftBracket" }], 716 | postprocess: createTextAtJson, 717 | }, 718 | { 719 | name: "blockElement", 720 | symbols: [{ type: "rightParen" }], 721 | postprocess: createTextAtJson, 722 | }, 723 | { 724 | name: "blockElement", 725 | symbols: [{ type: "rightBracket" }], 726 | postprocess: createTextAtJson, 727 | }, 728 | { 729 | name: "blockElement", 730 | symbols: [{ type: "exclamationMark" }], 731 | postprocess: createTextAtJson, 732 | }, 733 | { 734 | name: "blockElement", 735 | symbols: [ 736 | { type: "leftBracket" }, 737 | { type: "rightBracket" }, 738 | { type: "leftParen" }, 739 | { type: "url" }, 740 | { type: "rightParen" }, 741 | ], 742 | postprocess: createTextAtJson, 743 | }, 744 | { 745 | name: "blockElement", 746 | symbols: [{ type: "url" }], 747 | postprocess: createTextAtJson, 748 | }, 749 | { 750 | name: "blockElement", 751 | symbols: [{ type: "tab" }], 752 | postprocess: createTextAtJson, 753 | }, 754 | { 755 | name: "blockElement", 756 | symbols: [{ type: "newLine" }], 757 | postprocess: createTextAtJson, 758 | }, 759 | ]; 760 | 761 | const noCloseUnderRules = baseRules 762 | .filter((b) => { 763 | const [symbol] = b.symbols; 764 | return ( 765 | (b.name === "blockElement" || b.name === "lastElement") && 766 | (typeof symbol !== "object" || 767 | (symbol.type !== "closeUnder" && symbol.type !== "openUnder")) 768 | ); 769 | }) 770 | .map((r) => ({ ...r, name: "noCloseUnder" })); 771 | const noStarRules = baseRules 772 | .filter((b) => { 773 | const [symbol] = b.symbols; 774 | return ( 775 | (b.name === "blockElement" || b.name === "lastElement") && 776 | (typeof symbol !== "object" || symbol.type !== "star") 777 | ); 778 | }) 779 | .map((r) => ({ ...r, name: "noStar" })); 780 | const noStrikeRules = baseRules 781 | .filter((b) => { 782 | const [symbol] = b.symbols; 783 | return ( 784 | (b.name === "blockElement" || b.name === "lastElement") && 785 | (typeof symbol !== "object" || symbol.type !== "strike") 786 | ); 787 | }) 788 | .map((r) => ({ ...r, name: "noStrike" })); 789 | const noBoldUnderRules = baseRules 790 | .filter((b) => { 791 | const [symbol] = b.symbols; 792 | return ( 793 | (b.name === "blockElement" || b.name === "lastElement") && 794 | (typeof symbol !== "object" || symbol.type !== "boldUnder") 795 | ); 796 | }) 797 | .map((r) => ({ 798 | ...r, 799 | name: r.name === "blockElement" ? "noBoldUnder" : "noBoldUnderLast", 800 | })); 801 | const noBoldStarRules = baseRules 802 | .filter((b) => { 803 | const [symbol] = b.symbols; 804 | return ( 805 | (b.name === "blockElement" || b.name === "lastElement") && 806 | (typeof symbol !== "object" || symbol.type !== "boldStar") 807 | ); 808 | }) 809 | .map((r) => ({ 810 | ...r, 811 | name: r.name === "blockElement" ? "noBoldStar" : "noBoldStarLast", 812 | })); 813 | const grammarRules = baseRules 814 | .concat(noStrikeRules) 815 | .concat(noStarRules) 816 | .concat(noBoldUnderRules) 817 | .concat(noBoldStarRules) 818 | .concat(noCloseUnderRules); 819 | 820 | const leafParser = atJsonParser({ 821 | lexerRules: { 822 | alias: /\[[^\]]*\]\([^\)]*\)/, 823 | asset: /!\[[^\]]*\]\([^\)]*\)/, 824 | url: URL_REGEX, 825 | reference: /\[\[[^\]]+\]\]/, 826 | initialBullet: { match: /^(?:\t| )*- /, lineBreaks: true }, 827 | initialNumbered: { match: /^(?:\t| )*\d+\. /, lineBreaks: true }, 828 | bullet: { match: /\n(?:\t| )*- /, lineBreaks: true }, 829 | numbered: { match: /\n(?:\t| )*\d+\. /, lineBreaks: true }, 830 | codeBlock: { 831 | match: /`{3,}[\w -]*\n(?:[^`]|`(?!``)|``(?!`))*`{3,}/, 832 | lineBreaks: true, 833 | }, 834 | tab: { match: /(?:\t| )/ }, 835 | text: { match: /(?:[^~_*[\]\n\t!()`]|`(?!``)|``(?!`))+/, lineBreaks: true }, 836 | paragraph: { match: /\n\n\t*(?!- |\d+\.)/, lineBreaks: true }, 837 | newLine: { match: /\n/, lineBreaks: true }, 838 | strike: "~~", 839 | boldUnder: "__", 840 | boldStar: "**", 841 | openUnder: /(?:(?<=\s)|^)_(?!\s)/, 842 | closeUnder: /(? React.createElement("div", props), 9 | props = {}, 10 | path, 11 | } = {}) => { 12 | const parent = document.createElement("div"); 13 | parent.id = id.replace(/^\d*/, ""); 14 | let onClose: () => void; 15 | const finishRendering = (i = 0) => { 16 | const pathElement = 17 | typeof path === "undefined" 18 | ? document.body.lastElementChild 19 | : typeof path === "string" 20 | ? document.querySelector(path) 21 | : path; 22 | if ( 23 | pathElement && 24 | pathElement.parentElement && 25 | !pathElement.parentElement.querySelector(`#${parent.id}`) 26 | ) { 27 | pathElement.parentElement.insertBefore(parent, pathElement); 28 | const root = createRoot(parent); 29 | onClose = () => { 30 | root.unmount(); 31 | parent.remove(); 32 | }; 33 | root.render( 34 | //@ts-ignore what is happening here... 35 | React.createElement(Overlay, { 36 | ...props, 37 | onClose, 38 | isOpen: true, 39 | }) 40 | ); 41 | } else if (i < 100) { 42 | setTimeout(() => finishRendering(i + 1), 100); 43 | } 44 | }; 45 | finishRendering(); 46 | 47 | return () => { 48 | onClose?.(); 49 | }; 50 | }; 51 | 52 | export default renderOverlay; 53 | -------------------------------------------------------------------------------- /tests/config-todo.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import esbuild from "esbuild"; 3 | 4 | export const setup = async () => { 5 | const packageJson = JSON.parse(fs.readFileSync(".json").toString()); 6 | packageJson.main = packageJson.main || "index.js"; 7 | fs.writeFileSync( 8 | "node_modules/obsidian/package.json", 9 | JSON.stringify(packageJson, null, 2) 10 | ); 11 | 12 | await esbuild.build({ 13 | entryPoints: ["./tests/mockObsidianEnvironment.ts"], 14 | outfile: `node_modules/obsidian/${packageJson.main}`, 15 | bundle: true, 16 | platform: "node", 17 | external: ["./node_modules/jsdom/*"], 18 | allowOverwrite: true, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /tests/extension.test.ts: -------------------------------------------------------------------------------- 1 | import { _electron, test, expect } from "@playwright/test"; 2 | import { PluginManifest } from "obsidian"; 3 | // import SamePagePlugin from "../src/main"; 4 | 5 | test.beforeAll(() => { 6 | // mock obsidian environment 7 | }); 8 | 9 | test.skip('"End to end" obsidian test', async ({ page }) => { 10 | // TODO - need to do more research on how to test against a built electron app 11 | // 12 | // await page.goto("app://obsidian.md/index.html"); 13 | // expect(await page.title()).toEqual( 14 | // "New tab - obsidian-vargas - Obsidian v1.0.3" 15 | // ); 16 | // const app = await _electron.launch({ 17 | // executablePath: "/usr/bin/open", 18 | // args: ["/Applications/Obsidian.app"], 19 | // }); 20 | // const window = await app.firstWindow(); 21 | // // Print the title. 22 | // console.log(await window.title()); 23 | // await window.screenshot({ path: "intro.png" }); 24 | const manifest: PluginManifest = { 25 | id: "samepage", 26 | author: "SamePage", 27 | name: "SamePage", 28 | version: "test", 29 | minAppVersion: "1.0", 30 | description: "testing", 31 | }; 32 | expect(manifest).toBeTruthy(); 33 | // const plugin = new SamePagePlugin(app, manifest); 34 | // expect(plugin).toBeTruthy(); 35 | }); 36 | -------------------------------------------------------------------------------- /tests/leafParser.test.ts: -------------------------------------------------------------------------------- 1 | import atJsonToObsidian from "../src/utils/atJsonToObsidian"; 2 | import leafParser from "../src/utils/leafParser"; 3 | import type { InitialSchema } from "samepage/internal/types"; 4 | import { test, expect } from "@playwright/test"; 5 | import { v4 } from "uuid"; 6 | import setupRegistry from "samepage/internal/registry"; 7 | 8 | const notebookUuid = v4(); 9 | // @ts-ignore 10 | global.localStorage = { 11 | getItem: () => JSON.stringify({ uuid: notebookUuid }), 12 | }; 13 | 14 | const runTest = 15 | ( 16 | md: string, 17 | expected: InitialSchema, 18 | opts: { debug?: true; skipInverse?: true } = {} 19 | ) => 20 | () => { 21 | const output = leafParser(md, opts); 22 | expect(output).toBeTruthy(); 23 | expect(output).toEqual(expected); 24 | if (!opts.skipInverse) expect(atJsonToObsidian(output)).toEqual(md); 25 | }; 26 | 27 | test.beforeAll(() => 28 | setupRegistry({ app: "obsidian", getSetting: () => notebookUuid }) 29 | ); 30 | 31 | test( 32 | "Hello World Example", 33 | runTest( 34 | `Some text to start 35 | 36 | 37 | 38 | Text after newline {{no component support}} word 39 | 40 | \tA block quote - handle later 41 | 42 | Text after blockquote 43 | 44 | Here's some **bold** an *italics* a ~~strike~~ a ^^highlight^^. No highlighting! 45 | - First bullet 46 | - Second bullet 47 | Some regular text to end.`, 48 | { 49 | content: `Some text to start\n\nText after newline {{no component support}} word\nA block quote - handle later\nText after blockquote\nHere's some bold an italics a strike a ^^highlight^^. No highlighting!\nFirst bullet\nSecond bullet\nSome regular text to end.\n`, 50 | annotations: [ 51 | { 52 | type: "block", 53 | start: 0, 54 | end: 19, 55 | attributes: { level: 1, viewType: "document" }, 56 | }, 57 | { 58 | attributes: { 59 | level: 1, 60 | viewType: "document", 61 | }, 62 | end: 20, 63 | start: 19, 64 | type: "block", 65 | }, 66 | { 67 | attributes: { 68 | level: 1, 69 | viewType: "document", 70 | }, 71 | end: 69, 72 | start: 20, 73 | type: "block", 74 | }, 75 | { 76 | attributes: { 77 | level: 2, 78 | viewType: "document", 79 | }, 80 | end: 98, 81 | start: 69, 82 | type: "block", 83 | appAttributes: { 84 | obsidian: { 85 | spacing: "\t", 86 | }, 87 | }, 88 | }, 89 | { 90 | attributes: { 91 | level: 1, 92 | viewType: "document", 93 | }, 94 | end: 120, 95 | start: 98, 96 | type: "block", 97 | }, 98 | { 99 | attributes: { 100 | level: 1, 101 | viewType: "document", 102 | }, 103 | end: 191, 104 | start: 120, 105 | type: "block", 106 | }, 107 | { 108 | type: "bold", 109 | start: 132, 110 | end: 136, 111 | attributes: { delimiter: "**" }, 112 | }, 113 | { 114 | type: "italics", 115 | start: 140, 116 | end: 147, 117 | attributes: { delimiter: "*" }, 118 | }, 119 | { 120 | type: "strikethrough", 121 | start: 150, 122 | end: 156, 123 | attributes: { delimiter: "~~" }, 124 | }, 125 | { 126 | attributes: { 127 | level: 1, 128 | viewType: "bullet", 129 | }, 130 | end: 204, 131 | start: 191, 132 | type: "block", 133 | }, 134 | { 135 | attributes: { 136 | level: 1, 137 | viewType: "bullet", 138 | }, 139 | end: 244, 140 | start: 204, 141 | type: "block", 142 | }, 143 | ], 144 | } 145 | ) 146 | ); 147 | 148 | test( 149 | "Extra new lines at the end", 150 | runTest("Extra new line\n\n", { 151 | content: `Extra new line\n\n`, 152 | annotations: [ 153 | { 154 | type: "block", 155 | start: 0, 156 | end: 15, 157 | attributes: { level: 1, viewType: "document" }, 158 | }, 159 | { 160 | type: "block", 161 | start: 15, 162 | end: 16, 163 | attributes: { level: 1, viewType: "document" }, 164 | }, 165 | ], 166 | }) 167 | ); 168 | 169 | test( 170 | "Empty page is one block", 171 | runTest("", { 172 | content: `\n`, 173 | annotations: [ 174 | { 175 | type: "block", 176 | start: 0, 177 | end: 1, 178 | attributes: { level: 1, viewType: "document" }, 179 | }, 180 | ], 181 | }) 182 | ); 183 | 184 | test( 185 | "Blocks, 1 indented", 186 | runTest("Some Content\n\tNested Content\nRoot Content", { 187 | content: "Some Content\n\tNested Content\nRoot Content\n", 188 | annotations: [ 189 | { 190 | type: "block", 191 | start: 0, 192 | end: 42, 193 | attributes: { level: 1, viewType: "document" }, 194 | }, 195 | ], 196 | }) 197 | ); 198 | 199 | test( 200 | "Aliasless link", 201 | runTest("A [](https://samepage.network) text", { 202 | content: `A ${String.fromCharCode(0)} text\n`, 203 | annotations: [ 204 | { 205 | type: "block", 206 | start: 0, 207 | end: 9, 208 | attributes: { level: 1, viewType: "document" }, 209 | }, 210 | { 211 | type: "link", 212 | start: 2, 213 | end: 3, 214 | attributes: { href: "https://samepage.network" }, 215 | }, 216 | ], 217 | }) 218 | ); 219 | 220 | test( 221 | "Just a link", 222 | runTest("Just a link: https://samepage.network", { 223 | content: "Just a link: https://samepage.network\n", 224 | annotations: [ 225 | { 226 | type: "block", 227 | start: 0, 228 | end: 38, 229 | attributes: { level: 1, viewType: "document" }, 230 | }, 231 | ], 232 | }) 233 | ); 234 | 235 | test( 236 | "Image with alias", 237 | runTest("![alias](https://samepage.network/images/logo.png)", { 238 | content: "alias\n", 239 | annotations: [ 240 | { 241 | type: "block", 242 | start: 0, 243 | end: 6, 244 | attributes: { level: 1, viewType: "document" }, 245 | }, 246 | { 247 | type: "image", 248 | start: 0, 249 | end: 5, 250 | attributes: { 251 | src: "https://samepage.network/images/logo.png", 252 | }, 253 | }, 254 | ], 255 | }) 256 | ); 257 | 258 | test( 259 | "Image without alias", 260 | runTest("![](https://samepage.network/images/logo.png)", { 261 | content: `${String.fromCharCode(0)}\n`, 262 | annotations: [ 263 | { 264 | type: "block", 265 | start: 0, 266 | end: 2, 267 | attributes: { level: 1, viewType: "document" }, 268 | }, 269 | { 270 | type: "image", 271 | start: 0, 272 | end: 1, 273 | attributes: { 274 | src: "https://samepage.network/images/logo.png", 275 | }, 276 | }, 277 | ], 278 | }) 279 | ); 280 | 281 | test( 282 | "Top level bullets", 283 | runTest( 284 | `- Top level bullet 285 | - And another`, 286 | { 287 | content: "Top level bullet\nAnd another\n", 288 | annotations: [ 289 | { 290 | type: "block", 291 | start: 0, 292 | end: 17, 293 | attributes: { 294 | level: 1, 295 | viewType: "bullet", 296 | }, 297 | }, 298 | { 299 | type: "block", 300 | start: 17, 301 | end: 29, 302 | attributes: { 303 | level: 1, 304 | viewType: "bullet", 305 | }, 306 | }, 307 | ], 308 | } 309 | ) 310 | ); 311 | 312 | test( 313 | "Single new line", 314 | runTest("Single new line\n", { 315 | content: "Single new line\n\n", 316 | annotations: [ 317 | { 318 | type: "block", 319 | start: 0, 320 | end: 17, 321 | attributes: { 322 | level: 1, 323 | viewType: "document", 324 | }, 325 | }, 326 | ], 327 | }) 328 | ); 329 | 330 | test("A normal block reference", () => { 331 | runTest("A block [[page title#^abcdef]] to content", { 332 | content: `A block ${String.fromCharCode(0)} to content\n`, 333 | annotations: [ 334 | { 335 | start: 0, 336 | end: 21, 337 | type: "block", 338 | attributes: { 339 | viewType: "document", 340 | level: 1, 341 | }, 342 | }, 343 | { 344 | start: 8, 345 | end: 9, 346 | type: "reference", 347 | attributes: { 348 | notebookPageId: "page title#^abcdef", 349 | notebookUuid, 350 | }, 351 | }, 352 | ], 353 | })(); 354 | }); 355 | 356 | test("A cross app block reference", () => { 357 | runTest("A [[abcd1234-abcd-1234-abcd-1234abcd1234:reference]] to content", { 358 | content: `A ${String.fromCharCode(0)} to content\n`, 359 | annotations: [ 360 | { 361 | start: 0, 362 | end: 15, 363 | type: "block", 364 | attributes: { 365 | viewType: "document", 366 | level: 1, 367 | }, 368 | }, 369 | { 370 | start: 2, 371 | end: 3, 372 | type: "reference", 373 | attributes: { 374 | notebookPageId: "reference", 375 | notebookUuid: "abcd1234-abcd-1234-abcd-1234abcd1234", 376 | }, 377 | }, 378 | ], 379 | })(); 380 | }); 381 | 382 | test( 383 | "Double italics", 384 | runTest("Deal _with_ two _sets_ of italics", { 385 | content: "Deal with two sets of italics\n", 386 | annotations: [ 387 | { 388 | attributes: { 389 | level: 1, 390 | viewType: "document", 391 | }, 392 | end: 30, 393 | start: 0, 394 | type: "block", 395 | }, 396 | { 397 | start: 5, 398 | end: 9, 399 | type: "italics", 400 | attributes: { delimiter: "_" }, 401 | }, 402 | { 403 | start: 14, 404 | end: 18, 405 | type: "italics", 406 | attributes: { delimiter: "_" }, 407 | }, 408 | ], 409 | }) 410 | ); 411 | 412 | test( 413 | "Just double underscore should be valid", 414 | runTest("Review __public pages", { 415 | content: "Review public pages\n", 416 | annotations: [ 417 | { 418 | attributes: { 419 | level: 1, 420 | viewType: "document", 421 | }, 422 | end: 20, 423 | start: 0, 424 | type: "block", 425 | }, 426 | { 427 | type: "bold", 428 | start: 7, 429 | end: 19, 430 | attributes: { 431 | delimiter: "__", 432 | open: true, 433 | }, 434 | }, 435 | ], 436 | }) 437 | ); 438 | 439 | test( 440 | "Just double asterisk should be valid", 441 | runTest("Review **public pages", { 442 | content: "Review public pages\n", 443 | annotations: [ 444 | { 445 | attributes: { 446 | level: 1, 447 | viewType: "document", 448 | }, 449 | end: 20, 450 | start: 0, 451 | type: "block", 452 | }, 453 | { 454 | attributes: { 455 | delimiter: "**", 456 | open: true, 457 | }, 458 | end: 19, 459 | start: 7, 460 | type: "bold", 461 | }, 462 | ], 463 | }) 464 | ); 465 | 466 | test( 467 | "Double page tags", 468 | runTest("One [[page]] and two [[pages]]", { 469 | content: `One ${String.fromCharCode(0)} and two ${String.fromCharCode( 470 | 0 471 | )}\n`, 472 | annotations: [ 473 | { 474 | attributes: { 475 | level: 1, 476 | viewType: "document", 477 | }, 478 | end: 16, 479 | start: 0, 480 | type: "block", 481 | }, 482 | { 483 | start: 4, 484 | end: 5, 485 | type: "reference", 486 | attributes: { 487 | notebookPageId: "page", 488 | notebookUuid, 489 | }, 490 | }, 491 | { 492 | start: 14, 493 | end: 15, 494 | type: "reference", 495 | attributes: { 496 | notebookPageId: "pages", 497 | notebookUuid, 498 | }, 499 | }, 500 | ], 501 | }) 502 | ); 503 | 504 | test( 505 | "Odd number underscores", 506 | runTest("Deal _with_ odd _underscores", { 507 | content: "Deal with odd underscores\n", 508 | annotations: [ 509 | { 510 | attributes: { 511 | level: 1, 512 | viewType: "document", 513 | }, 514 | end: 26, 515 | start: 0, 516 | type: "block", 517 | }, 518 | { 519 | start: 5, 520 | end: 9, 521 | type: "italics", 522 | attributes: { delimiter: "_" }, 523 | }, 524 | { 525 | start: 14, 526 | end: 25, 527 | type: "italics", 528 | attributes: { delimiter: "_", open: true }, 529 | }, 530 | ], 531 | }) 532 | ); 533 | 534 | test( 535 | "Odd number asterisks", 536 | runTest("Deal *with* odd *asterisks", { 537 | content: "Deal with odd asterisks\n", 538 | annotations: [ 539 | { 540 | attributes: { 541 | level: 1, 542 | viewType: "document", 543 | }, 544 | end: 24, 545 | start: 0, 546 | type: "block", 547 | }, 548 | { 549 | start: 5, 550 | end: 9, 551 | type: "italics", 552 | attributes: { delimiter: "*" }, 553 | }, 554 | { 555 | start: 14, 556 | end: 23, 557 | type: "italics", 558 | attributes: { delimiter: "*", open: true }, 559 | }, 560 | ], 561 | }) 562 | ); 563 | 564 | test( 565 | "Odd number double underscores", 566 | runTest("Deal __with__ odd __underscores", { 567 | content: `Deal with odd underscores\n`, 568 | annotations: [ 569 | { 570 | attributes: { 571 | level: 1, 572 | viewType: "document", 573 | }, 574 | end: 26, 575 | start: 0, 576 | type: "block", 577 | }, 578 | { 579 | start: 5, 580 | end: 9, 581 | type: "bold", 582 | attributes: { delimiter: "__" }, 583 | }, 584 | { 585 | start: 14, 586 | end: 25, 587 | type: "bold", 588 | attributes: { delimiter: "__", open: true }, 589 | }, 590 | ], 591 | }) 592 | ); 593 | 594 | test( 595 | "Odd number double asterisks", 596 | runTest("Deal **with** odd **asterisks", { 597 | content: `Deal with odd asterisks\n`, 598 | annotations: [ 599 | { 600 | attributes: { 601 | level: 1, 602 | viewType: "document", 603 | }, 604 | end: 24, 605 | start: 0, 606 | type: "block", 607 | }, 608 | { 609 | start: 5, 610 | end: 9, 611 | type: "bold", 612 | attributes: { delimiter: "**" }, 613 | }, 614 | { 615 | start: 14, 616 | end: 23, 617 | type: "bold", 618 | attributes: { delimiter: "**", open: true }, 619 | }, 620 | ], 621 | }) 622 | ); 623 | 624 | test( 625 | "Odd number double tilde", 626 | runTest("Deal ~~with~~ odd ~~tildes", { 627 | content: `Deal with odd tildes\n`, 628 | annotations: [ 629 | { 630 | attributes: { 631 | level: 1, 632 | viewType: "document", 633 | }, 634 | end: 21, 635 | start: 0, 636 | type: "block", 637 | }, 638 | { 639 | start: 5, 640 | end: 9, 641 | type: "strikethrough", 642 | attributes: { delimiter: "~~" }, 643 | }, 644 | { 645 | start: 14, 646 | end: 20, 647 | type: "strikethrough", 648 | attributes: { delimiter: "~~", open: true }, 649 | }, 650 | ], 651 | }) 652 | ); 653 | 654 | test( 655 | "Underscore within bold underscores", 656 | runTest("__hello _world__", { 657 | content: "hello world\n", 658 | annotations: [ 659 | { 660 | attributes: { 661 | level: 1, 662 | viewType: "document", 663 | }, 664 | end: 12, 665 | start: 0, 666 | type: "block", 667 | }, 668 | { 669 | end: 11, 670 | start: 0, 671 | type: "bold", 672 | attributes: { 673 | delimiter: "__", 674 | }, 675 | }, 676 | { 677 | end: 11, 678 | start: 6, 679 | type: "italics", 680 | attributes: { 681 | delimiter: "_", 682 | open: true, 683 | }, 684 | }, 685 | ], 686 | }) 687 | ); 688 | 689 | test( 690 | "Asterisk within bold stars", 691 | runTest("**hello *world**", { 692 | content: "hello world\n", 693 | annotations: [ 694 | { 695 | attributes: { 696 | level: 1, 697 | viewType: "document", 698 | }, 699 | end: 12, 700 | start: 0, 701 | type: "block", 702 | }, 703 | { 704 | end: 11, 705 | start: 0, 706 | type: "bold", 707 | attributes: { delimiter: "**" }, 708 | }, 709 | { 710 | end: 11, 711 | start: 6, 712 | type: "italics", 713 | attributes: { 714 | delimiter: "*", 715 | open: true, 716 | }, 717 | }, 718 | ], 719 | }) 720 | ); 721 | 722 | test( 723 | "Two new lines before bullet", 724 | runTest( 725 | "So this is a test share page.\n\n- So how does this work\n- And this\n\n", 726 | { 727 | content: 728 | "So this is a test share page.\n\nSo how does this work\nAnd this\n\n", 729 | annotations: [ 730 | { 731 | type: "block", 732 | start: 0, 733 | end: 31, 734 | attributes: { viewType: "document", level: 1 }, 735 | }, 736 | { 737 | type: "block", 738 | start: 31, 739 | end: 53, 740 | attributes: { viewType: "bullet", level: 1 }, 741 | }, 742 | { 743 | type: "block", 744 | start: 53, 745 | end: 62, 746 | attributes: { viewType: "bullet", level: 1 }, 747 | }, 748 | { 749 | type: "block", 750 | start: 62, 751 | end: 63, 752 | attributes: { viewType: "document", level: 1 }, 753 | }, 754 | ], 755 | } 756 | ) 757 | ); 758 | 759 | test( 760 | "multiple aliases", 761 | runTest( 762 | "links: [one]([nested] some text - https://samepage.network), [two](https://samepage.network), [three](https://samepage.network)", 763 | { 764 | content: "links: one, two, three\n", 765 | annotations: [ 766 | { 767 | start: 0, 768 | end: 23, 769 | type: "block", 770 | attributes: { viewType: "document", level: 1 }, 771 | }, 772 | { 773 | start: 7, 774 | end: 10, 775 | type: "link", 776 | attributes: { href: "[nested] some text - https://samepage.network" }, 777 | }, 778 | { 779 | start: 12, 780 | end: 15, 781 | type: "link", 782 | attributes: { href: "https://samepage.network" }, 783 | }, 784 | { 785 | start: 17, 786 | end: 22, 787 | type: "link", 788 | attributes: { href: "https://samepage.network" }, 789 | }, 790 | ], 791 | } 792 | ) 793 | ); 794 | 795 | test( 796 | "Code Blocks", 797 | runTest( 798 | `\`\`\`python 799 | class SubClass(SuperClass): 800 | 801 | def __init__(self, **kwargs): 802 | super(SubClass, self).__init__(**kwargs) 803 | 804 | def method(self, *args, **kwargs): 805 | # A comment about what's going on 806 | self.field = Method(*pool_args, **pool_kwargs) 807 | \`\`\``, 808 | { 809 | content: 810 | "class SubClass(SuperClass):\n\n def __init__(self, **kwargs):\n super(SubClass, self).__init__(**kwargs)\n\n def method(self, *args, **kwargs):\n # A comment about what's going on\n self.field = Method(*pool_args, **pool_kwargs)\n\n", 811 | annotations: [ 812 | { 813 | type: "block", 814 | start: 0, 815 | end: 250, 816 | attributes: { 817 | viewType: "document", 818 | level: 1, 819 | }, 820 | }, 821 | { 822 | type: "code", 823 | start: 0, 824 | end: 249, 825 | attributes: { 826 | language: "python", 827 | }, 828 | }, 829 | ], 830 | } 831 | ) 832 | ); 833 | 834 | test( 835 | "Triple new line at end", 836 | runTest("A page\n\n\n", { 837 | content: "A page\n\n\n", 838 | annotations: [ 839 | { 840 | type: "block", 841 | start: 0, 842 | end: 7, 843 | attributes: { 844 | viewType: "document", 845 | level: 1, 846 | }, 847 | }, 848 | { 849 | type: "block", 850 | start: 7, 851 | end: 9, 852 | attributes: { 853 | viewType: "document", 854 | level: 1, 855 | }, 856 | }, 857 | ], 858 | }) 859 | ); 860 | 861 | test( 862 | "Triple new line in the middle", 863 | runTest("A page\n\n\nA paragraph", { 864 | content: "A page\n\nA paragraph\n", 865 | annotations: [ 866 | { 867 | type: "block", 868 | start: 0, 869 | end: 7, 870 | attributes: { 871 | viewType: "document", 872 | level: 1, 873 | }, 874 | }, 875 | { 876 | type: "block", 877 | start: 7, 878 | end: 20, 879 | attributes: { 880 | viewType: "document", 881 | level: 1, 882 | }, 883 | }, 884 | ], 885 | }) 886 | ); 887 | 888 | test( 889 | "tabbing with spaces", 890 | runTest("- Block\n - Nested", { 891 | content: "Block\nNested\n", 892 | annotations: [ 893 | { 894 | type: "block", 895 | start: 0, 896 | end: 6, 897 | attributes: { level: 1, viewType: "bullet" }, 898 | }, 899 | { 900 | type: "block", 901 | start: 6, 902 | end: 13, 903 | attributes: { level: 2, viewType: "bullet" }, 904 | appAttributes: { 905 | obsidian: { 906 | spacing: ` `, 907 | }, 908 | }, 909 | }, 910 | ], 911 | }) 912 | ); 913 | 914 | test( 915 | "Empty bullet with newline", 916 | runTest("\n- \n", { 917 | content: "\n\n\n", 918 | annotations: [ 919 | { 920 | type: "block", 921 | start: 0, 922 | end: 1, 923 | attributes: { level: 1, viewType: "document" }, 924 | }, 925 | { 926 | type: "block", 927 | start: 1, 928 | end: 3, 929 | attributes: { level: 1, viewType: "bullet" }, 930 | }, 931 | ], 932 | }) 933 | ); 934 | 935 | test( 936 | "Single new line after line", 937 | runTest("Single new line\n\n\t\n", { 938 | content: "Single new line\n\n\n", 939 | annotations: [ 940 | { 941 | type: "block", 942 | start: 0, 943 | end: 16, 944 | attributes: { 945 | level: 1, 946 | viewType: "document", 947 | }, 948 | }, 949 | { 950 | type: "block", 951 | start: 16, 952 | end: 18, 953 | attributes: { 954 | level: 2, 955 | viewType: "document", 956 | }, 957 | appAttributes: { 958 | obsidian: { 959 | spacing: `\t`, 960 | }, 961 | }, 962 | }, 963 | ], 964 | }) 965 | ); 966 | 967 | test( 968 | "Unclosed alias", 969 | runTest(`[unclosed alias](https://samepage.network`, { 970 | content: "[unclosed alias](https://samepage.network\n", 971 | annotations: [ 972 | { 973 | type: "block", 974 | start: 0, 975 | end: 42, 976 | attributes: { 977 | level: 1, 978 | viewType: "document", 979 | }, 980 | }, 981 | ], 982 | }) 983 | ); 984 | 985 | test("Lots of blocks", () => { 986 | const blocks = Array(30).fill(null); 987 | const md = blocks.reduce((p) => `${p}Hello\n\n`, ""); 988 | const content = blocks.reduce((p) => `${p}Hello\n`, "") + "\n"; 989 | const annotations = blocks 990 | .map((_, i) => ({ 991 | attributes: { 992 | level: 1, 993 | viewType: "document" as const, 994 | }, 995 | end: (i + 1) * 6, 996 | start: i * 6, 997 | type: "block" as const, 998 | })) 999 | .concat({ 1000 | attributes: { 1001 | level: 1, 1002 | viewType: "document" as const, 1003 | }, 1004 | end: 181, 1005 | start: 180, 1006 | type: "block" as const, 1007 | }); 1008 | runTest(md, { 1009 | annotations, 1010 | content, 1011 | })(); 1012 | }); 1013 | 1014 | test( 1015 | "Code block with hyphen", 1016 | runTest( 1017 | `\`\`\`ad-icon 1018 | 1019 | \`\`\``, 1020 | { 1021 | content: "\n\n", 1022 | annotations: [ 1023 | { 1024 | attributes: { 1025 | level: 1, 1026 | viewType: "document", 1027 | }, 1028 | end: 13, 1029 | start: 0, 1030 | type: "block", 1031 | }, 1032 | { 1033 | attributes: { 1034 | language: "ad-icon", 1035 | }, 1036 | end: 12, 1037 | start: 0, 1038 | type: "code", 1039 | }, 1040 | ], 1041 | } 1042 | ) 1043 | ); 1044 | 1045 | test( 1046 | "Code block without newline before close", 1047 | runTest("```dataviewjs\nlet setting = {};\n>```", { 1048 | content: "let setting = {};\n>\n", 1049 | annotations: [ 1050 | { 1051 | attributes: { 1052 | level: 1, 1053 | viewType: "document", 1054 | }, 1055 | end: 20, 1056 | start: 0, 1057 | type: "block", 1058 | }, 1059 | { 1060 | attributes: { 1061 | language: "dataviewjs", 1062 | }, 1063 | end: 19, 1064 | start: 0, 1065 | type: "code", 1066 | }, 1067 | ], 1068 | }) 1069 | ); 1070 | 1071 | test( 1072 | "Code block with four ticks", 1073 | runTest( 1074 | `\`\`\`\`adgrid 1075 | > [!profile-card|cards] 1076 | \`\`\`\``, 1077 | { 1078 | content: "> [!profile-card|cards]\n\n", 1079 | annotations: [ 1080 | { 1081 | attributes: { 1082 | level: 1, 1083 | viewType: "document", 1084 | }, 1085 | end: 25, 1086 | start: 0, 1087 | type: "block", 1088 | }, 1089 | { 1090 | attributes: { 1091 | language: "adgrid", 1092 | ticks: 4, 1093 | }, 1094 | end: 24, 1095 | start: 0, 1096 | type: "code", 1097 | }, 1098 | ], 1099 | } 1100 | ) 1101 | ); 1102 | 1103 | test( 1104 | "Unclosed bolding (star)", 1105 | runTest("**Important!\n\nParagraph", { 1106 | content: `Important!\nParagraph\n`, 1107 | annotations: [ 1108 | { 1109 | type: "block", 1110 | start: 0, 1111 | end: 11, 1112 | attributes: { level: 1, viewType: "document" }, 1113 | }, 1114 | { 1115 | type: "bold", 1116 | start: 0, 1117 | end: 10, 1118 | attributes: { delimiter: "**", open: true }, 1119 | }, 1120 | { 1121 | type: "block", 1122 | start: 11, 1123 | end: 21, 1124 | attributes: { level: 1, viewType: "document" }, 1125 | }, 1126 | ], 1127 | }) 1128 | ); 1129 | 1130 | test( 1131 | "End line with single asterisk", 1132 | runTest("Important*\n\nParagraph", { 1133 | content: `Important*\nParagraph\n`, 1134 | annotations: [ 1135 | { 1136 | type: "block", 1137 | start: 0, 1138 | end: 11, 1139 | attributes: { level: 1, viewType: "document" }, 1140 | }, 1141 | { 1142 | type: "block", 1143 | start: 11, 1144 | end: 21, 1145 | attributes: { level: 1, viewType: "document" }, 1146 | }, 1147 | ], 1148 | }) 1149 | ); 1150 | 1151 | test( 1152 | "Unclosed bolding (underscore)", 1153 | runTest("__Important!\n\nParagraph", { 1154 | content: `Important!\nParagraph\n`, 1155 | annotations: [ 1156 | { 1157 | type: "block", 1158 | start: 0, 1159 | end: 11, 1160 | attributes: { level: 1, viewType: "document" }, 1161 | }, 1162 | { 1163 | type: "bold", 1164 | start: 0, 1165 | end: 10, 1166 | attributes: { delimiter: "__", open: true }, 1167 | }, 1168 | { 1169 | type: "block", 1170 | start: 11, 1171 | end: 21, 1172 | attributes: { level: 1, viewType: "document" }, 1173 | }, 1174 | ], 1175 | }) 1176 | ); 1177 | 1178 | test( 1179 | "Unclosed italics (star)", 1180 | runTest("*Important!\n\nParagraph", { 1181 | content: `Important!\nParagraph\n`, 1182 | annotations: [ 1183 | { 1184 | type: "block", 1185 | start: 0, 1186 | end: 11, 1187 | attributes: { level: 1, viewType: "document" }, 1188 | }, 1189 | { 1190 | type: "italics", 1191 | start: 0, 1192 | end: 10, 1193 | attributes: { delimiter: "*", open: true }, 1194 | }, 1195 | { 1196 | type: "block", 1197 | start: 11, 1198 | end: 21, 1199 | attributes: { level: 1, viewType: "document" }, 1200 | }, 1201 | ], 1202 | }) 1203 | ); 1204 | 1205 | test( 1206 | "Unclosed italics (underscore)", 1207 | runTest("_Important!\n\nParagraph", { 1208 | content: `Important!\nParagraph\n`, 1209 | annotations: [ 1210 | { 1211 | type: "block", 1212 | start: 0, 1213 | end: 11, 1214 | attributes: { level: 1, viewType: "document" }, 1215 | }, 1216 | { 1217 | type: "italics", 1218 | start: 0, 1219 | end: 10, 1220 | attributes: { delimiter: "_", open: true }, 1221 | }, 1222 | { 1223 | type: "block", 1224 | start: 11, 1225 | end: 21, 1226 | attributes: { level: 1, viewType: "document" }, 1227 | }, 1228 | ], 1229 | }) 1230 | ); 1231 | 1232 | test( 1233 | "end line with double asterisk", 1234 | runTest("Important**\n\nParagraph", { 1235 | content: `Important**\nParagraph\n`, 1236 | annotations: [ 1237 | { 1238 | type: "block", 1239 | start: 0, 1240 | end: 12, 1241 | attributes: { level: 1, viewType: "document" }, 1242 | }, 1243 | { 1244 | type: "block", 1245 | start: 12, 1246 | end: 22, 1247 | attributes: { level: 1, viewType: "document" }, 1248 | }, 1249 | ], 1250 | }) 1251 | ); 1252 | 1253 | test( 1254 | "end line with double underscore", 1255 | runTest("Important__\n\nParagraph", { 1256 | content: `Important__\nParagraph\n`, 1257 | annotations: [ 1258 | { 1259 | type: "block", 1260 | start: 0, 1261 | end: 12, 1262 | attributes: { level: 1, viewType: "document" }, 1263 | }, 1264 | { 1265 | type: "block", 1266 | start: 12, 1267 | end: 22, 1268 | attributes: { level: 1, viewType: "document" }, 1269 | }, 1270 | ], 1271 | }) 1272 | ); 1273 | 1274 | test( 1275 | "Underscore preceeded by word character does not italicize", 1276 | runTest("Hello_world", { 1277 | content: `Hello_world\n`, 1278 | annotations: [ 1279 | { 1280 | type: "block", 1281 | start: 0, 1282 | end: 12, 1283 | attributes: { level: 1, viewType: "document" }, 1284 | }, 1285 | ], 1286 | }) 1287 | ); 1288 | 1289 | test( 1290 | "Underscore at end of word is text", 1291 | runTest("Hello_ _world_", { 1292 | content: `Hello_ world\n`, 1293 | annotations: [ 1294 | { 1295 | type: "block", 1296 | start: 0, 1297 | end: 13, 1298 | attributes: { level: 1, viewType: "document" }, 1299 | }, 1300 | { 1301 | type: "italics", 1302 | start: 7, 1303 | end: 12, 1304 | attributes: { delimiter: "_" }, 1305 | }, 1306 | ], 1307 | }) 1308 | ); 1309 | 1310 | test.skip( 1311 | "three dash", 1312 | runTest("---", { 1313 | content: `${String.fromCharCode(0)}\n`, 1314 | annotations: [ 1315 | { 1316 | type: "block", 1317 | start: 0, 1318 | end: 2, 1319 | attributes: { level: 1, viewType: "document" }, 1320 | }, 1321 | { 1322 | type: "custom", 1323 | start: 0, 1324 | end: 1, 1325 | attributes: { name: "horizontalLine" }, 1326 | }, 1327 | ], 1328 | }) 1329 | ); 1330 | 1331 | test.skip( 1332 | "three underscore", 1333 | runTest("___", { 1334 | content: `${String.fromCharCode(0)}\n`, 1335 | annotations: [ 1336 | { 1337 | type: "block", 1338 | start: 0, 1339 | end: 2, 1340 | attributes: { level: 1, viewType: "document" }, 1341 | }, 1342 | { 1343 | type: "custom", 1344 | start: 0, 1345 | end: 1, 1346 | attributes: { name: "horizontalLine" }, 1347 | }, 1348 | ], 1349 | }) 1350 | ); 1351 | 1352 | test.skip( 1353 | "three star", 1354 | runTest("***", { 1355 | content: `${String.fromCharCode(0)}\n`, 1356 | annotations: [ 1357 | { 1358 | type: "block", 1359 | start: 0, 1360 | end: 2, 1361 | attributes: { level: 1, viewType: "document" }, 1362 | }, 1363 | { 1364 | type: "custom", 1365 | start: 0, 1366 | end: 1, 1367 | attributes: { name: "horizontalLine" }, 1368 | }, 1369 | ], 1370 | }) 1371 | ); 1372 | 1373 | test.skip( 1374 | "Asterisk bullets", 1375 | runTest("* First\n* Second", { 1376 | content: `First\nSecond\n`, 1377 | annotations: [ 1378 | { 1379 | type: "block", 1380 | start: 0, 1381 | end: 6, 1382 | attributes: { level: 1, viewType: "bullet" }, 1383 | }, 1384 | { 1385 | type: "block", 1386 | start: 6, 1387 | end: 13, 1388 | attributes: { level: 1, viewType: "bullet" }, 1389 | }, 1390 | ], 1391 | }) 1392 | ); 1393 | 1394 | test( 1395 | "Start bolding end with asterisk", 1396 | runTest("**Important*\n\nParagraph", { 1397 | content: `Important*\nParagraph\n`, 1398 | annotations: [ 1399 | { 1400 | type: "block", 1401 | start: 0, 1402 | end: 11, 1403 | attributes: { level: 1, viewType: "document" }, 1404 | }, 1405 | { 1406 | type: "bold", 1407 | start: 0, 1408 | end: 10, 1409 | attributes: { delimiter: "**", open: true }, 1410 | }, 1411 | { 1412 | type: "block", 1413 | start: 11, 1414 | end: 21, 1415 | attributes: { level: 1, viewType: "document" }, 1416 | }, 1417 | ], 1418 | }) 1419 | ); 1420 | 1421 | // This is a tricky problem to solve - our lexer reads this as boldAsterisk, boldAsterisk, text. 1422 | // We somehow need read this as boldAsterisk, italicsAsterisk, text. 1423 | test.skip( 1424 | "Four asterisks to start", 1425 | runTest("****text", { 1426 | content: "*text\n", 1427 | annotations: [ 1428 | { 1429 | type: "block", 1430 | start: 0, 1431 | end: 6, 1432 | attributes: { level: 1, viewType: "document" }, 1433 | }, 1434 | { type: "bold", start: 0, end: 5, attributes: { delimiter: "**" } }, 1435 | { type: "italics", start: 0, end: 5, attributes: { delimiter: "**" } }, 1436 | ], 1437 | }) 1438 | ); 1439 | -------------------------------------------------------------------------------- /tests/mockObsidianEnvironment.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from "@codemirror/state"; 2 | import type { Editor } from "codemirror"; 3 | import type { 4 | App, 5 | BaseComponent, 6 | ButtonComponent, 7 | ColorComponent, 8 | Command, 9 | Component, 10 | DropdownComponent, 11 | EditorSuggest, 12 | EventRef, 13 | ExtraButtonComponent, 14 | KeymapEventHandler, 15 | MarkdownPostProcessor, 16 | MarkdownPostProcessorContext, 17 | MomentFormatComponent, 18 | Notice as NoticeType, 19 | ObsidianProtocolHandler, 20 | Plugin as PluginType, 21 | PluginManifest, 22 | PluginSettingTab as PluginSettingTabType, 23 | SearchComponent, 24 | Setting as SettingType, 25 | SliderComponent, 26 | TextAreaComponent, 27 | TextComponent, 28 | ToggleComponent, 29 | ViewCreator, 30 | } from "obsidian"; 31 | 32 | export class Notice implements NoticeType { 33 | constructor(s: string) {} 34 | setMessage(message: string | DocumentFragment): this { 35 | return this; 36 | } 37 | hide(): void {} 38 | } 39 | export class Plugin implements PluginType { 40 | constructor() {} 41 | app: App; 42 | manifest: PluginManifest; 43 | addRibbonIcon( 44 | icon: string, 45 | title: string, 46 | callback: (evt: MouseEvent) => any 47 | ): HTMLElement { 48 | throw new Error("Method not implemented."); 49 | } 50 | addStatusBarItem(): HTMLElement { 51 | throw new Error("Method not implemented."); 52 | } 53 | addCommand(command: Command): Command { 54 | throw new Error("Method not implemented."); 55 | } 56 | addSettingTab(settingTab: PluginSettingTabType): void { 57 | throw new Error("Method not implemented."); 58 | } 59 | registerView(type: string, viewCreator: ViewCreator): void { 60 | throw new Error("Method not implemented."); 61 | } 62 | registerExtensions(extensions: string[], viewType: string): void { 63 | throw new Error("Method not implemented."); 64 | } 65 | registerMarkdownPostProcessor( 66 | postProcessor: MarkdownPostProcessor, 67 | sortOrder?: number | undefined 68 | ): MarkdownPostProcessor { 69 | throw new Error("Method not implemented."); 70 | } 71 | registerMarkdownCodeBlockProcessor( 72 | language: string, 73 | handler: ( 74 | source: string, 75 | el: HTMLElement, 76 | ctx: MarkdownPostProcessorContext 77 | ) => void | Promise, 78 | sortOrder?: number | undefined 79 | ): MarkdownPostProcessor { 80 | throw new Error("Method not implemented."); 81 | } 82 | registerCodeMirror(callback: (cm: Editor) => any): void { 83 | throw new Error("Method not implemented."); 84 | } 85 | registerEditorExtension(extension: Extension): void { 86 | throw new Error("Method not implemented."); 87 | } 88 | registerObsidianProtocolHandler( 89 | action: string, 90 | handler: ObsidianProtocolHandler 91 | ): void { 92 | throw new Error("Method not implemented."); 93 | } 94 | registerEditorSuggest(editorSuggest: EditorSuggest): void { 95 | throw new Error("Method not implemented."); 96 | } 97 | loadData(): Promise { 98 | throw new Error("Method not implemented."); 99 | } 100 | saveData(data: any): Promise { 101 | throw new Error("Method not implemented."); 102 | } 103 | load(): void { 104 | throw new Error("Method not implemented."); 105 | } 106 | onload(): void { 107 | throw new Error("Method not implemented."); 108 | } 109 | unload(): void { 110 | throw new Error("Method not implemented."); 111 | } 112 | onunload(): void { 113 | throw new Error("Method not implemented."); 114 | } 115 | addChild(component: T): T { 116 | throw new Error("Method not implemented."); 117 | } 118 | removeChild(component: T): T { 119 | throw new Error("Method not implemented."); 120 | } 121 | register(cb: () => any): void { 122 | throw new Error("Method not implemented."); 123 | } 124 | registerEvent(eventRef: EventRef): void { 125 | throw new Error("Method not implemented."); 126 | } 127 | registerDomEvent( 128 | el: Window, 129 | type: K, 130 | callback: (this: HTMLElement, ev: WindowEventMap[K]) => any, 131 | options?: boolean | AddEventListenerOptions | undefined 132 | ): void; 133 | registerDomEvent( 134 | el: Document, 135 | type: K, 136 | callback: (this: HTMLElement, ev: DocumentEventMap[K]) => any, 137 | options?: boolean | AddEventListenerOptions | undefined 138 | ): void; 139 | registerDomEvent( 140 | el: HTMLElement, 141 | type: K, 142 | callback: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, 143 | options?: boolean | AddEventListenerOptions | undefined 144 | ): void; 145 | registerDomEvent( 146 | el: unknown, 147 | type: unknown, 148 | callback: unknown, 149 | options?: unknown 150 | ): void { 151 | throw new Error("Method not implemented."); 152 | } 153 | registerScopeEvent(keyHandler: KeymapEventHandler): void { 154 | throw new Error("Method not implemented."); 155 | } 156 | registerInterval(id: number): number { 157 | throw new Error("Method not implemented."); 158 | } 159 | } 160 | export class PluginSettingTab implements PluginSettingTabType { 161 | app: App; 162 | containerEl: HTMLElement; 163 | constructor() {} 164 | display() { 165 | throw new Error("Method not implemented."); 166 | } 167 | hide() { 168 | throw new Error("Method not implemented."); 169 | } 170 | } 171 | export class Setting implements SettingType { 172 | settingEl: HTMLElement; 173 | infoEl: HTMLElement; 174 | nameEl: HTMLElement; 175 | descEl: HTMLElement; 176 | controlEl: HTMLElement; 177 | components: BaseComponent[]; 178 | constructor() {} 179 | setName(name: string | DocumentFragment): this { 180 | throw new Error("Method not implemented."); 181 | } 182 | setDesc(desc: string | DocumentFragment): this { 183 | throw new Error("Method not implemented."); 184 | } 185 | setClass(cls: string): this { 186 | throw new Error("Method not implemented."); 187 | } 188 | setTooltip(tooltip: string): this { 189 | throw new Error("Method not implemented."); 190 | } 191 | setHeading(): this { 192 | throw new Error("Method not implemented."); 193 | } 194 | setDisabled(disabled: boolean): this { 195 | throw new Error("Method not implemented."); 196 | } 197 | addButton(cb: (component: ButtonComponent) => any): this { 198 | throw new Error("Method not implemented."); 199 | } 200 | addExtraButton(cb: (component: ExtraButtonComponent) => any): this { 201 | throw new Error("Method not implemented."); 202 | } 203 | addToggle(cb: (component: ToggleComponent) => any): this { 204 | throw new Error("Method not implemented."); 205 | } 206 | addText(cb: (component: TextComponent) => any): this { 207 | throw new Error("Method not implemented."); 208 | } 209 | addSearch(cb: (component: SearchComponent) => any): this { 210 | throw new Error("Method not implemented."); 211 | } 212 | addTextArea(cb: (component: TextAreaComponent) => any): this { 213 | throw new Error("Method not implemented."); 214 | } 215 | addMomentFormat(cb: (component: MomentFormatComponent) => any): this { 216 | throw new Error("Method not implemented."); 217 | } 218 | addDropdown(cb: (component: DropdownComponent) => any): this { 219 | throw new Error("Method not implemented."); 220 | } 221 | addColorPicker(cb: (component: ColorComponent) => any): this { 222 | throw new Error("Method not implemented."); 223 | } 224 | addSlider(cb: (component: SliderComponent) => any): this { 225 | throw new Error("Method not implemented."); 226 | } 227 | then(cb: (setting: this) => any): this { 228 | throw new Error("Method not implemented."); 229 | } 230 | clear(): this { 231 | throw new Error("Method not implemented."); 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "allowJs": false, 6 | "skipLibCheck": true, 7 | "esModuleInterop": false, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react", 17 | "baseUrl": ".", 18 | }, 19 | "include": ["./src", "./node_modules/samepage/declare.d.ts"] 20 | } 21 | --------------------------------------------------------------------------------