├── deps ├── esbuild.ts ├── async.ts ├── option-t.ts ├── scrapbox-parser.ts ├── scrapbox-std-browser.ts ├── testing.ts ├── immer.ts ├── scrapbox-std.ts ├── preact.tsx └── scrapbox.ts ├── global.css ├── app.css ├── types.ts ├── README.md ├── .github └── workflows │ ├── ci.yml │ └── update.yml ├── id.ts ├── useTheme.ts ├── detectURL.ts ├── UserCSS.tsx ├── is.ts ├── stayHovering.ts ├── cardBubble.css ├── watchList.ts ├── id.test.ts ├── position.ts ├── parseLink.ts ├── LICENSE ├── throttle.ts ├── eventEmitter.ts ├── scripts └── minifyCSS.ts ├── deno.jsonc ├── statusBar.css ├── useKaTeX.ts ├── useEventListener.ts ├── hasLink.ts ├── type-traits.ts ├── mod.tsx ├── useBubbleData.ts ├── hasLink.test.ts ├── parseLink.test.ts ├── useProject.ts ├── debug.ts ├── textBubble.css ├── throttle.test.ts ├── useBubbles.ts ├── cache.ts ├── storage.ts ├── CardList.tsx ├── TextBubble.tsx ├── detectURL.test.ts ├── bubble.ts ├── App.tsx ├── card.css ├── page.css ├── Bubble.tsx ├── convert.ts ├── Card.tsx ├── katex.ts ├── storage.test.ts ├── app.min.css.ts ├── deno.lock ├── __snapshots__ └── convert.test.ts.snap ├── Page.tsx └── convert.test.ts /deps/esbuild.ts: -------------------------------------------------------------------------------- 1 | export * from "npm:esbuild@0.27"; 2 | -------------------------------------------------------------------------------- /deps/async.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@std/async@1/delay"; 2 | -------------------------------------------------------------------------------- /global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | -------------------------------------------------------------------------------- /deps/option-t.ts: -------------------------------------------------------------------------------- 1 | export * from "npm:option-t@55/plain_result"; 2 | -------------------------------------------------------------------------------- /deps/scrapbox-parser.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@progfay/scrapbox-parser@10"; 2 | -------------------------------------------------------------------------------- /deps/scrapbox-std-browser.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@cosense/std@0.31/browser/dom"; 2 | -------------------------------------------------------------------------------- /app.css: -------------------------------------------------------------------------------- 1 | @import "./global.css"; 2 | @import "./textBubble.css"; 3 | @import "./cardBubble.css"; 4 | -------------------------------------------------------------------------------- /deps/testing.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@std/assert"; 2 | export * from "jsr:@std/testing/snapshot"; 3 | -------------------------------------------------------------------------------- /deps/immer.ts: -------------------------------------------------------------------------------- 1 | import { enableMapSet } from "npm:immer@11"; 2 | enableMapSet(); 3 | 4 | export * from "npm:immer@11"; 5 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type LinkType = "hashtag" | "link" | "title"; 2 | 3 | export interface LinkTo { 4 | project?: string; 5 | titleLc: string; 6 | } 7 | -------------------------------------------------------------------------------- /deps/scrapbox-std.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@cosense/std@0.31/rest"; 2 | export * from "jsr:@cosense/std@0.31/title"; 3 | export * from "jsr:@cosense/std@0.31/parseAbsoluteLink"; 4 | -------------------------------------------------------------------------------- /deps/preact.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | type ComponentChildren, 3 | createContext, 4 | Fragment, 5 | type FunctionComponent, 6 | h, 7 | type RefCallback, 8 | render, 9 | toChildArray, 10 | } from "npm:preact@10"; 11 | export * from "npm:preact@10/hooks"; 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScrapBubble 2 | 3 | Show n-hop link destination pages beyond projects 4 | 5 | ## Install 6 | 7 | (write later) 8 | 9 | ## License 10 | 11 | MIT 12 | 13 | ## Related Project 14 | 15 | - [daiiz/ScrapScripts](https://github.com/daiiz/ScrapScripts) - Unofficial 16 | browser extension for Scrapbox 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: denoland/setup-deno@v2 11 | with: 12 | deno-version: "v2" 13 | - name: Check all 14 | run: deno task check 15 | -------------------------------------------------------------------------------- /id.ts: -------------------------------------------------------------------------------- 1 | import { toTitleLc } from "./deps/scrapbox-std.ts"; 2 | 3 | export type ID = `/${string}/${string}`; 4 | /** 同一ページか判定するためのIDを作る */ 5 | export const toId = (project: string, title: string): ID => 6 | `/${project.toLowerCase()}/${toTitleLc(title)}`; 7 | 8 | /** IDからリンク情報を復元する */ 9 | export const fromId = (id: ID): { project: string; titleLc: string } => { 10 | const matches = id.match(`/([^\/]+)/(.+)`); 11 | if (!matches) throw SyntaxError(`"${id}" cannnot match "/([^\/]+)/(.+)"`); 12 | return { 13 | project: matches[1], 14 | titleLc: matches[2], 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from "./deps/preact.tsx"; 2 | import { useProject } from "./useProject.ts"; 3 | import { isTheme, type Theme } from "./deps/scrapbox.ts"; 4 | import { isErr, unwrapOk } from "./deps/option-t.ts"; 5 | 6 | const defaultTheme = "default-light"; 7 | 8 | /** projectのthemeを取得するhook */ 9 | export const useTheme = (project: string): Theme => { 10 | const res = useProject(project); 11 | 12 | return useMemo(() => { 13 | if (!res || isErr(res)) return defaultTheme; 14 | const theme = unwrapOk(res).theme; 15 | return isTheme(theme) ? theme : defaultTheme; 16 | }, [res]); 17 | }; 18 | -------------------------------------------------------------------------------- /detectURL.ts: -------------------------------------------------------------------------------- 1 | /** URL文字列をURLに変換する*/ 2 | export const detectURL = ( 3 | text: URL | string, 4 | base?: URL | string, 5 | ): URL | string => { 6 | if (text instanceof URL) return text; 7 | try { 8 | return new URL(text); 9 | } catch (e: unknown) { 10 | if (!(e instanceof TypeError)) throw e; 11 | if (!base) return text; 12 | // 相対パスへの変換を試みる 13 | // ./や../や/で始まらない文字列は、相対パス扱いしない 14 | if (!/^\.\/|^\.\.\/|^\//.test(text)) return text; 15 | try { 16 | return new URL(text, base); 17 | } catch (e: unknown) { 18 | if (!(e instanceof TypeError)) throw e; 19 | return text; 20 | } 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /UserCSS.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { useMemo } from "./deps/preact.tsx"; 4 | import { detectURL } from "./detectURL.ts"; 5 | 6 | export interface UserCSSProps { 7 | /** CSSもしくはCSSへのURL */ 8 | style: string | URL; 9 | } 10 | 11 | /** UserCSSを挿入する */ 12 | export const UserCSS = (props: UserCSSProps) => { 13 | const url = useMemo(() => detectURL(props.style, import.meta.url), [ 14 | props.style, 15 | ]); 16 | 17 | return ( 18 | <> 19 | {url !== "" && (url instanceof URL 20 | ? 21 | : )} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /is.ts: -------------------------------------------------------------------------------- 1 | /** 編集画面のタイトル行のDOMかどうか判定する */ 2 | export const isTitle = ( 3 | element: HTMLElement, 4 | ): element is HTMLSpanElement => 5 | element instanceof HTMLSpanElement && 6 | element.matches(".line-title .text"); 7 | 8 | /** scrapbox内のリンクかどうか判定する */ 9 | export const isPageLink = ( 10 | element: HTMLElement, 11 | ): element is HTMLAnchorElement => 12 | element instanceof HTMLAnchorElement && 13 | element.classList.contains("page-link"); 14 | 15 | /** 指定したtemplate literal stringかどうか判定する */ 16 | export const isLiteralStrings = ( 17 | value: string | undefined, 18 | ...literals: S 19 | ): value is S[number] => value !== undefined && literals.includes(value); 20 | -------------------------------------------------------------------------------- /stayHovering.ts: -------------------------------------------------------------------------------- 1 | /** ホバー操作を待機する 2 | * @return ホバーがキャンセルされたら`false` 3 | */ 4 | export const stayHovering = ( 5 | element: E, 6 | interval: number, 7 | ): Promise => 8 | new Promise((resolve) => { 9 | let canceled = false; 10 | const handleCancel = () => { 11 | canceled = true; 12 | resolve(false); 13 | }; 14 | element.addEventListener("click", handleCancel); 15 | element.addEventListener("pointerleave", handleCancel); 16 | setTimeout(() => { 17 | if (!canceled) resolve(true); 18 | element.removeEventListener("click", handleCancel); 19 | element.removeEventListener("pointerleave", handleCancel); 20 | resolve(false); 21 | }, interval); 22 | }); 23 | -------------------------------------------------------------------------------- /cardBubble.css: -------------------------------------------------------------------------------- 1 | @import "./card.css"; 2 | 3 | .card-bubble { 4 | background-color: var(--page-bg, #fff); 5 | box-shadow: 6 | 0 2px 2px 0 rgba(0, 0, 0, 0.14), 7 | 0 3px 1px -2px rgba(0, 0, 0, 0.2), 8 | 0 1px 5px 0 rgba(0, 0, 0, 0.12); 9 | position: absolute; 10 | max-width: 80vw; 11 | box-sizing: content-box; 12 | z-index: 9000; 13 | font-size: 11px; 14 | line-height: 1.42857; 15 | 16 | display: flex; 17 | padding: 0px; 18 | margin: 0px; 19 | list-style: none; 20 | overflow-x: auto; 21 | overflow-y: visible; 22 | } 23 | .card-bubble li { 24 | display: block; 25 | position: relative; 26 | float: none; 27 | margin: 5px; 28 | box-sizing: border-box; 29 | box-shadow: var(--card-box-shadow, 0 2px 0 rgba(0, 0, 0, 0.12)); 30 | border-radius: 2px; 31 | 32 | width: 120px; 33 | height: 120px; 34 | } 35 | -------------------------------------------------------------------------------- /watchList.ts: -------------------------------------------------------------------------------- 1 | import type { ProjectId, UnixTime } from "./deps/scrapbox.ts"; 2 | import { listProjects } from "./deps/scrapbox-std.ts"; 3 | import { isErr, unwrapOk } from "./deps/option-t.ts"; 4 | 5 | export const getWatchList = async (): Promise => { 6 | const value = localStorage.getItem("projectsLastAccessed"); 7 | if (!value) return []; 8 | 9 | try { 10 | const list = JSON.parse(value) as Record; 11 | const ids = Object.entries(list).sort(([, a], [, b]) => b - a).map(( 12 | [projectId], 13 | ) => projectId); 14 | const result = await listProjects([]); 15 | if (isErr(result)) return ids; 16 | const joinedIds = unwrapOk(result).projects.map((project) => project.id); 17 | return ids.filter((id) => !joinedIds.includes(id)); 18 | } catch (e: unknown) { 19 | if (!(e instanceof SyntaxError)) throw e; 20 | return []; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /id.test.ts: -------------------------------------------------------------------------------- 1 | import { fromId, toId } from "./id.ts"; 2 | import { assertEquals } from "./deps/testing.ts"; 3 | 4 | Deno.test("toId()", () => { 5 | assertEquals(toId("project", "Page A"), "/project/page_a"); 6 | assertEquals( 7 | toId("Upper-Letter-Project", "Page A"), 8 | "/upper-letter-project/page_a", 9 | ); 10 | }); 11 | Deno.test("fromId()", () => { 12 | assertEquals(fromId("/project/page_a"), { 13 | project: "project", 14 | titleLc: "page_a", 15 | }); 16 | assertEquals(fromId("/project-test/page_a"), { 17 | project: "project-test", 18 | titleLc: "page_a", 19 | }); 20 | assertEquals(fromId("/project/prefix/page_a"), { 21 | project: "project", 22 | titleLc: "prefix/page_a", 23 | }); 24 | assertEquals(fromId("/project/Page A"), { 25 | project: "project", 26 | titleLc: "Page A", 27 | }); 28 | assertEquals(fromId("/upper-letter-project/Page A"), { 29 | project: "upper-letter-project", 30 | titleLc: "Page A", 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /position.ts: -------------------------------------------------------------------------------- 1 | export type Position = 2 | & { 3 | top: number; 4 | bottom: number; 5 | maxWidth: number; 6 | } 7 | & ({ 8 | left: number; 9 | } | { 10 | right: number; 11 | }); 12 | 13 | /** Bubbleの表示位置を計算する 14 | * 15 | * @param target このNodeに対してbubbleを表示する 16 | * @return 表示位置 17 | */ 18 | export const calcBubblePosition = (target: Element): Position => { 19 | // 表示位置を計算する 20 | const { top, right, left, bottom } = target.getBoundingClientRect(); 21 | const root = document.body.getBoundingClientRect(); 22 | // linkが画面の右寄りにあったら、bubbleを左側に出す 23 | const adjustRight = (left - root.left) / root.width > 0.5; 24 | 25 | return { 26 | top: Math.round(bottom - root.top), 27 | bottom: Math.round(globalThis.innerHeight - globalThis.scrollY - top), 28 | ...(adjustRight 29 | ? { right: Math.round(root.right - right) } 30 | : { left: Math.round(left - root.left) }), 31 | maxWidth: adjustRight 32 | ? right - 10 33 | : document.documentElement.clientWidth - left - 10, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /parseLink.ts: -------------------------------------------------------------------------------- 1 | export interface ScrapboxLink { 2 | href: string; 3 | pathType: "root" | "relative"; 4 | } 5 | 6 | /** Scrapbox記法のリンクから、project, title, hashを取り出す 7 | * 8 | * @param link 構文解析したScrapbox記法のリンク 9 | * @return 抽出したproject, title, hash 10 | */ 11 | export const parseLink = ( 12 | link: ScrapboxLink, 13 | ): { project: string; title?: string; hash?: string } | { 14 | project?: string; 15 | title: string; 16 | hash?: string; 17 | } => { 18 | root: if (link.pathType === "root") { 19 | const [, project = "", title = ""] = link.href.match( 20 | /\/([\w\-]+)(?:\/?|\/(.*))$/, 21 | ) ?? ["", "", ""]; 22 | if (project === "") break root; 23 | const [, hash] = title?.match?.(/#([a-f\d]{24,32})$/) ?? ["", ""]; 24 | return title === "" 25 | ? { project } 26 | : hash === "" 27 | ? { project, title } 28 | : { project, title: title.slice(0, -1 - hash.length), hash }; 29 | } 30 | const [, hash] = link.href.match(/#([a-f\d]{24,32})$/) ?? ["", ""]; 31 | return hash === "" 32 | ? { title: link.href } 33 | : { title: link.href.slice(0, -1 - hash.length), hash }; 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 takker 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 | -------------------------------------------------------------------------------- /throttle.ts: -------------------------------------------------------------------------------- 1 | export type Fn = () => Promise; 2 | 3 | /** 同時に一定数だけPromiseを走らせる函数 4 | * 5 | * 最後に追加したjobから実行する 6 | * 7 | * @param max 同時に動かすPromiseの最大数 8 | * @return jobを動かして結果を返す函数 9 | */ 10 | export const makeThrottle = (max: number): (job: Fn) => Promise => { 11 | const queue: [ 12 | Fn, 13 | (value: T) => void, 14 | (error: unknown) => void, 15 | ][] = []; 16 | const pendings = new Set>(); 17 | 18 | const runNext = (prev: Promise) => { 19 | pendings.delete(prev); 20 | const task = queue.pop(); 21 | if (!task) return; 22 | const promise: Promise = task[0]() 23 | .finally(() => runNext(promise)) 24 | .then((result) => task[1](result)) 25 | .catch((error) => task[2](error)); 26 | pendings.add(promise); 27 | }; 28 | 29 | return (job) => { 30 | if (pendings.size < max) { 31 | const promise = job().finally(() => runNext(promise)); 32 | pendings.add(promise); 33 | return promise; 34 | } 35 | return new Promise((resolve, reject) => { 36 | queue.push([job, resolve, reject]); 37 | }); 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /eventEmitter.ts: -------------------------------------------------------------------------------- 1 | export type Listener = (event: T) => void; 2 | 3 | export interface EventEmitter { 4 | /** eventを発行する */ 5 | dispatch: (eventName: T, value: U) => void; 6 | 7 | /** 特定のeventが発火したときに実行するlistenerを登録する */ 8 | on: (eventName: T, listener: Listener) => void; 9 | 10 | /** listenerの登録を解除する */ 11 | off: (eventName: T, listener: Listener) => void; 12 | } 13 | 14 | export const makeEmitter = (): EventEmitter => { 15 | const listenersMap = new Map>>(); 16 | 17 | return { 18 | dispatch: (eventName, value) => { 19 | const listeners = listenersMap.get(eventName); 20 | if (!listeners) return; 21 | for (const listener of listeners) { 22 | listener(value); 23 | } 24 | }, 25 | on: (eventName, listener) => { 26 | const listeners = listenersMap.get(eventName) ?? new Set>(); 27 | listeners.add(listener); 28 | listenersMap.set(eventName, listeners); 29 | }, 30 | off: (eventName, listener) => { 31 | const listeners = listenersMap.get(eventName); 32 | if (!listeners) return; 33 | listeners.delete(listener); 34 | }, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /scripts/minifyCSS.ts: -------------------------------------------------------------------------------- 1 | import { build, initialize } from "../deps/esbuild.ts"; 2 | 3 | // prepare esbuild 4 | await initialize({ 5 | wasmURL: "https://cdn.jsdelivr.net/npm/esbuild-wasm@0.21.5/esbuild.wasm", 6 | worker: false, 7 | }); 8 | 9 | // bundle & minify app.css 10 | const name = "file-loader"; 11 | const { outputFiles: [css] } = await build({ 12 | entryPoints: [new URL("../app.css", import.meta.url).href], 13 | bundle: true, 14 | minify: true, 15 | write: false, 16 | plugins: [{ 17 | name, 18 | setup: ({ onLoad, onResolve }) => { 19 | onResolve({ filter: /.*/ }, ({ path, importer }) => { 20 | return { 21 | path: importer ? new URL(path, importer).href : path, 22 | namespace: name, 23 | }; 24 | }); 25 | onLoad({ filter: /.*/, namespace: name }, async ({ path }) => ({ 26 | contents: await (await fetch(new URL(path))).text(), 27 | loader: "css", 28 | })); 29 | }, 30 | }], 31 | }); 32 | 33 | // create app.min.css.ts 34 | await Deno.writeTextFile( 35 | new URL("../app.min.css.ts", import.meta.url), 36 | `// deno-fmt-ignore-file\nexport const CSS = String.raw\`${css.text}\`;`, 37 | ); 38 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext", 7 | "deno.ns" 8 | ] 9 | }, 10 | "lint": { 11 | "rules": { 12 | "tags": [ 13 | "recommended", 14 | "jsr", 15 | "jsx", 16 | "react" 17 | ] 18 | } 19 | }, 20 | "tasks": { 21 | "check": { 22 | "command": "deno fmt --check && deno lint", 23 | "dependencies": [ 24 | "type-check", 25 | "test" 26 | ] 27 | }, 28 | "fix": { 29 | "command": "deno fmt && deno lint --fix", 30 | "dependencies": [ 31 | "type-check", 32 | "test" 33 | ] 34 | }, 35 | "refresh-lock": "rm deno.lock && deno task check", 36 | "test": "deno test --allow-env=NODE_ENV --allow-read --parallel", 37 | "type-check": "deno check --remote **/*.ts **/*.tsx", 38 | "update": "MYDIR=$(mktemp -d) && TMPDIR=$MYDIR deno run --allow-env=TMPDIR,XDG_DATA_HOME,HOME,GITHUB_TOKEN --allow-read --allow-write=\"$MYDIR\",\"~/.local\" --allow-run=git,\"~/.deno/bin/deno\" --allow-net=jsr.io,registry.npmjs.org jsr:@molt/cli --dry-run --no-lock deps/*.ts deps/*.tsx" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /statusBar.css: -------------------------------------------------------------------------------- 1 | .status-bar { 2 | display: inline-block; 3 | position: absolute; 4 | background-color: var(--page-bg, #fefefe); 5 | cursor: default; 6 | } 7 | .status-bar > * { 8 | border: 1px solid var(--status-bar-border-color, #a9aaaf); 9 | } 10 | 11 | .status-bar.top-left { 12 | top: 0; 13 | left: 0; 14 | } 15 | .status-bar.top-left > * { 16 | border-top: none; 17 | border-left: none; 18 | } 19 | .status-bar.top-left :last-of-type { 20 | border-bottom-right-radius: 3px; 21 | } 22 | 23 | .status-bar.top-right { 24 | top: 0; 25 | right: 0; 26 | } 27 | .status-bar.top-right > * { 28 | border-top: none; 29 | border-right: none; 30 | } 31 | .status-bar.top-right :last-of-type { 32 | border-bottom-left-radius: 3px; 33 | } 34 | 35 | .status-bar.bottom-right { 36 | bottom: 0; 37 | right: 0; 38 | } 39 | .status-bar.bottom-right > * { 40 | border-bottom: none; 41 | border-right: none; 42 | } 43 | .status-bar.bottom-right :last-of-type { 44 | border-top-left-radius: 3px; 45 | } 46 | 47 | .status-bar.bottom-left { 48 | bottom: 0; 49 | left: 0; 50 | } 51 | .status-bar.bottom-left > * { 52 | border-bottom: none; 53 | border-left: none; 54 | } 55 | .status-bar.bottom-left :last-of-type { 56 | border-top-right-radius: 3px; 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: update 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - id: molt 18 | uses: hasundue/molt-action@v1 19 | with: 20 | pull-request: false 21 | lock: false 22 | source: |- 23 | deps/*.ts 24 | deps/*.tsx 25 | - uses: denoland/setup-deno@v2 26 | if: steps.molt.outputs.files != '' 27 | with: 28 | deno-version: "v2" 29 | - name: Update deno.lock 30 | if: steps.molt.outputs.files != '' 31 | run: deno task refresh-lock 32 | - name: Create Pull Request 33 | if: steps.molt.outputs.files != '' 34 | uses: peter-evans/create-pull-request@v7 35 | with: 36 | token: ${{ secrets.GITHUB_TOKEN }} 37 | branch: molt-action 38 | base: main 39 | title: ${{ steps.molt.outputs.summary }} 40 | body: ${{ steps.molt.outputs.report }} 41 | labels: dependencies 42 | sign-commits: true 43 | delete-branch: true 44 | -------------------------------------------------------------------------------- /useKaTeX.ts: -------------------------------------------------------------------------------- 1 | import { type RefCallback, useCallback, useState } from "./deps/preact.tsx"; 2 | import { defaultVersion, importKaTeX, type KatexOptions } from "./katex.ts"; 3 | 4 | export interface ParseError { 5 | name: string; 6 | message: string; 7 | position: number; 8 | } 9 | 10 | export interface UseKaTeXResult { 11 | ref: RefCallback; 12 | error: string; 13 | } 14 | 15 | export const useKaTeX = ( 16 | formula: string, 17 | options?: KatexOptions, 18 | ): UseKaTeXResult => { 19 | const [error, setError] = useState(""); 20 | 21 | const ref: RefCallback = useCallback( 22 | (element) => { 23 | if (!element) return; 24 | 25 | importKaTeX(defaultVersion).then((katex) => { 26 | try { 27 | katex.render(formula, element, options); 28 | setError(""); 29 | } catch (e) { 30 | if (e instanceof Error && e.name === "ParseError") { 31 | // remove an unnecessary token 32 | setError(e.message.slice("KaTeX parse error: ".length)); 33 | } else { 34 | throw e; 35 | } 36 | } 37 | }); 38 | }, 39 | [formula, ...Object.values(options ?? {})], 40 | ); 41 | return { ref, error }; 42 | }; 43 | -------------------------------------------------------------------------------- /useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "./deps/preact.tsx"; 2 | 3 | type EventMap = E extends Window ? WindowEventMap 4 | : E extends Document ? DocumentEventMap 5 | : E extends HTMLElement ? HTMLElementEventMap 6 | : GlobalEventHandlersEventMap; 7 | type Listener> = E extends 8 | Window ? (this: Window, ev: EventMap[K]) => void 9 | : E extends Document ? (this: Document, ev: EventMap[K]) => void 10 | : E extends HTMLElement ? (this: HTMLElement, ev: EventMap[K]) => void 11 | : EventListenerOrEventListenerObject; 12 | 13 | export const useEventListener = < 14 | E extends EventTarget, 15 | K extends (keyof EventMap) & string, 16 | >( 17 | element: E, 18 | type: K, 19 | listener: Listener, 20 | options?: boolean | AddEventListenerOptions, 21 | deps?: unknown[], 22 | ): void => { 23 | useEffect(() => { 24 | element.addEventListener( 25 | type, 26 | listener as EventListenerOrEventListenerObject, 27 | options, 28 | ); 29 | return () => 30 | element.removeEventListener( 31 | type, 32 | listener as EventListenerOrEventListenerObject, 33 | options, 34 | ); 35 | }, [element, type, options, ...(deps ?? [])]); 36 | }; 37 | -------------------------------------------------------------------------------- /hasLink.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "./deps/scrapbox-parser.ts"; 2 | import { parseLink } from "./parseLink.ts"; 3 | import type { LinkTo } from "./types.ts"; 4 | import { toTitleLc } from "./deps/scrapbox-std.ts"; 5 | 6 | /** 指定したリンクがScrapboxのページ中に存在するか調べる 7 | * 8 | * @param link 調べたいリンクのタイトル 9 | * @param nodes ページの構文解析結果 10 | * @return 見つかったら`true` 11 | */ 12 | export const hasLink = ( 13 | link: LinkTo, 14 | nodes: Node[], 15 | ): boolean => 16 | nodes.some((node) => { 17 | const isRelative = !link.project; 18 | switch (node.type) { 19 | case "hashTag": 20 | return isRelative && 21 | toTitleLc(node.href) === link.titleLc; 22 | case "link": { 23 | if (node.pathType == "absolute") return false; 24 | if ((node.pathType === "relative") !== isRelative) return false; 25 | 26 | const { project, title = "" } = parseLink({ 27 | pathType: node.pathType, 28 | href: node.href, 29 | }); 30 | return isRelative 31 | ? !project && toTitleLc(title) === link.titleLc 32 | : project === link.project && 33 | toTitleLc(title) === link.titleLc; 34 | } 35 | case "quote": 36 | case "strong": 37 | case "decoration": 38 | return hasLink(link, node.nodes); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /type-traits.ts: -------------------------------------------------------------------------------- 1 | // https://techracho.bpsinc.jp/yoshi/2020_09_04/97108 2 | type Digits = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; 3 | type Tail = T extends `${Digits}${infer U}` ? U : never; 4 | type First = T extends `${infer U}${Tail}` ? U : never; 5 | type DigitsStr = `${Digits}`; 6 | type Tile = [ 7 | [], 8 | [...T], 9 | [...T, ...T], 10 | [...T, ...T, ...T], 11 | [...T, ...T, ...T, ...T], 12 | [...T, ...T, ...T, ...T, ...T], 13 | [...T, ...T, ...T, ...T, ...T, ...T], 14 | [...T, ...T, ...T, ...T, ...T, ...T, ...T], 15 | [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], 16 | [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], 17 | [...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T, ...T], 18 | ][N]; 19 | type MakeTupleImpl = 20 | string extends N ? never 21 | // 文字列リテラルじゃなくて string 型が渡ってきた場合は変換できない 22 | : N extends "" ? X 23 | // if (src == '') { return x; } 24 | : First extends infer U ? U extends DigitsStr // const ch = src[0] 25 | ? MakeTupleImpl< 26 | T, 27 | Tail, // src.slice(1) 28 | [...Tile<[T], U>, ...Tile /* x * 10 */] 29 | > 30 | : never 31 | : never; 32 | export type MakeTuple = MakeTupleImpl; 33 | -------------------------------------------------------------------------------- /mod.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { render } from "./deps/preact.tsx"; 4 | import { getWatchList } from "./watchList.ts"; 5 | import { App, type AppProps, userscriptName } from "./App.tsx"; 6 | import { setDebugMode } from "./debug.ts"; 7 | import type { ProjectId, Scrapbox } from "./deps/scrapbox.ts"; 8 | declare const scrapbox: Scrapbox; 9 | 10 | export type { AppProps }; 11 | export interface MountInit 12 | extends Partial> { 13 | /** debug用有効化フラグ */ 14 | debug?: boolean | Iterable; 15 | 16 | /** 透過的に扱うprojectのリスト */ 17 | whiteList?: Iterable; 18 | 19 | /** watch list */ 20 | watchList?: Iterable; 21 | } 22 | 23 | export const mount = async (init?: MountInit): Promise => { 24 | const { 25 | delay = 500, 26 | whiteList = [], 27 | watchList = (await getWatchList()).slice(0, 100), 28 | style = "", 29 | debug = false, 30 | } = init ?? {}; 31 | 32 | setDebugMode(debug); 33 | const app = document.createElement("div"); 34 | app.dataset.userscriptName = userscriptName; 35 | document.body.append(app); 36 | const shadowRoot = app.attachShadow({ mode: "open" }); 37 | render( 38 | , 44 | shadowRoot, 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /useBubbleData.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "./deps/preact.tsx"; 2 | import { load, subscribe, unsubscribe } from "./bubble.ts"; 3 | import { createDebug } from "./debug.ts"; 4 | import type { ID } from "./id.ts"; 5 | import type { Bubble } from "./storage.ts"; 6 | 7 | const logger = createDebug("ScrapBubble:useBubbleData.ts"); 8 | 9 | /** bubbleデータを取得するhooks 10 | * 11 | * @param pageIds 取得するページのID 12 | * @returns bubble data 13 | */ 14 | export const useBubbleData = ( 15 | pageIds: readonly ID[], 16 | ): readonly Bubble[] => { 17 | const [bubbles, setBubbles] = useState( 18 | makeBubbles(pageIds), 19 | ); 20 | 21 | // データ更新用listenerの登録 22 | useEffect(() => { 23 | setBubbles(makeBubbles(pageIds)); 24 | 25 | let timer: number | undefined; 26 | /** ページデータを更新する */ 27 | const updateData = () => { 28 | // 少し待ってからまとめて更新する 29 | clearTimeout(timer); 30 | timer = setTimeout(() => { 31 | logger.debug(`Update ${pageIds.length} pages`); 32 | setBubbles(makeBubbles(pageIds)); 33 | }, 10); 34 | }; 35 | 36 | // 更新を購読する 37 | pageIds.forEach((id) => subscribe(id, updateData)); 38 | return () => pageIds.forEach((id) => unsubscribe(id, updateData)); 39 | }, pageIds); 40 | 41 | // debug用 42 | 43 | return bubbles; 44 | }; 45 | 46 | const makeBubbles = (pageIds: readonly ID[]): readonly Bubble[] => { 47 | const bubbles = [...load(pageIds)].flatMap((bubble) => 48 | bubble ? [bubble] : [] 49 | ); 50 | 51 | // debug用 52 | logger.debug( 53 | `Required: ${pageIds.length} pages, ${bubbles.length} found`, 54 | bubbles, 55 | ); 56 | return bubbles; 57 | }; 58 | -------------------------------------------------------------------------------- /hasLink.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "./deps/scrapbox-parser.ts"; 2 | import { hasLink } from "./hasLink.ts"; 3 | import type { LinkTo } from "./types.ts"; 4 | import { assertEquals } from "./deps/testing.ts"; 5 | 6 | Deno.test("hasLink()", async (t) => { 7 | const text = ` 8 | サンプルテキスト 9 | \`[これはリンクではない]\`けど、[こっち]はリンク 10 | [scrapbox] 11 | code:js 12 | これも[リンク]にはならない 13 | #hashtag も検知できるはず 14 | [大文字Aと小文字a]が入り混じっていてもいい 15 | table:teset 16 | [link in table] 17 | [/project/external link] 18 | `; 19 | const nodes = parse(text).flatMap((block) => { 20 | switch (block.type) { 21 | case "codeBlock": 22 | case "title": 23 | return []; 24 | case "table": 25 | return block.cells.flat().flat(); 26 | case "line": 27 | return block.nodes; 28 | } 29 | }); 30 | 31 | const links = [ 32 | [{ titleLc: "こっち" }, true], 33 | [{ titleLc: "Scrapbox" }, false], 34 | [{ titleLc: "scrapbox" }, true], 35 | [ 36 | { titleLc: "これはリンクではない" }, 37 | false, 38 | ], 39 | [{ titleLc: "HashTag" }, false], 40 | [{ titleLc: "hashtag" }, true], 41 | [{ titleLc: "大文字aと小文字a" }, true], 42 | [{ titleLc: "link_in_table" }, true], 43 | [{ titleLc: "/project/external link" }, false], 44 | [{ project: "project", titleLc: "external link" }, false], 45 | [{ project: "project", titleLc: "external_link" }, true], 46 | ] as [LinkTo, boolean][]; 47 | for (const [link, result] of links) { 48 | await t.step( 49 | `the text ${result ? "has" : "doesn't have"} ${ 50 | !("project" in link) ? link.titleLc : `/${link.project}/${link.titleLc}` 51 | }`, 52 | () => { 53 | assertEquals(hasLink(link, nodes), result); 54 | }, 55 | ); 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /parseLink.test.ts: -------------------------------------------------------------------------------- 1 | import { parseLink } from "./parseLink.ts"; 2 | import { assertEquals } from "./deps/testing.ts"; 3 | 4 | Deno.test("external link", () => { 5 | assertEquals( 6 | parseLink({ 7 | pathType: "root", 8 | href: "/project/title#61b5253a1280f00000003d6b5e", 9 | }), 10 | { 11 | project: "project", 12 | title: "title", 13 | hash: "61b5253a1280f00000003d6b5e", 14 | }, 15 | ); 16 | assertEquals( 17 | parseLink({ pathType: "root", href: "/project/title#invalidId" }), 18 | { 19 | project: "project", 20 | title: "title#invalidId", 21 | }, 22 | ); 23 | assertEquals(parseLink({ pathType: "root", href: "/project/title" }), { 24 | project: "project", 25 | title: "title", 26 | }); 27 | assertEquals(parseLink({ pathType: "root", href: "/project/" }), { 28 | project: "project", 29 | }); 30 | assertEquals(parseLink({ pathType: "root", href: "/project" }), { 31 | project: "project", 32 | }); 33 | assertEquals( 34 | parseLink({ pathType: "root", href: "/project/#title-with-#" }), 35 | { 36 | project: "project", 37 | title: "#title-with-#", 38 | }, 39 | ); 40 | }); 41 | Deno.test("internal link", () => { 42 | assertEquals( 43 | parseLink({ 44 | pathType: "relative", 45 | href: "title#61b5253a1280f00000003d6b5e", 46 | }), 47 | { 48 | title: "title", 49 | hash: "61b5253a1280f00000003d6b5e", 50 | }, 51 | ); 52 | assertEquals( 53 | parseLink({ pathType: "relative", href: "title#invalidId" }), 54 | { 55 | title: "title#invalidId", 56 | }, 57 | ); 58 | assertEquals(parseLink({ pathType: "relative", href: "title" }), { 59 | title: "title", 60 | }); 61 | assertEquals(parseLink({ pathType: "relative", href: "title/with#/" }), { 62 | title: "title/with#/", 63 | }); 64 | }); 65 | 66 | Deno.test("project-like title", () => { 67 | assertEquals(parseLink({ pathType: "root", href: "/*@__PURE__*/" }), { 68 | title: "/*@__PURE__*/", 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /useProject.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "./deps/preact.tsx"; 2 | import { 3 | type FetchError, 4 | getProject, 5 | type ProjectError, 6 | } from "./deps/scrapbox-std.ts"; 7 | import { cacheFirstFetch } from "./cache.ts"; 8 | import { makeEmitter } from "./eventEmitter.ts"; 9 | import type { MemberProject, NotMemberProject } from "./deps/scrapbox.ts"; 10 | import { createDebug } from "./debug.ts"; 11 | import type { Result } from "./deps/option-t.ts"; 12 | 13 | export type ProjectResult = Result< 14 | NotMemberProject | MemberProject, 15 | ProjectError | FetchError 16 | >; 17 | type State = { loading: true } | { loading: false; value: T }; 18 | const emitter = makeEmitter(); 19 | 20 | const projectMap = new Map>(); 21 | 22 | const logger = createDebug("ScrapBubble:useProject.ts"); 23 | 24 | /** /api/projects/:projectの結果を返すhook 25 | * 26 | * @param project 情報を取得したいprojectの名前 27 | * @return projectの情報。未初期化もしくは読み込み中のときは`undefined`を返す 28 | */ 29 | export const useProject = (project: string): ProjectResult | undefined => { 30 | const [projectResult, setProjectResult] = useState(); 31 | 32 | useEffect(() => { 33 | emitter.on(project, setProjectResult); 34 | 35 | const state = projectMap.get(project); 36 | if (state) { 37 | setProjectResult(state.loading ? undefined : state.value); 38 | } else { 39 | projectMap.set(project, { loading: true }); 40 | setProjectResult(undefined); 41 | 42 | // projectの情報を取得する 43 | (async () => { 44 | try { 45 | const req = getProject.toRequest(project); 46 | for await (const [, res] of cacheFirstFetch(req)) { 47 | const result = await getProject.fromResponse(res); 48 | projectMap.set(project, { loading: false, value: result }); 49 | emitter.dispatch(project, result); 50 | // networkからcacheを更新する必要はないので、1 loopで処理を終える 51 | break; 52 | } 53 | } catch (e: unknown) { 54 | // 想定外のエラーはログに出す 55 | logger.error(e); 56 | // 未初期化状態に戻す 57 | projectMap.delete(project); 58 | } 59 | })(); 60 | } 61 | 62 | return () => emitter.off(project, setProjectResult); 63 | }, [project]); 64 | 65 | return projectResult; 66 | }; 67 | -------------------------------------------------------------------------------- /debug.ts: -------------------------------------------------------------------------------- 1 | let debugMode: boolean | Set = false; 2 | 3 | /** cache周りのdebug出力の有効・無効の切り替えを行う 4 | * 5 | * @param mode `true`:全てのdebug出力を有効にする, `false`:全てのdebug出力を無効にする, それ以外:指定したファイル中のdebug出力のみ有効にする 6 | */ 7 | export const setDebugMode = (mode: boolean | Iterable): void => { 8 | debugMode = typeof mode === "boolean" ? mode : new Set(mode); 9 | }; 10 | 11 | /** debug modeのときだけ有効なconsoleをファイルごとに作る 12 | * 13 | * @param filename コンソール出力を呼び出したファイル名 14 | */ 15 | export const createDebug = (filename: string): Console => 16 | Object.fromEntries([...Object.entries(console)].map( 17 | ([key, value]: [string, unknown]) => { 18 | if (typeof value !== "function") return [key, value]; 19 | switch (key as keyof Console) { 20 | case "warn": 21 | case "error": 22 | return [ 23 | key, 24 | (...args: unknown[]) => 25 | value(`%c${filename}`, "color: gray", ...args), 26 | ]; 27 | case "log": 28 | case "info": 29 | case "debug": 30 | return [key, (...args: unknown[]) => { 31 | if ( 32 | debugMode !== true && (!debugMode || !debugMode.has(filename)) 33 | ) { 34 | return; 35 | } 36 | value(`%c${filename}`, "color: gray", ...args); 37 | }]; 38 | case "assert": 39 | return [key, (assertion: boolean, ...args: unknown[]) => { 40 | if ( 41 | debugMode !== true && (!debugMode || !debugMode.has(filename)) 42 | ) { 43 | return; 44 | } 45 | value(assertion, `%c${filename}`, "color: gray", ...args); 46 | }]; 47 | case "time": 48 | case "timeEnd": 49 | return [key, (label: string) => { 50 | if ( 51 | debugMode !== true && (!debugMode || !debugMode.has(filename)) 52 | ) { 53 | return; 54 | } 55 | value(`${filename} ${label}`); 56 | }]; 57 | default: 58 | return [key, (...args: unknown[]) => { 59 | if ( 60 | debugMode !== true && (!debugMode || !debugMode.has(filename)) 61 | ) { 62 | return; 63 | } 64 | return value(...args); 65 | }]; 66 | } 67 | }, 68 | )) as unknown as Console; 69 | -------------------------------------------------------------------------------- /textBubble.css: -------------------------------------------------------------------------------- 1 | @import "./page.css"; 2 | @import "./statusBar.css"; 3 | 4 | .text-bubble { 5 | font-size: 11px; 6 | line-height: 1.42857; 7 | user-select: text; 8 | position: absolute; 9 | color: var(--page-text-color, #4a4a4a); 10 | box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); 11 | display: flex; 12 | flex-direction: column; 13 | 14 | z-index: 9000; 15 | 16 | &.no-scroll { 17 | overflow-y: hidden; 18 | } 19 | 20 | [data-theme="default-dark"] { 21 | --text-bubble-border-color: hsl(0, 0%, 39%); 22 | } 23 | 24 | [data-theme="default-minimal"] { 25 | --text-bubble-border-color: hsl(0, 0%, 89%); 26 | } 27 | 28 | [data-theme="paper-light"] { 29 | --text-bubble-border-color: hsl(53, 8%, 58%); 30 | } 31 | 32 | [data-theme="paper-dark-dark"] { 33 | --text-bubble-border-color: hsl(203, 42%, 17%); 34 | } 35 | 36 | [data-theme="blue"] { 37 | --text-bubble-border-color: hsl(227, 68%, 62%); 38 | } 39 | 40 | [data-theme="purple"] { 41 | --text-bubble-border-color: hsl(267, 39%, 60%); 42 | } 43 | 44 | [data-theme="green"] { 45 | --text-bubble-border-color: hsl(136, 29%, 50%); 46 | } 47 | 48 | [data-theme="orange"] { 49 | --text-bubble-border-color: hsl(43, 71%, 51%); 50 | } 51 | 52 | [data-theme="red"] { 53 | --text-bubble-border-color: hsl(4, 58%, 56%); 54 | } 55 | 56 | [data-theme="spring"] { 57 | --text-bubble-border-color: hsl(72, 64%, 57%); 58 | } 59 | 60 | [data-theme="kyoto"] { 61 | --text-bubble-border-color: hsl(331, 21%, 26%); 62 | } 63 | 64 | [data-theme="newyork"] { 65 | --text-bubble-border-color: hsl(176, 29%, 67%); 66 | } 67 | 68 | [role="tabpanel"] { 69 | padding: 5px 0px 5px 5px; 70 | border-radius: 4px; 71 | background-color: var(--page-bg, #fefefe); 72 | max-height: 80vh; 73 | overflow-y: auto; 74 | } 75 | 76 | [role="tabpanel"], 77 | button { 78 | border: 1px solid var(--text-bubble-border-color, hsl(221, 15%, 25%)); 79 | } 80 | 81 | button { 82 | cursor: default; 83 | color: inherit; 84 | font-size: inherit; 85 | line-height: inherit; 86 | background-color: var(--page-bg, #fefefe); 87 | border-radius: 4px 4px 0 0; 88 | } 89 | 90 | button[aria-selected="true"] { 91 | background-color: var(--text-bubble-border-color, hsl(221, 15%, 25%)); 92 | } 93 | } 94 | 95 | .project-badge { 96 | text-decoration: none; 97 | color: var(--tool-text-color, #363c49); 98 | } 99 | -------------------------------------------------------------------------------- /throttle.test.ts: -------------------------------------------------------------------------------- 1 | import { makeThrottle } from "./throttle.ts"; 2 | import { assertEquals } from "./deps/testing.ts"; 3 | 4 | Deno.test("makeThrottle()", async (t) => { 5 | await t.step("順番に実行", async () => { 6 | const throttle = makeThrottle(10); 7 | 8 | const queue: number[] = []; 9 | const resolved = await Promise.all( 10 | [...Array(10).keys()].map((_, i) => 11 | throttle(() => { 12 | queue.push(i); 13 | return Promise.resolve(i); 14 | }) 15 | ), 16 | ); 17 | 18 | assertEquals(queue, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 19 | assertEquals(resolved, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 20 | }); 21 | await t.step( 22 | "thresholdより多いjobsは、最後に追加されたものから実行される", 23 | async () => { 24 | for (let i = 1; i < 10; i++) { 25 | const throttle = makeThrottle(10 - i); 26 | 27 | const queue: number[] = []; 28 | const resolved = await Promise.all( 29 | [...Array(10).keys()].map((_, i) => 30 | throttle(() => { 31 | queue.push(i); 32 | return Promise.resolve(i); 33 | }) 34 | ), 35 | ); 36 | const result = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; 37 | const splitted = result.splice(10 - i, i).reverse(); 38 | // e.g. max jobsが5(=10 - i)のときは、result = [0,1,2,3,4,9,8,7,6,5]となる 39 | result.push(...splitted); 40 | 41 | assertEquals(queue, result); 42 | assertEquals(resolved, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 43 | } 44 | }, 45 | ); 46 | 47 | await t.step("一部を止める", async () => { 48 | const throttle = makeThrottle(3); 49 | 50 | const queue: number[] = []; 51 | const push = (i: number) => 52 | throttle(() => { 53 | queue.push(i); 54 | return Promise.resolve(i); 55 | }); 56 | 57 | let resolve: (() => void) | undefined; 58 | const resolved = await Promise.all([ 59 | push(0), 60 | push(1), 61 | throttle(() => 62 | new Promise((r) => resolve = r).then(() => { 63 | queue.push(2); 64 | return 2; 65 | }) 66 | ), 67 | push(3), 68 | push(4), 69 | push(5), 70 | push(6), 71 | throttle(() => { 72 | resolve?.(); 73 | queue.push(7); 74 | return Promise.resolve(7); 75 | }), 76 | push(8), 77 | push(9), 78 | ]); 79 | 80 | assertEquals(queue, [0, 1, 9, 8, 7, 6, 2, 5, 4, 3]); 81 | assertEquals(resolved, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /useBubbles.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from "./deps/preact.tsx"; 2 | import type { Position } from "./position.ts"; 3 | import type { LinkType } from "./types.ts"; 4 | import type { Scrapbox } from "./deps/scrapbox.ts"; 5 | declare const scrapbox: Scrapbox; 6 | 7 | /** bubble data */ 8 | export interface Source { 9 | /** bubble元(リンクなど)のproject name */ 10 | project: string; 11 | 12 | /** bubble元(リンクなど)のtilte */ 13 | title: string; 14 | 15 | /** bubble元(リンクなど)のline ID */ 16 | hash?: string; 17 | 18 | /** スクロール先リンク 19 | * 20 | * 内部リンク記法へスクロールするときは、`project`を削る 21 | */ 22 | linkTo?: { 23 | project?: string; 24 | titleLc: string; 25 | }; 26 | 27 | /** 発生源の種類 */ 28 | type: LinkType; 29 | 30 | /** bubbleの表示位置 */ 31 | position: Position; 32 | } 33 | 34 | export interface BubbleOperators { 35 | /** 現在の階層の下に新しいbubbleを出す 36 | * 37 | * すでにbubbleされていた場合、それ以降のbubbleを含めて消してから新しいのを出す 38 | */ 39 | bubble: (source: Source) => void; 40 | 41 | /** 現在の階層より下のbubblesをすべて消す */ 42 | hide: () => void; 43 | } 44 | 45 | export interface BubbleSource extends BubbleOperators { 46 | /** bubble data */ 47 | source: Source; 48 | 49 | /** 親階層のbubblesのページタイトル */ 50 | parentTitles: string[]; 51 | } 52 | 53 | export const useBubbles = (): [ 54 | BubbleOperators, 55 | ...BubbleSource[], 56 | ] => { 57 | const [bubbles, setBubbles] = useState<[ 58 | BubbleOperators, 59 | ...BubbleSource[], 60 | ]>([{ 61 | bubble: (source: Source) => change(0, source), 62 | hide: () => change(0), 63 | }]); 64 | 65 | const change = useCallback( 66 | ( 67 | depth: number, 68 | source?: Source, 69 | ) => { 70 | // 操作函数や他のbubbleデータから計算される値を追加する 71 | // 更新されたbubble以外は、objectの参照を壊さずにそのまま返す 72 | setBubbles(( 73 | [first, ...prev], 74 | ) => [ 75 | first, 76 | ...( 77 | source 78 | ? [ 79 | ...prev.slice(0, depth), 80 | source === prev.at(depth)?.source ? prev.at(depth)! : ({ 81 | source, 82 | parentTitles: [ 83 | scrapbox.Page.title ?? "", 84 | ...prev.slice(0, depth).map((bubble) => bubble.source.title), 85 | ], 86 | bubble: (source: Source) => change(depth + 1, source), 87 | hide: () => change(depth + 1), 88 | }), 89 | ] 90 | : [...prev.slice(0, depth)] 91 | ), 92 | ]); 93 | }, 94 | [], 95 | ); 96 | 97 | return bubbles; 98 | }; 99 | -------------------------------------------------------------------------------- /cache.ts: -------------------------------------------------------------------------------- 1 | import type { UnixTime } from "./deps/scrapbox.ts"; 2 | import { findLatestCache } from "./deps/scrapbox-std-browser.ts"; 3 | import { makeThrottle } from "./throttle.ts"; 4 | import { delay } from "./deps/async.ts"; 5 | 6 | const cacheVersion = "0.6.5"; // release前に更新する 7 | const cacheName = `ScrapBubble-${cacheVersion}`; 8 | const cache = await globalThis.caches.open(cacheName); 9 | 10 | // 古いcacheがあったら削除しておく 11 | // 他の操作をブロックする必要はない 12 | (async () => { 13 | for (const name of await globalThis.caches.keys()) { 14 | if (name.startsWith("ScrapBubble-") && name !== cacheName) { 15 | await globalThis.caches.delete(name); 16 | console.log(`[ScrapBubble] deleted old cache :"${name}"`); 17 | } 18 | } 19 | })(); 20 | 21 | /** 同時に3つまでfetchできるようにする函数 */ 22 | const throttle = makeThrottle(3); 23 | 24 | export interface CacheFirstFetchOptions extends CacheQueryOptions { 25 | /** 失敗したresponseをcacheに保存するかどうか 26 | * 27 | * @default `false` (保存しない) 28 | */ 29 | saveFailedResponse?: boolean; 30 | } 31 | 32 | /** cacheとnetworkから順番にresponseをとってくる 33 | * 34 | * networkからとってこなくていいときは、途中でiteratorをbreakすればfetchしない 35 | * 36 | * fetchは同時に3回までしかできないよう制限する 37 | * 38 | * @param req 要求 39 | * @param options cacheを探すときの設定 40 | */ 41 | export async function* cacheFirstFetch( 42 | req: Request, 43 | options?: CacheFirstFetchOptions, 44 | ): AsyncGenerator { 45 | // cacheから返す 46 | // まず自前のcache storageから失敗したresponseを取得し、なければscrapbox.ioのcacheから正常なresponseを探す 47 | const cachePromise = 48 | ((options?.saveFailedResponse ? cache.match(req) : undefined) ?? 49 | findLatestCache(req, options)).then((res) => ["cache", res] as const); 50 | { 51 | const timer = delay(1000).then(() => "timeout" as const); 52 | const first = await Promise.race([cachePromise, timer]); 53 | if (first !== "timeout") { 54 | // 1秒以内にcacheを読み込めたら、cache→networkの順に返す 55 | if (first[1]) { 56 | yield ["cache", first[1]]; 57 | } 58 | // networkから返す 59 | const res = await throttle(() => fetch(req)); 60 | if (!res.ok && options?.saveFailedResponse) { 61 | // scrapbox.ioは失敗したresponseをcacheしないので、自前のcacheに格納しておく 62 | await cache.put(req, res.clone()); 63 | } 64 | yield ["network", res]; 65 | } 66 | } 67 | 68 | // 1秒経ってもcacheの読み込みが終わらないときは、fetchを走らせ始める 69 | const networkPromise = throttle(() => fetch(req)) 70 | .then((res) => ["network", res] as const); 71 | const [type, res] = await Promise.race([cachePromise, networkPromise]); 72 | 73 | if (type === "network") { 74 | yield [type, res]; 75 | // networkPromiseが先にresolveしたら、cachePromiseはyieldせずに捨てる 76 | return; 77 | } 78 | if (res) yield [type, res]; 79 | yield await networkPromise; 80 | } 81 | 82 | /** 有効期限切れのresponseかどうか調べる 83 | * 84 | * @param response 調べるresponse 85 | * @param maxAge 寿命(単位はs) 86 | */ 87 | export const isExpired = ( 88 | response: Response, 89 | maxAge: UnixTime, 90 | ): boolean => { 91 | const updated = new Date(response.headers.get("Date") ?? 0).getTime() / 92 | 1000; 93 | return updated + maxAge < new Date().getTime() / 1000; 94 | }; 95 | -------------------------------------------------------------------------------- /deps/scrapbox.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | BaseLine as Line, 3 | MemberProject, 4 | NotFoundError, 5 | NotLoggedInError, 6 | NotMemberError, 7 | NotMemberProject, 8 | PageWithInfoboxDefinition, 9 | PageWithoutInfoboxDefinition, 10 | ProjectId, 11 | ProjectRelatedPage, 12 | RelatedPage, 13 | StringLc, 14 | UnixTime, 15 | } from "jsr:@cosense/types@0.11/rest"; 16 | export type { Scrapbox } from "jsr:@cosense/types@0.11/userscript"; 17 | 18 | import type { ErrorLike } from "jsr:@cosense/types@0.11/rest"; 19 | 20 | // cf. https://blog.uhy.ooo/entry/2021-04-09/typescript-is-any-as/#%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E5%AE%9A%E7%BE%A9%E5%9E%8B%E3%82%AC%E3%83%BC%E3%83%89%E3%81%AE%E5%BC%95%E6%95%B0%E3%81%AE%E5%9E%8B%E3%82%92%E3%81%A9%E3%81%86%E3%81%99%E3%82%8B%E3%81%8B 21 | function isNotNullish(data: unknown): data is Record { 22 | return data != null; 23 | } 24 | export function isScrapboxError(e: unknown): e is ErrorLike { 25 | try { 26 | const json = typeof e === "string" ? JSON.parse(e) : e; 27 | if (!isNotNullish(json)) return false; 28 | return typeof json.name === "string" && typeof json.message === "string"; 29 | } catch (e2: unknown) { 30 | if (e2 instanceof SyntaxError) return false; 31 | throw e2; 32 | } 33 | } 34 | 35 | import type { MakeTuple } from "../type-traits.ts"; 36 | const defaults = [ 37 | "default-light", 38 | "default-dark", 39 | "default-minimal", 40 | ] as const; 41 | const papers = [ 42 | "paper-light", 43 | "paper-dark-dark", 44 | "paper-dark", 45 | ] as const; 46 | const stationaries = [ 47 | "blue", 48 | "purple", 49 | "green", 50 | "orange", 51 | "red", 52 | ] as const; 53 | const hackers = [ 54 | "hacker1", 55 | "hacker2", 56 | ] as const; 57 | const seasons = [ 58 | "winter", 59 | "spring", 60 | "summer", 61 | "automn", 62 | ] as const; 63 | const tropicals = [ 64 | "tropical", 65 | ] as const; 66 | const cities = [ 67 | "kyoto", 68 | "newyork", 69 | "paris", 70 | ] as const; 71 | const games = [ 72 | "mred", 73 | "lgreen", 74 | ] as const; 75 | const lightThemes = [ 76 | "default-light", 77 | "default-minimal", 78 | "paper-light", 79 | ...stationaries, 80 | ...seasons, 81 | ...tropicals, 82 | ...cities, 83 | ...games, 84 | ] as const; 85 | export type LightTheme = typeof lightThemes[number]; 86 | export function isLightTheme(theme: string): theme is LightTheme { 87 | return (lightThemes as MakeTuple).includes(theme); 88 | } 89 | const darkThemes = [ 90 | "default-dark", 91 | "paper-dark-dark", 92 | ] as const; 93 | export type DarkTheme = typeof darkThemes[number]; 94 | export function isDarkTheme(theme: string): theme is DarkTheme { 95 | return (darkThemes as MakeTuple).includes(theme); 96 | } 97 | const themes = [ 98 | ...defaults, 99 | ...papers, 100 | ...stationaries, 101 | ...hackers, 102 | ...seasons, 103 | ...tropicals, 104 | ...cities, 105 | ...games, 106 | ] as const; 107 | export type Theme = typeof themes[number]; 108 | export function isTheme(theme: string): theme is Theme { 109 | return (themes as MakeTuple).includes(theme); 110 | } 111 | -------------------------------------------------------------------------------- /storage.ts: -------------------------------------------------------------------------------- 1 | import type { Line, StringLc } from "./deps/scrapbox.ts"; 2 | import type { ID } from "./id.ts"; 3 | import { produce } from "./deps/immer.ts"; 4 | 5 | export interface Bubble { 6 | /** project name */ 7 | project: string; 8 | 9 | /** page titleLc */ 10 | titleLc: StringLc; 11 | 12 | /** サムネイル */ 13 | image: string | null; 14 | 15 | /** サムネイル本文 */ 16 | descriptions: string[]; 17 | 18 | /** ページ本文 19 | * 20 | * 1行目にタイトルを入れる 21 | */ 22 | lines: Line[]; 23 | 24 | /** ページが存在すれば`true`*/ 25 | exists: boolean; 26 | 27 | /** ページの更新日時 */ 28 | updated: number; 29 | 30 | /** 内部リンク記法による逆リンクのリスト 31 | * 32 | * 未計算のときは`undefined`になる 33 | */ 34 | linked?: StringLc[]; 35 | 36 | /** `linked`が不正確な可能性がある場合は`true` */ 37 | isLinkedCorrect: boolean; 38 | 39 | /** 外部リンク記法による逆リンクのリスト 40 | * 41 | * 未計算のときは`undefined`になる 42 | */ 43 | projectLinked?: ID[]; 44 | } 45 | 46 | export type BubbleStorage = Map; 47 | 48 | /** 指定したリンクが空リンクかどうか判定する */ 49 | export const isEmptyLink = ( 50 | bubbles: Iterable, 51 | ): boolean => { 52 | let linked = 0; 53 | for (const bubble of bubbles) { 54 | if (!bubble) continue; 55 | 56 | // 中身があるなら空リンクでない 57 | if (bubble.exists) return false; 58 | 59 | linked += (bubble.linked?.length ?? 0) + 60 | (bubble.projectLinked?.length ?? 0); 61 | // 2つ以上のページから参照されているなら空リンクでない 62 | if (linked > 1) return false; 63 | } 64 | return linked < 2; 65 | }; 66 | 67 | /** bubbleを更新する 68 | * 69 | * update rule 70 | * 71 | * - 基本的に、updatedが大きい方を採用する 72 | * - linesは、更新日時にかかわらずdummyでないほうを採用する 73 | * - linkedとprojectLinkedは、undefinedでないほうを採用する 74 | * 75 | * 更新がなければ以前のobjectをそのまま返し、更新があれば新しいobjectで返す 76 | */ 77 | export const update = ( 78 | prev: Bubble | undefined, 79 | current: Readonly, 80 | ): Bubble | undefined => 81 | produce(prev, (draft) => { 82 | if (!draft) return current; 83 | 84 | if (draft.updated < current.updated) { 85 | // 更新日時が新しければ、そちらを採用する 86 | // linked, projectLinked, linesのみ別途判定する 87 | const { lines, linked, projectLinked, ...rest } = current; 88 | 89 | Object.assign(draft, rest); 90 | if (!isDummy(current)) draft.lines = lines; 91 | if (linked) draft.linked ??= linked; 92 | if (projectLinked) draft.projectLinked ??= projectLinked; 93 | 94 | return; 95 | } 96 | 97 | // `updated`が変化していない場合、変更されている可能性のあるpropertiesは 98 | // - `lines` 99 | // - `linked` 100 | // - `projectLinked` 101 | // に限られる 102 | 103 | // 本物の本文がやってきたら、そちらを採用する 104 | if (isDummy(draft) && !isDummy(current)) { 105 | draft.lines = current.lines; 106 | } 107 | // linkedは正確に取得したデータを優先する 108 | if (current.linked) { 109 | if (current.isLinkedCorrect) { 110 | draft.linked = current.linked; 111 | } else if ( 112 | !draft.isLinkedCorrect && 113 | (draft.linked?.length ?? 0) <= current.linked.length 114 | ) { 115 | draft.linked = current.linked; 116 | } 117 | } 118 | if (current.projectLinked) draft.projectLinked = current.projectLinked; 119 | }); 120 | 121 | /** linesがdescriptionからでっち上げられたデータかどうか判定する */ 122 | const isDummy = (bubble: Bubble): boolean => bubble.lines[0].id === "dummy"; 123 | -------------------------------------------------------------------------------- /CardList.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { Card } from "./Card.tsx"; 4 | import { type h, useMemo } from "./deps/preact.tsx"; 5 | import { useBubbleData } from "./useBubbleData.ts"; 6 | import type { Bubble } from "./storage.ts"; 7 | import type { LinkTo } from "./types.ts"; 8 | import { type ID, toId } from "./id.ts"; 9 | import type { BubbleOperators, Source } from "./useBubbles.ts"; 10 | 11 | export interface CardListProps { 12 | delay: number; 13 | prefetch: (project: string, title: string) => void; 14 | /** key ページカードのID, value: 逆リンク先 */ 15 | linked: Map; 16 | /** key ページカードのID, value: 逆リンク先 */ 17 | externalLinked: Map; 18 | bubble: BubbleOperators["bubble"]; 19 | source: Pick; 20 | projectsForSort: Set; 21 | onClick?: h.JSX.MouseEventHandler; 22 | } 23 | 24 | export const CardList = ({ 25 | source, 26 | linked, 27 | externalLinked, 28 | projectsForSort: projectsForSort_, 29 | ...props 30 | }: CardListProps) => { 31 | const ids = useMemo(() => [...linked.keys(), ...externalLinked.keys()], [ 32 | linked, 33 | externalLinked, 34 | ]); 35 | const cards = useBubbleData(ids); 36 | 37 | const projectsForSort = useMemo(() => [...projectsForSort_], [ 38 | projectsForSort_, 39 | ]); 40 | /** 更新日時降順とproject昇順に並び替えた関連ページデータ 41 | * 42 | * externalCardsは分けない 43 | */ 44 | const sortedCards = useMemo( 45 | () => { 46 | const compare = (a: Bubble, b: Bubble) => { 47 | const aIndex = projectsForSort.indexOf(a.project); 48 | const bIndex = projectsForSort.indexOf(b.project); 49 | 50 | if (aIndex === bIndex) return b.updated - a.updated; 51 | if (aIndex < 0) return 1; 52 | if (bIndex < 0) return -1; 53 | return aIndex - bIndex; 54 | }; 55 | 56 | return [...cards].sort(compare); 57 | }, 58 | [cards, projectsForSort], 59 | ); 60 | 61 | const cardStyle = useMemo(() => ({ 62 | bottom: `${source.position.bottom}px`, 63 | maxWidth: `${source.position.maxWidth}px`, 64 | ...("left" in source.position 65 | ? { 66 | left: `${source.position.left}px`, 67 | } 68 | : { 69 | right: `${source.position.right}px`, 70 | }), 71 | }), [ 72 | source.position, 73 | ]); 74 | 75 | return ( 76 |
    81 | {sortedCards.map(( 82 | { 83 | project, 84 | titleLc, 85 | lines: [{ text: title }], 86 | descriptions, 87 | image, 88 | }, 89 | ) => { 90 | const key = toId(project, titleLc); 91 | // hookの計算にラグがあり、linkToが見つからない場合がある 92 | const linkTo = linked.get(key) ?? externalLinked.get(key); 93 | 94 | return ( 95 |
  • 96 | 104 |
  • 105 | ); 106 | })} 107 |
108 | ); 109 | }; 110 | -------------------------------------------------------------------------------- /TextBubble.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { 4 | type FunctionComponent, 5 | type h, 6 | useCallback, 7 | useMemo, 8 | useState, 9 | } from "./deps/preact.tsx"; 10 | import { type ID, toId } from "./id.ts"; 11 | import { Page, type PageProps } from "./Page.tsx"; 12 | import type { Bubble } from "./storage.ts"; 13 | import type { BubbleOperators, Source } from "./useBubbles.ts"; 14 | import { useTheme } from "./useTheme.ts"; 15 | 16 | export interface TextBubble extends BubbleOperators { 17 | pages: [Bubble, ...Bubble[]]; 18 | whiteList: Set; 19 | delay: number; 20 | noIndent?: boolean; 21 | source: Source; 22 | prefetch: (project: string, title: string) => void; 23 | onClick?: h.JSX.MouseEventHandler; 24 | } 25 | export const TextBubble: FunctionComponent = ( 26 | { pages, onClick, source, whiteList, ...rest }, 27 | ) => { 28 | const [activeTab, setActiveTab] = useState( 29 | toId(pages[0].project, pages[0].titleLc), 30 | ); 31 | 32 | const pageStyle = useMemo(() => ({ 33 | top: `${source.position.top}px`, 34 | maxWidth: `${source.position.maxWidth}px`, 35 | ...("left" in source.position 36 | ? { 37 | left: `${source.position.left}px`, 38 | } 39 | : { 40 | right: `${source.position.right}px`, 41 | }), 42 | }), [source.position]); 43 | return ( 44 |
49 | {pages.length > 1 && 50 | ( 51 |
52 | {pages.map((page) => ( 53 | 61 | ))} 62 |
63 | )} 64 | {pages.map((page) => ( 65 | 76 | ))} 77 |
78 | ); 79 | }; 80 | 81 | const Tab: FunctionComponent< 82 | { 83 | project: string; 84 | titleLc: string; 85 | selected: boolean; 86 | tabSelector: (id: ID) => void; 87 | } 88 | > = ({ project, titleLc, tabSelector, selected }) => { 89 | const handleClick = useCallback(() => tabSelector(toId(project, titleLc)), [ 90 | project, 91 | titleLc, 92 | ]); 93 | const theme = useTheme(project); 94 | return ( 95 | 105 | ); 106 | }; 107 | 108 | const TabPanel: FunctionComponent<{ selected: boolean } & PageProps> = ( 109 | { selected, ...rest }, 110 | ) => { 111 | const theme = useTheme(rest.project); 112 | return ( 113 | 120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /detectURL.test.ts: -------------------------------------------------------------------------------- 1 | import { detectURL } from "./detectURL.ts"; 2 | import { assert, assertEquals } from "./deps/testing.ts"; 3 | 4 | Deno.test("Absolute Path", async (t) => { 5 | { 6 | const text = "https://example.com/test.css"; 7 | await t.step(text, () => { 8 | const url = detectURL(text); 9 | assert(url instanceof URL); 10 | if (url instanceof URL) assertEquals(url.href, text); 11 | }); 12 | } 13 | { 14 | const text = ".div { display: block; }"; 15 | await t.step(text, () => { 16 | const url = detectURL(text); 17 | assert(!(url instanceof URL)); 18 | assertEquals(url, text); 19 | }); 20 | } 21 | { 22 | const text = "./test.css"; 23 | await t.step(text, () => { 24 | const url = detectURL(text); 25 | assert(!(url instanceof URL)); 26 | assertEquals(url, text); 27 | }); 28 | } 29 | }); 30 | Deno.test("Relative Path", async (t) => { 31 | const base = "https://example.com/foo/bar/baz"; 32 | { 33 | const text = "https://example.com/test.css"; 34 | await t.step(text, () => { 35 | const url = detectURL(text, base); 36 | assert(url instanceof URL); 37 | if (url instanceof URL) assertEquals(url.href, text); 38 | }); 39 | } 40 | { 41 | const text = ".div { display: block; }"; 42 | await t.step(text, () => { 43 | const url = detectURL(text, base); 44 | assert(!(url instanceof URL)); 45 | assertEquals(url, text); 46 | }); 47 | } 48 | { 49 | const text = "#editor { display: block; }"; 50 | await t.step(text, () => { 51 | const url = detectURL(text, base); 52 | assert(!(url instanceof URL)); 53 | assertEquals(url, text); 54 | }); 55 | } 56 | { 57 | const text = "#editor { background: url('../../image.png'); }"; 58 | await t.step(text, () => { 59 | const url = detectURL(text, base); 60 | assert(!(url instanceof URL)); 61 | assertEquals(url, text); 62 | }); 63 | } 64 | { 65 | const text = "#editor { background: url('./image.png'); }"; 66 | await t.step(text, () => { 67 | const url = detectURL(text, base); 68 | assert(!(url instanceof URL)); 69 | assertEquals(url, text); 70 | }); 71 | } 72 | { 73 | const text = "#editor { background: url('/image.png'); }"; 74 | await t.step(text, () => { 75 | const url = detectURL(text, base); 76 | assert(!(url instanceof URL)); 77 | assertEquals(url, text); 78 | }); 79 | } 80 | { 81 | const text = "./test.css"; 82 | await t.step(text, () => { 83 | const url = detectURL(text, base); 84 | assert(url instanceof URL); 85 | assertEquals(url.href, "https://example.com/foo/bar/test.css"); 86 | }); 87 | } 88 | { 89 | const text = "../test.css"; 90 | await t.step(text, () => { 91 | const url = detectURL(text, base); 92 | assert(url instanceof URL); 93 | assertEquals(url.href, "https://example.com/foo/test.css"); 94 | }); 95 | } 96 | { 97 | const text = "../../hoge/test.css"; 98 | await t.step(text, () => { 99 | const url = detectURL(text, base); 100 | assert(url instanceof URL); 101 | assertEquals(url.href, "https://example.com/hoge/test.css"); 102 | }); 103 | } 104 | { 105 | const text = "/test.css"; 106 | await t.step(text, () => { 107 | const url = detectURL(text, base); 108 | assert(url instanceof URL); 109 | assertEquals(url.href, "https://example.com/test.css"); 110 | }); 111 | } 112 | { 113 | const text = "//test.com/test.css"; 114 | await t.step(text, () => { 115 | const url = detectURL(text, base); 116 | assert(url instanceof URL); 117 | assertEquals(url.href, "https://test.com/test.css"); 118 | }); 119 | } 120 | }); 121 | -------------------------------------------------------------------------------- /bubble.ts: -------------------------------------------------------------------------------- 1 | import { cacheFirstFetch, isExpired } from "./cache.ts"; 2 | import { type ID, toId } from "./id.ts"; 3 | import { type Listener, makeEmitter } from "./eventEmitter.ts"; 4 | import { type Bubble, type BubbleStorage, update } from "./storage.ts"; 5 | import { convert } from "./convert.ts"; 6 | import { createDebug } from "./debug.ts"; 7 | import { getPage } from "./deps/scrapbox-std.ts"; 8 | import type { ProjectId, UnixTime } from "./deps/scrapbox.ts"; 9 | import { isOk, unwrapOk } from "./deps/option-t.ts"; 10 | 11 | const logger = createDebug("ScrapBubble:bubble.ts"); 12 | 13 | const storage: BubbleStorage = new Map(); 14 | /** データを更新中のページのリスト */ 15 | const loadingIds = new Set(); 16 | const emitter = makeEmitter(); 17 | 18 | /** bubble dataを読み込む 19 | * 20 | * データの更新は行わない 21 | * 22 | * @param pageIds 取得したいページのリスト 23 | * @return pageの情報。未初期化のときは`undefined`を返す 24 | */ 25 | export function* load( 26 | pageIds: Iterable, 27 | ): Generator { 28 | for (const pageId of pageIds) { 29 | yield storage.get(pageId); 30 | } 31 | } 32 | 33 | /** 特定のページの更新を購読する */ 34 | export const subscribe = (pageId: ID, listener: Listener): void => 35 | emitter.on(pageId, listener); 36 | 37 | /** 特定のページの更新購読を解除する */ 38 | export const unsubscribe = (pageId: ID, listener: Listener): void => 39 | emitter.off(pageId, listener); 40 | 41 | export interface PrefetchOptions { 42 | /** networkからデータを取得しないときは`true`を渡す*/ 43 | ignoreFetch?: boolean; 44 | /** cacheの有効期限 (単位は秒) */ 45 | maxAge?: UnixTime; 46 | } 47 | 48 | /** ページデータを取得し、cacheに格納する 49 | * 50 | * cacheは一定間隔ごとに更新される 51 | */ 52 | export const prefetch = async ( 53 | title: string, 54 | projects: Iterable, 55 | watchList: Set, 56 | options?: PrefetchOptions, 57 | ): Promise => { 58 | const promises: Promise[] = []; 59 | // 最後に登録したものからfetchされるので、反転させておく 60 | // makeThrottleの仕様を参照 61 | for (const project of [...projects].reverse()) { 62 | const id = toId(project, title); 63 | if (loadingIds.has(id)) continue; 64 | promises.push( 65 | updateApiCache(project, title, watchList, options), 66 | ); 67 | } 68 | 69 | await Promise.all(promises); 70 | }; 71 | 72 | /** debug用カウンタ */ 73 | let counter = 0; 74 | 75 | /** cacheおよびnetworkから新しいページデータを取得する 76 | * 77 | * もし更新があれば、更新通知も発行する 78 | */ 79 | const updateApiCache = async ( 80 | project: string, 81 | title: string, 82 | watchList: Set, 83 | options?: PrefetchOptions, 84 | ): Promise => { 85 | const id = toId(project, title); 86 | if (loadingIds.has(id)) return; 87 | 88 | // 排他ロックをかける 89 | // これで同時に同じページの更新が走らないようにする 90 | loadingIds.add(id); 91 | const i = counter++; 92 | 93 | const timeTag = `[${i}] Check update ${id}`; 94 | logger.time(timeTag); 95 | try { 96 | const req = getPage.toRequest(project, title, { 97 | followRename: true, 98 | projects: [...watchList], 99 | }); 100 | 101 | for await ( 102 | const [type, res] of cacheFirstFetch(req, { 103 | ignoreSearch: true, 104 | saveFailedResponse: true, 105 | }) 106 | ) { 107 | logger.debug(`[${i}]${type} ${id}`); 108 | const result = await getPage.fromResponse(res); 109 | // 更新があればeventを発行する 110 | if (isOk(result)) { 111 | const converted = convert(project, unwrapOk(result)); 112 | for (const [bubbleId, bubble] of converted) { 113 | const prev = storage.get(bubbleId); 114 | const updatedBubble = update(prev, bubble); 115 | if (!updatedBubble) continue; 116 | if (prev === updatedBubble) continue; 117 | storage.set(bubbleId, updatedBubble); 118 | emitter.dispatch(bubbleId, bubble); 119 | } 120 | } 121 | 122 | if (options?.ignoreFetch === true) break; 123 | // 有効期限が切れているなら、新しくデータをnetworkから取ってくる 124 | if (type === "cache" && !isExpired(res, options?.maxAge ?? 60)) { 125 | break; 126 | } 127 | } 128 | } catch (e: unknown) { 129 | // 想定外のエラーはログに出す 130 | logger.error(e); 131 | } finally { 132 | // ロック解除 133 | loadingIds.delete(id); 134 | logger.timeEnd(timeTag); 135 | counter--; 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { Bubble } from "./Bubble.tsx"; 4 | import { UserCSS } from "./UserCSS.tsx"; 5 | import { CSS } from "./app.min.css.ts"; 6 | import { prefetch as prefetch_ } from "./bubble.ts"; 7 | import { useCallback, useEffect } from "./deps/preact.tsx"; 8 | import type { ProjectId, Scrapbox } from "./deps/scrapbox.ts"; 9 | import { toId } from "./id.ts"; 10 | import { isPageLink, isTitle } from "./is.ts"; 11 | import { defaultVersion } from "./katex.ts"; 12 | import { parseLink } from "./parseLink.ts"; 13 | import { calcBubblePosition } from "./position.ts"; 14 | import { stayHovering } from "./stayHovering.ts"; 15 | import type { LinkType } from "./types.ts"; 16 | import { useBubbles } from "./useBubbles.ts"; 17 | import { useEventListener } from "./useEventListener.ts"; 18 | declare const scrapbox: Scrapbox; 19 | 20 | export const userscriptName = "scrap-bubble"; 21 | 22 | export interface AppProps { 23 | /** hoverしてからbubbleを表示するまでのタイムラグ */ 24 | delay: number; 25 | 26 | /** 透過的に扱うprojectのリスト */ 27 | whiteList: Set; 28 | 29 | /** watch list */ 30 | watchList: Set; 31 | 32 | /** カスタムCSS 33 | * 34 | * URL or URL文字列の場合は、CSSファイルへのURLだとみなしてで読み込む 35 | * それ以外の場合は、インラインCSSとして 127 | 128 | {bubbles.map((bubble) => ( 129 | 136 | ))} 137 | 138 | ); 139 | }; 140 | 141 | const getLinkType = (element: HTMLSpanElement | HTMLAnchorElement): LinkType => 142 | isPageLink(element) 143 | ? (element.type === "link" ? "link" : "hashtag") 144 | : "title"; 145 | -------------------------------------------------------------------------------- /card.css: -------------------------------------------------------------------------------- 1 | .related-page-card[data-theme="default-dark"] { 2 | --card-title-bg: hsl(0, 0%, 39%); 3 | } 4 | .related-page-card[data-theme="default-minimal"] { 5 | --card-title-bg: hsl(0, 0%, 89%); 6 | } 7 | .related-page-card[data-theme="paper-light"] { 8 | --card-title-bg: hsl(53, 8%, 58%); 9 | } 10 | .related-page-card[data-theme="paper-dark-dark"] { 11 | --card-title-bg: hsl(203, 42%, 17%); 12 | } 13 | .related-page-card[data-theme="blue"] { 14 | --card-title-bg: hsl(227, 68%, 62%); 15 | } 16 | .related-page-card[data-theme="purple"] { 17 | --card-title-bg: hsl(267, 39%, 60%); 18 | } 19 | .text-bubble[data-theme="green"] { 20 | --card-title-bg: hsl(136, 29%, 50%); 21 | } 22 | .related-page-card[data-theme="orange"] { 23 | --card-title-bg: hsl(43, 71%, 51%); 24 | } 25 | .related-page-card[data-theme="red"] { 26 | --card-title-bg: hsl(4, 58%, 56%); 27 | } 28 | .related-page-card[data-theme="spring"] { 29 | --card-title-bg: hsl(72, 64%, 57%); 30 | } 31 | .related-page-card[data-theme="kyoto"] { 32 | --card-title-bg: hsl(331, 21%, 26%); 33 | } 34 | .related-page-card[data-theme="newyork"] { 35 | --card-title-bg: hsl(176, 29%, 67%); 36 | } 37 | 38 | .related-page-card { 39 | display: block; 40 | position: relative; 41 | height: inherit; 42 | width: inherit; 43 | overflow: hidden; 44 | text-overflow: ellipsis; 45 | font-family: "Roboto", Helvetica, Arial, "Hiragino Sans", sans-serif; 46 | background-color: var(--card-bg, #fff); 47 | color: var(--card-title-color, #555); 48 | word-break: break-word; 49 | text-decoration: none; 50 | } 51 | .related-page-card:hover { 52 | box-shadow: var(--card-box-hover-shadow, 0 2px 0 rgba(0, 0, 0, 0.23)); 53 | } 54 | .related-page-card:focus { 55 | outline: 0; 56 | box-shadow: 0 0px 0px 3px rgba(102, 175, 233, 0.6); 57 | border-color: #66afe9; 58 | transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s; 59 | } 60 | .related-page-card.hover { 61 | opacity: 0; 62 | position: absolute; 63 | width: 100%; 64 | height: 100%; 65 | top: 0; 66 | left: 0; 67 | background-color: var(--card-hover-bg, rgba(0, 0, 0, 0.05)); 68 | mix-blend-mode: multiply; 69 | z-index: 1; 70 | transition: background-color 0.1s; 71 | } 72 | .related-page-card:hover .hover { 73 | opacity: 1; 74 | } 75 | .related-page-card:active .hover { 76 | opacity: 1; 77 | background-color: var(--card-active-bg, rgba(0, 0, 0, 0.1)); 78 | } 79 | .related-page-card .content { 80 | height: calc(100% - 5px); 81 | width: inherit; 82 | display: flex; 83 | flex-direction: column; 84 | overflow: hidden; 85 | } 86 | .related-page-card .content .header { 87 | width: 100%; 88 | color: #396bdd; 89 | text-overflow: ellipsis; 90 | border-top: var(--card-title-bg, #f2f2f3) solid 10px; 91 | padding: 8px 10px; 92 | } 93 | .related-page-card .content .header .title { 94 | font-size: 11px; /* 14 * 0.8 */ 95 | line-height: 16px; /* 20 * 0.8 */ 96 | font-weight: bold; 97 | max-height: 48px; /* 60 * 0.8 */ 98 | color: var(--card-title-color, #363c49); 99 | margin: 0; 100 | overflow: hidden; 101 | display: block; 102 | -webkit-line-clamp: 3; 103 | -webkit-box-orient: vertical; 104 | text-overflow: ellipsis; 105 | } 106 | .related-page-card .content .description { 107 | line-height: 16px; /* 20 * 0.8 */ 108 | padding: 8px 10px 0; 109 | font-size: 10px; /* 12 * 0.8 */ 110 | white-space: pre-line; 111 | column-count: 1; 112 | column-gap: 2em; 113 | column-width: 10em; 114 | height: inherit; 115 | color: var(--card-description-color, gray); 116 | flex-shrink: 16; 117 | overflow: hidden; 118 | } 119 | .related-page-card .content .thumbnail { 120 | display: block; 121 | width: 100%; 122 | margin: 0 auto; 123 | padding: 0 5px; 124 | } 125 | .related-page-card .content .description p { 126 | margin: 0; 127 | display: block; 128 | } 129 | .related-page-card .content .description code { 130 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace; 131 | font-size: 90%; 132 | color: var(--code-color, #342d9c); 133 | background-color: var(--code-bg, rgba(0, 0, 0, 0.04)); 134 | padding: 0; 135 | white-space: pre-wrap; 136 | word-wrap: break-word; 137 | } 138 | 139 | .related-page-card .content .description .icon { 140 | height: 9px; /* 11 * 0.8 */ 141 | vertical-align: middle; 142 | } 143 | .related-page-card .content .description .page-link { 144 | background-color: transparent; 145 | text-decoration: none; 146 | cursor: pointer; 147 | color: var(--page-link-color, #5e8af7); 148 | } 149 | -------------------------------------------------------------------------------- /page.css: -------------------------------------------------------------------------------- 1 | a { 2 | background-color: transparent; 3 | text-decoration: none; 4 | cursor: pointer; 5 | } 6 | img { 7 | display: inline-block; 8 | max-width: 100%; 9 | max-height: 100px; 10 | } 11 | code { 12 | font-family: var( 13 | --code-text-font, 14 | Menlo, 15 | Monaco, 16 | Consolas, 17 | "Courier New", 18 | monospace 19 | ); 20 | font-size: 90%; 21 | color: var(--code-color, #342d9c); 22 | background-color: var(--code-bg, rgba(0, 0, 0, 0.04)); 23 | padding: 0; 24 | white-space: pre-wrap; 25 | word-wrap: break-word; 26 | } 27 | blockquote { 28 | background-color: var(--quote-bg-color, rgba(0, 0, 0, 0.05)); 29 | display: block; 30 | border-left: solid 4px #a0a0a0; 31 | padding-left: 4px; 32 | margin: 0px; 33 | } 34 | strong { 35 | font-weight: bold; 36 | } 37 | iframe { 38 | display: inline-block; 39 | margin: 3px 0; 40 | vertical-align: middle; 41 | max-width: 100%; 42 | width: 640px; 43 | height: 360px; 44 | border: 0; 45 | } 46 | audio { 47 | display: inline-block; 48 | vertical-align: middle; 49 | white-space: initial; 50 | max-width: 100%; 51 | } 52 | 53 | .formula { 54 | margin: auto 6px; 55 | } 56 | .formula.error code { 57 | color: #fd7373; 58 | } 59 | .katex-display { 60 | display: inline-block !important; 61 | margin: 0 !important; 62 | text-align: inherit !important; 63 | } 64 | .error .katex-display { 65 | display: none; 66 | } 67 | .cli { 68 | border-radius: 4px; 69 | } 70 | .cli .prefix { 71 | color: #9c6248; 72 | } 73 | .helpfeel { 74 | background-color: #fbebdd; 75 | border-radius: 4px; 76 | padding: 3px !important; 77 | } 78 | .helpfeel .prefix { 79 | color: #f17c00; 80 | } 81 | .helpfeel .entry { 82 | color: #cc5020; 83 | } 84 | 85 | .code-block { 86 | display: block; 87 | line-height: 1.7em; 88 | background-color: var(--code-bg, rgba(0, 0, 0, 0.04)); 89 | } 90 | .code-block-start { 91 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace; 92 | color: #342d9c; 93 | background-color: #ffcfc6; 94 | font-size: 0.9em; 95 | padding: 1px 2px; 96 | } 97 | .code-block-start a { 98 | color: #342d9c; 99 | text-decoration: underline; 100 | } 101 | code.code-block, 102 | .table-block.table-block-row { 103 | padding-left: 1.0em; 104 | } 105 | .copy { 106 | font-family: "Font Awesome 5 Free"; 107 | cursor: pointer; 108 | } 109 | 110 | .table-block { 111 | white-space: nowrap; 112 | } 113 | .table-block-start { 114 | padding: 1px 2px; 115 | font-size: 0.9em; 116 | background-color: #ffcfc6; 117 | } 118 | .table-block-start a { 119 | color: #342d9c; 120 | text-decoration: underline; 121 | } 122 | .cell { 123 | margin: 0; 124 | padding: 0 2px 0 8px; 125 | box-sizing: content-box; 126 | display: inline-block; 127 | white-space: pre; 128 | } 129 | .cell:nth-child(2n+1) { 130 | background-color: rgba(0, 0, 0, 0.04); 131 | } 132 | .cell:nth-child(2n) { 133 | background-color: rgba(0, 0, 0, 0.06); 134 | } 135 | 136 | .strong-image { 137 | max-height: 100%; 138 | } 139 | .icon { 140 | height: 11px; 141 | vertical-align: middle; 142 | } 143 | .strong-icon { 144 | height: calc(11px * 1.2); 145 | } 146 | 147 | .tool-button { 148 | margin-left: 1em; 149 | cursor: pointer; 150 | font-size: 0.9em; 151 | } 152 | 153 | .deco-\/ { 154 | font-style: italic; 155 | } 156 | .deco-\*-1 { 157 | font-weight: bold; 158 | } 159 | .deco-\*-2 { 160 | font-weight: bold; 161 | font-size: 1.20em; 162 | } 163 | .deco-\*-3 { 164 | font-weight: bold; 165 | font-size: 1.44em; 166 | } 167 | .deco-\*-4 { 168 | font-weight: bold; 169 | font-size: 1.73em; 170 | } 171 | .deco-\*-5 { 172 | font-weight: bold; 173 | font-size: 2.07em; 174 | } 175 | .deco-\*-6 { 176 | font-weight: bold; 177 | font-size: 2.49em; 178 | } 179 | .deco-\*-7 { 180 | font-weight: bold; 181 | font-size: 3.00em; 182 | } 183 | .deco-\*-8 { 184 | font-weight: bold; 185 | font-size: 3.58em; 186 | } 187 | .deco-\*-9 { 188 | font-weight: bold; 189 | font-size: 4.30em; 190 | } 191 | .deco-\*-10 { 192 | font-weight: bold; 193 | font-size: 5.16em; 194 | } 195 | .deco-\- { 196 | text-decoration: line-through; 197 | } 198 | .deco-_ { 199 | text-decoration: underline; 200 | } 201 | .page-link { 202 | color: var(--page-link-color, #5e8af7); 203 | } 204 | a.page-link:hover { 205 | color: var(--page-link-hover-color, #2d67f5); 206 | } 207 | .empty-page-link { 208 | color: var(--empty-page-link-color, #fd7373); 209 | } 210 | a.empty-page-link:hover { 211 | color: var(--empty-page-link-hover-color, #fd7373); 212 | } 213 | .link { 214 | color: var(--page-link-color, #5e8af7); 215 | text-decoration: underline; 216 | } 217 | a.link:hover { 218 | color: var(--page-link-color-hover-color, #2d67f5); 219 | } 220 | .link img { 221 | padding-bottom: 3px; 222 | border-style: none none solid; 223 | border-width: 1.5px; 224 | border-color: #8fadf9; 225 | } 226 | 227 | .permalink { 228 | background-color: var(--line-permalink-color, rgba(234, 218, 74, 0.75)); 229 | } 230 | -------------------------------------------------------------------------------- /Bubble.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { CardList } from "./CardList.tsx"; 4 | import { useLayoutEffect, useMemo, useState } from "./deps/preact.tsx"; 5 | import { toTitleLc } from "./deps/scrapbox-std.ts"; 6 | import { useBubbleData } from "./useBubbleData.ts"; 7 | import type { LinkTo } from "./types.ts"; 8 | import { fromId, type ID, toId } from "./id.ts"; 9 | import type { Bubble as BubbleData } from "./storage.ts"; 10 | import { produce } from "./deps/immer.ts"; 11 | import type { BubbleSource } from "./useBubbles.ts"; 12 | import { createDebug } from "./debug.ts"; 13 | import type { Scrapbox } from "./deps/scrapbox.ts"; 14 | import { TextBubble } from "./TextBubble.tsx"; 15 | declare const scrapbox: Scrapbox; 16 | 17 | const logger = createDebug("ScrapBubble:Bubble.tsx"); 18 | 19 | export interface BubbleProps extends BubbleSource { 20 | whiteList: Set; 21 | delay: number; 22 | prefetch: (project: string, title: string) => void; 23 | } 24 | 25 | export const Bubble = ({ 26 | source, 27 | parentTitles, 28 | whiteList, 29 | ...props 30 | }: BubbleProps) => { 31 | /** 検索対象のproject list */ 32 | const projects = useMemo( 33 | () => 34 | whiteList.has(source.project) 35 | // source.projectを一番先頭にする 36 | ? new Set([source.project, ...whiteList]) 37 | : new Set([source.project]), 38 | [whiteList, source.project], 39 | ); 40 | 41 | const [linked, externalLinked, pages] = useBubbleFilter( 42 | source, 43 | projects, 44 | whiteList, 45 | parentTitles, 46 | ); 47 | 48 | return ( 49 | <> 50 | {hasOneBubble(pages) && ( 51 | 58 | )} 59 | 67 | 68 | ); 69 | }; 70 | 71 | const hasOneBubble = ( 72 | pages: BubbleData[], 73 | ): pages is [BubbleData, ...BubbleData[]] => pages.length > 0; 74 | 75 | /** 指定したsourceからbubblesするページ本文とページカードを、親bubblesやwhiteListを使って絞り込む 76 | * 77 | * でやっている処理の一部を切り出して見通しをよくしただけ 78 | */ 79 | const useBubbleFilter = ( 80 | source: { project: string; title: string }, 81 | projects: Set, 82 | whiteList: Set, 83 | parentTitles: string[], 84 | ) => { 85 | type LinkMap = Map; 86 | 87 | const [[linked, externalLinked, pages], setBubbleData] = useState< 88 | [LinkMap, LinkMap, BubbleData[]] 89 | >([new Map(), new Map(), []]); 90 | 91 | const pageIds = useMemo( 92 | () => { 93 | const pageIds = [...projects].map((project) => 94 | toId(project, source.title) 95 | ); 96 | 97 | logger.debug("projects", pageIds); 98 | return pageIds; 99 | }, 100 | [projects, source.title], 101 | ); 102 | 103 | const bubbles = useBubbleData(pageIds); 104 | const parentsLc = useMemo( 105 | () => parentTitles.map((title) => toTitleLc(title)), 106 | [parentTitles], 107 | ); 108 | 109 | useLayoutEffect( 110 | () => { 111 | /** `source.title`を内部リンク記法で参照しているリンクのリスト */ 112 | const linked = new Map(); 113 | /** `source.title`を外部リンク記法で参照しているリンクのリスト */ 114 | const externalLinked = new Map(); 115 | /** ページ本文 116 | * 117 | * bubbleをそのまま保つことで、参照の違いのみで更新の有無を判断できる 118 | */ 119 | const pages: BubbleData[] = []; 120 | 121 | // 逆リンクおよびpagesから, parentsとwhitelistにないものを除いておく 122 | for (const bubble of bubbles) { 123 | const eLinkTo = { project: bubble.project, titleLc: bubble.titleLc }; 124 | for (const id of bubble.projectLinked ?? []) { 125 | const { project, titleLc } = fromId(id); 126 | // External Linksの内、projectがwhiteListに属するlinksも重複除去処理を施す 127 | if (parentsLc.includes(titleLc) && whiteList.has(project)) { 128 | continue; 129 | } 130 | if (externalLinked.has(id)) continue; 131 | externalLinked.set(id, eLinkTo); 132 | } 133 | // whiteLitにないprojectのページは、External Links以外表示しない 134 | if (!whiteList.has(bubble.project)) continue; 135 | const linkTo = { titleLc: bubble.titleLc }; 136 | // 親と重複しない逆リンクのみ格納する 137 | for (const linkLc of bubble.linked ?? []) { 138 | if (parentsLc.includes(linkLc)) continue; 139 | const id = toId(bubble.project, linkLc); 140 | if (linked.has(id)) continue; 141 | linked.set(id, linkTo); 142 | } 143 | // 親と重複しないページ本文のみ格納する 144 | if (parentsLc.includes(bubble.titleLc)) continue; 145 | if (!bubble.exists) continue; 146 | pages.push(bubble); 147 | } 148 | 149 | // 再レンダリングを抑制するために、必要なものだけ更新する 150 | setBubbleData(produce((draft) => { 151 | logger.debug( 152 | `depth: ${parentsLc.length}, bubbled from ${ 153 | toId(source.project, source.title) 154 | }, bubbles,`, 155 | bubbles, 156 | "before", 157 | draft[0], 158 | `internal cards,`, 159 | linked, 160 | "external cards", 161 | externalLinked, 162 | ); 163 | for (const key of draft[0].keys()) { 164 | if (!linked.has(key)) draft[0].delete(key); 165 | } 166 | for (const [key, value] of linked) { 167 | draft[0].set(key, value); 168 | } 169 | 170 | for (const key of draft[1].keys()) { 171 | if (!externalLinked.has(key)) draft[1].delete(key); 172 | } 173 | for (const [key, value] of externalLinked) { 174 | draft[1].set(key, value); 175 | } 176 | draft[2] = pages; 177 | })); 178 | }, 179 | [bubbles, whiteList, parentsLc], 180 | ); 181 | 182 | return [linked, externalLinked, pages] as const; 183 | }; 184 | -------------------------------------------------------------------------------- /convert.ts: -------------------------------------------------------------------------------- 1 | import { toTitleLc } from "./deps/scrapbox-std.ts"; 2 | import type { Bubble, BubbleStorage } from "./storage.ts"; 3 | import { fromId, type ID, toId } from "./id.ts"; 4 | import type { 5 | PageWithInfoboxDefinition, 6 | PageWithoutInfoboxDefinition, 7 | RelatedPage, 8 | } from "./deps/scrapbox.ts"; 9 | 10 | /** APIから取得したページデータを、Bubble用に変換する 11 | * 12 | * @param titleLc ページのタイトル タイトル変更があると、page.titleから復元できないため、別途指定している 13 | * @param project ページのproject 14 | * @param page 変換したいページデータ 15 | * @return 変換したデータ 16 | */ 17 | export const convert = ( 18 | project: string, 19 | page: PageWithInfoboxDefinition | PageWithoutInfoboxDefinition, 20 | ): BubbleStorage => { 21 | const storage: BubbleStorage = new Map(); 22 | 23 | // pageが参照しているリンクの逆リンクにpageを入れる 24 | // これにより、2 hop linksのハブとなるcardは、全ての逆リンクが格納されていると保証され、linkedとprojectLinkedはundefinedでなくなる 25 | const titleLc = toTitleLc(page.title); 26 | for (const link of page.links) { 27 | const bubble: Bubble = makeDummy(project, link); 28 | bubble.linked = [titleLc]; 29 | storage.set(toId(project, link), bubble); 30 | } 31 | const pageId = toId(project, titleLc); 32 | const projectLinksLc = page.projectLinks.map((id) => { 33 | const link = fromId(id as ID); 34 | return toId(link.project, link.titleLc); 35 | }); 36 | // projectLinksおよびprojectLinks1hopにあるページカードの外部リンク記法経由の逆リンクは正確に取得できないので無視する 37 | // 外部リンク記法経由の逆リンクはpage.titleのみ考慮する 38 | 39 | // ページ本文を入れる 40 | const pageBubble: Required = { 41 | ...toBubble(project, page), 42 | linked: [], 43 | projectLinked: [], 44 | }; 45 | storage.set(pageId, pageBubble); 46 | 47 | // 1 hop linksからカードを取り出す 48 | // 同時に`emptyPageIds`を作る 49 | const linksLc = page.links.map((link) => toTitleLc(link)); 50 | for (const card of page.relatedPages.links1hop) { 51 | if (card.linksLc.includes(titleLc)) { 52 | // 双方向リンク or 逆リンク 53 | 54 | // 逆リンクを作る 55 | // 重複はありえないので配列でいい 56 | pageBubble.linked.push(card.titleLc); 57 | } 58 | // `page`→`card`→`linkLc`←`page`というリンク関係にあるときは、`card`を`linkLc`の逆リンクとして登録する 59 | for ( 60 | const linkLc of card.linksLc.filter((linkLc) => linksLc.includes(linkLc)) 61 | ) { 62 | const cardId = toId(project, linkLc); 63 | const bubble = storage.get(cardId) ?? makeDummy(project, linkLc); 64 | if (!bubble.linked) { 65 | bubble.linked = [card.titleLc]; 66 | continue; 67 | } 68 | bubble.linked.push(card.titleLc); 69 | } 70 | // cardを入れる 71 | const cardId = toId(project, card.titleLc); 72 | const bubble = toBubble(project, card); 73 | // external linksでなければ`projectLinked`は存在しない 74 | const linked = storage.get(cardId)?.linked; 75 | if (linked) bubble.linked = linked; 76 | storage.set(cardId, bubble); 77 | } 78 | 79 | // external linksからカードを取り出す 80 | for ( 81 | const card of page.relatedPages.projectLinks1hop 82 | ) { 83 | const cardId = toId(card.projectName, card.titleLc); 84 | if (!projectLinksLc.includes(cardId)) { 85 | // 逆リンク 86 | // 双方向リンクは順リンクと判別がつかないのでやめる 87 | 88 | // 逆リンクを作る 89 | pageBubble.projectLinked.push(cardId); 90 | } 91 | // cardを入れる 92 | const bubble = toBubble(card.projectName, card); 93 | const projectLinked = storage.get(cardId)?.projectLinked; 94 | if (projectLinked) bubble.projectLinked = projectLinked; 95 | storage.set(cardId, bubble); 96 | } 97 | pageBubble.isLinkedCorrect = true; 98 | 99 | // 2 hop linksからカードを取り出す 100 | for (const card of page.relatedPages.links2hop) { 101 | for (const linkLc of card.linksLc) { 102 | // 逆リンクを作る 103 | // 重複はありえないので配列でいい 104 | const cardId = toId(project, linkLc); 105 | const bubble = storage.get(cardId) ?? makeDummy(project, linkLc); 106 | if (!bubble.linked) { 107 | bubble.linked = [card.titleLc]; 108 | continue; 109 | } 110 | bubble.linked.push(card.titleLc); 111 | } 112 | // cardを入れる 113 | const cardId = toId(project, card.titleLc); 114 | const bubble = toBubble(project, card); 115 | // external linksでなければ`projectLinked`は存在しない 116 | const linked = storage.get(cardId)?.linked; 117 | if (linked) bubble.linked = linked; 118 | storage.set(cardId, bubble); 119 | } 120 | 121 | return storage; 122 | }; 123 | 124 | /** 関連ページやページ本文の情報をBubbleに変換する 125 | * 126 | * @param project 与えたデータのproject name 127 | * @param page bubbleに変換したいデータ 128 | * @param checked データを確認した日時 129 | * @return 変換後のデータ 130 | */ 131 | const toBubble = ( 132 | project: string, 133 | page: 134 | | PageWithInfoboxDefinition 135 | | PageWithoutInfoboxDefinition 136 | | Omit< 137 | RelatedPage, 138 | | "linksLc" 139 | | "pageRank" 140 | | "created" 141 | | "descriptions" 142 | | "infoboxResult" 143 | | "infoboxDisableLinks" 144 | | "charsCount" 145 | | "lastAccessed" 146 | > 147 | & { descriptions?: string[] }, 148 | ): Bubble => ({ 149 | project, 150 | titleLc: "titleLc" in page ? page.titleLc : toTitleLc(page.title), 151 | // 関連ページの場合は、関連ページが存在する時点で中身があるので、常に`true`とする 152 | exists: "persistent" in page ? page.persistent : true, 153 | descriptions: "descriptions" in page ? page.descriptions ?? [] : [], 154 | image: page.image, 155 | lines: "lines" in page 156 | ? page.lines 157 | // descriptionsからでっち上げる 158 | : [page.title, ...page.descriptions ?? []].map(( 159 | text, 160 | ) => ({ 161 | text, 162 | id: "dummy", 163 | userId: "dummy", 164 | updated: page.updated, 165 | created: page.updated, 166 | })), 167 | updated: page.updated, 168 | isLinkedCorrect: false, 169 | }); 170 | 171 | /** ページタイトルだけからBubbleを作る 172 | * 173 | * @param project 与えたデータのproject name 174 | * @param title ページタイトル 175 | * @param checked データを確認した日時 176 | * @return 変換後のデータ 177 | */ 178 | const makeDummy = ( 179 | project: string, 180 | title: string, 181 | ): Bubble => ({ 182 | project, 183 | titleLc: toTitleLc(title), 184 | exists: false, 185 | descriptions: [], 186 | image: null, 187 | lines: [{ 188 | text: title, 189 | id: "dummy", 190 | userId: "dummy", 191 | updated: 0, 192 | created: 0, 193 | }], 194 | updated: 0, 195 | isLinkedCorrect: false, 196 | }); 197 | -------------------------------------------------------------------------------- /Card.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { type h, useCallback, useMemo } from "./deps/preact.tsx"; 4 | import { useKaTeX } from "./useKaTeX.ts"; 5 | import { encodeTitleURI } from "./deps/scrapbox-std.ts"; 6 | import { pushPageTransition } from "./deps/scrapbox-std-browser.ts"; 7 | import type { 8 | FormulaNode, 9 | HashTagNode, 10 | IconNode, 11 | LinkNode, 12 | Node as NodeType, 13 | StrongIconNode, 14 | } from "./deps/scrapbox-parser.ts"; 15 | import { parse } from "./deps/scrapbox-parser.ts"; 16 | import { useTheme } from "./useTheme.ts"; 17 | import { stayHovering } from "./stayHovering.ts"; 18 | import type { BubbleOperators } from "./useBubbles.ts"; 19 | import { calcBubblePosition } from "./position.ts"; 20 | import type { Scrapbox } from "./deps/scrapbox.ts"; 21 | declare const scrapbox: Scrapbox; 22 | 23 | export interface CardProps { 24 | project: string; 25 | title: string; 26 | descriptions: string[]; 27 | thumbnail: string; 28 | linkTo?: { 29 | project?: string; 30 | titleLc: string; 31 | }; 32 | delay: number; 33 | prefetch: (project: string, title: string) => void; 34 | bubble: BubbleOperators["bubble"]; 35 | } 36 | 37 | export const Card = ({ 38 | project, 39 | title, 40 | descriptions, 41 | thumbnail, 42 | linkTo, 43 | bubble, 44 | delay, 45 | prefetch, 46 | }: CardProps) => { 47 | const blocks = useMemo( 48 | () => thumbnail ? [] : parse(descriptions.join("\n"), { hasTitle: false }), 49 | [ 50 | thumbnail, 51 | descriptions, 52 | ], 53 | ); 54 | const theme = useTheme(project); 55 | 56 | const handleEnter = useCallback( 57 | async ( 58 | { currentTarget: a }: h.JSX.TargetedMouseEvent, 59 | ) => { 60 | prefetch(project, title); 61 | 62 | if (!await stayHovering(a, delay)) return; 63 | 64 | bubble({ 65 | project, 66 | title, 67 | linkTo, 68 | type: "link", 69 | position: calcBubblePosition(a), 70 | }); 71 | }, 72 | [project, title, delay, linkTo?.project, linkTo?.titleLc], 73 | ); 74 | 75 | const handleClick = useMemo(() => 76 | linkTo 77 | ? () => { 78 | pushPageTransition({ 79 | type: "page", 80 | from: { project: linkTo.project ?? project, title: linkTo.titleLc }, 81 | to: { project, title }, 82 | }); 83 | } 84 | : () => {}, [project, title, linkTo?.project, linkTo?.titleLc]); 85 | 86 | return ( 87 | 97 |
98 |
99 |
100 |
{title}
101 |
102 | {thumbnail 103 | ? ( 104 |
105 | 106 |
107 | ) 108 | : ( 109 |
110 | {blocks.flatMap((block, index) => 111 | block.type === "line" 112 | ? [ 113 |

114 | {block.nodes.map((node) => ( 115 | 116 | ))} 117 |

, 118 | ] 119 | : [] 120 | )} 121 |
122 | )} 123 |
124 |
125 | ); 126 | }; 127 | 128 | type NodeProps = { 129 | node: NodeType; 130 | project: string; 131 | }; 132 | const SummaryNode = ({ node, project }: NodeProps) => { 133 | switch (node.type) { 134 | case "code": 135 | return {node.text}; 136 | case "formula": 137 | return ; 138 | case "commandLine": 139 | return {node.symbol} ${node.text}; 140 | case "helpfeel": 141 | return ? {node.text}; 142 | case "quote": 143 | case "strong": 144 | case "decoration": 145 | return ( 146 | <> 147 | {node.nodes.map((node) => ( 148 | 149 | ))} 150 | 151 | ); 152 | case "icon": 153 | case "strongIcon": 154 | return ; 155 | case "hashTag": 156 | return ; 157 | case "link": 158 | return ; 159 | case "plain": 160 | case "blank": 161 | return <>{node.text}; 162 | default: 163 | return; 164 | } 165 | }; 166 | 167 | type FormulaProps = { 168 | node: FormulaNode; 169 | }; 170 | const Formula = ({ node: { formula } }: FormulaProps) => { 171 | const { ref, error } = useKaTeX(formula); 172 | 173 | return ( 174 | 175 | {error 176 | ? {formula} 177 | : } 178 | 179 | ); 180 | }; 181 | type IconProps = { 182 | project: string; 183 | node: IconNode | StrongIconNode; 184 | }; 185 | const Icon = ({ node: { pathType, path }, project: _project }: IconProps) => { 186 | const [project, title] = pathType === "relative" 187 | ? [ 188 | _project, 189 | path, 190 | ] 191 | : path.match(/\/([\w\-]+)\/(.+)$/)?.slice?.(1) ?? [_project, path]; 192 | 193 | return ( 194 | 198 | ); 199 | }; 200 | type HashTagProps = { 201 | node: HashTagNode; 202 | }; 203 | const HashTag = ({ node: { href } }: HashTagProps) => ( 204 | #{href} 205 | ); 206 | type LinkProps = { 207 | node: LinkNode; 208 | }; 209 | const Link = ({ node: { pathType, href, content } }: LinkProps) => 210 | pathType !== "absolute" 211 | ? {href} // contentが空のときはundefinedではなく''になるので、 212 | // ??ではなく||でfallback処理をする必要がある 213 | : {content || href}; 214 | -------------------------------------------------------------------------------- /katex.ts: -------------------------------------------------------------------------------- 1 | // based on https://github.com/DefinitelyTyped/DefinitelyTyped/blob/27b08d3078064f2ca3b1e192ee231396107fafeb/types/katex/index.d.ts 2 | // Modifications: 3 | // - $deno fmt 4 | // - macros?: any -> macros?: unknown 5 | // - add deno-lint-ignore 6 | 7 | export interface TrustContext { 8 | command: string; 9 | url: string; 10 | protocol: string; 11 | } 12 | 13 | /** Documentation: https://katex.org/docs/options.html */ 14 | export interface KatexOptions { 15 | /** 16 | * If `true`, math will be rendered in display mode 17 | * (math in display style and center math on page) 18 | * 19 | * If `false`, math will be rendered in inline mode 20 | * @default false 21 | */ 22 | displayMode?: boolean | undefined; 23 | /** 24 | * Determines the markup language of the output. The valid choices are: 25 | * - `html`: Outputs KaTeX in HTML only. 26 | * - `mathml`: Outputs KaTeX in MathML only. 27 | * - `htmlAndMathml`: Outputs HTML for visual rendering 28 | * and includes MathML for accessibility. 29 | * 30 | * @default 'htmlAndMathml' 31 | */ 32 | output?: "html" | "mathml" | "htmlAndMathml" | undefined; 33 | /** 34 | * If `true`, display math has \tags rendered on the left 35 | * instead of the right, like \usepackage[leqno]{amsmath} in LaTeX. 36 | * 37 | * @default false 38 | */ 39 | leqno?: boolean | undefined; 40 | /** 41 | * If `true`, display math renders flush left with a 2em left margin, 42 | * like \documentclass[fleqn] in LaTeX with the amsmath package. 43 | * 44 | * @default false 45 | */ 46 | fleqn?: boolean | undefined; 47 | /** 48 | * If `true`, KaTeX will throw a `ParseError` when 49 | * it encounters an unsupported command or invalid LaTex 50 | * 51 | * If `false`, KaTeX will render unsupported commands as 52 | * text, and render invalid LaTeX as its source code with 53 | * hover text giving the error, in color given by errorColor 54 | * @default true 55 | */ 56 | throwOnError?: boolean | undefined; 57 | /** 58 | * A Color string given in format `#XXX` or `#XXXXXX` 59 | */ 60 | errorColor?: string | undefined; 61 | /** 62 | * A collection of custom macros. 63 | * 64 | * See `src/macros.js` for its usage 65 | */ 66 | macros?: unknown; 67 | /** 68 | * Specifies a minimum thickness, in ems, for fraction lines, 69 | * \sqrt top lines, {array} vertical lines, \hline, \hdashline, 70 | * \underline, \overline, and the borders of \fbox, \boxed, and 71 | * \fcolorbox. 72 | */ 73 | minRuleThickness?: number | undefined; 74 | /** 75 | * If `true`, `\color` will work like LaTeX's `\textcolor` 76 | * and takes 2 arguments 77 | * 78 | * If `false`, `\color` will work like LaTeX's `\color` 79 | * and takes 1 argument 80 | * 81 | * In both cases, `\textcolor` works as in LaTeX 82 | * 83 | * @default false 84 | */ 85 | colorIsTextColor?: boolean | undefined; 86 | /** 87 | * All user-specified sizes will be caped to `maxSize` ems 88 | * 89 | * If set to Infinity, users can make elements and space 90 | * arbitrarily large 91 | * 92 | * @default Infinity 93 | */ 94 | maxSize?: number | undefined; 95 | /** 96 | * Limit the number of macro expansions to specified number 97 | * 98 | * If set to `Infinity`, marco expander will try to fully expand 99 | * as in LaTex 100 | * 101 | * @default 1000 102 | */ 103 | maxExpand?: number | undefined; 104 | /** 105 | * If `false` or `"ignore"`, allow features that make 106 | * writing in LaTex convenient but not supported by LaTex 107 | * 108 | * If `true` or `"error"`, throw an error for such transgressions 109 | * 110 | * If `"warn"`, warn about behavior via `console.warn` 111 | * 112 | * @default "warn" 113 | */ 114 | // deno-lint-ignore ban-types 115 | strict?: boolean | string | Function | undefined; 116 | /** 117 | * If `false` (do not trust input), prevent any commands that could enable adverse behavior, rendering them instead in errorColor. 118 | * 119 | * If `true` (trust input), allow all such commands. 120 | * 121 | * @default false 122 | */ 123 | trust?: boolean | ((context: TrustContext) => boolean) | undefined; 124 | /** 125 | * Place KaTeX code in the global group. 126 | * 127 | * @default false 128 | */ 129 | globalGroup?: boolean | undefined; 130 | } 131 | 132 | export interface Katex { 133 | /** 134 | * Renders a TeX expression into the specified DOM element 135 | * @param tex A TeX expression 136 | * @param element The DOM element to render into 137 | * @param options KaTeX options 138 | */ 139 | render( 140 | tex: string, 141 | element: HTMLElement, 142 | options?: KatexOptions, 143 | ): void; 144 | /** 145 | * Renders a TeX expression into an HTML string 146 | * @param tex A TeX expression 147 | * @param options KaTeX options 148 | */ 149 | renderToString( 150 | tex: string, 151 | options?: KatexOptions, 152 | ): string; 153 | } 154 | 155 | declare global { 156 | interface Window { 157 | katex: Katex; 158 | } 159 | } 160 | 161 | // deno-lint-ignore no-namespace 162 | export namespace katex { 163 | export declare class ParseError implements Error { 164 | // deno-lint-ignore no-explicit-any 165 | constructor(message: string, lexer: any, position: number); 166 | name: string; 167 | message: string; 168 | position: number; 169 | } 170 | } 171 | 172 | export const defaultVersion = "0.16.9"; 173 | let initialized: Promise | undefined; 174 | let error: string | Event | undefined; 175 | export const importKaTeX = (version: string): Promise => { 176 | const url = 177 | `https://cdnjs.cloudflare.com/ajax/libs/KaTeX/${version}/katex.min.js`; 178 | 179 | if (error) throw error; 180 | if (!document.querySelector(`script[src="${url}"]`)) { 181 | const script = document.createElement("script"); 182 | script.src = url; 183 | initialized = new Promise((resolve, reject) => { 184 | // deno-lint-ignore no-window 185 | script.onload = () => resolve(window.katex); 186 | script.onerror = (e) => { 187 | error = e; 188 | reject(e); 189 | }; 190 | document.head.append(script); 191 | }); 192 | } 193 | if (initialized) return initialized; 194 | 195 | return new Promise((resolve) => { 196 | const id = setInterval(() => { 197 | // deno-lint-ignore no-window 198 | if (!window.katex) return; 199 | clearInterval(id); 200 | // deno-lint-ignore no-window 201 | resolve(window.katex); 202 | }, 500); 203 | }); 204 | }; 205 | 206 | export { defaultVersion as version }; 207 | -------------------------------------------------------------------------------- /storage.test.ts: -------------------------------------------------------------------------------- 1 | import { type Bubble, update } from "./storage.ts"; 2 | import { 3 | assertEquals, 4 | assertNotEquals, 5 | assertNotStrictEquals, 6 | assertStrictEquals, 7 | } from "./deps/testing.ts"; 8 | 9 | Deno.test("update()", async (t) => { 10 | await t.step("新規作成", () => { 11 | const next: Bubble = { 12 | descriptions: [ 13 | "[A1],[A2],[A3]", 14 | "https://scrapbox.io/api/pages/takker-dist/A", 15 | ], 16 | exists: true, 17 | image: null, 18 | lines: [ 19 | { 20 | created: 1672173548, 21 | id: "dummy", 22 | text: "A", 23 | updated: 1672173548, 24 | userId: "dummy", 25 | }, 26 | { 27 | created: 1672173548, 28 | id: "dummy", 29 | text: "[A1],[A2],[A3]", 30 | updated: 1672173548, 31 | userId: "dummy", 32 | }, 33 | { 34 | created: 1672173548, 35 | id: "dummy", 36 | text: "https://scrapbox.io/api/pages/takker-dist/A", 37 | updated: 1672173548, 38 | userId: "dummy", 39 | }, 40 | ], 41 | project: "takker-dist", 42 | titleLc: "a", 43 | updated: 1672173550, 44 | isLinkedCorrect: false, 45 | }; 46 | 47 | const updated = update(undefined, next); 48 | assertStrictEquals(updated, next); 49 | }); 50 | await t.step("更新日時が違う場合", async (t) => { 51 | const next: Bubble = { 52 | descriptions: [ 53 | "[A1],[A2],[A3]", 54 | "https://scrapbox.io/api/pages/takker-dist/A", 55 | ], 56 | exists: true, 57 | image: null, 58 | lines: [ 59 | { 60 | created: 1672173548, 61 | id: "dummy", 62 | text: "A", 63 | updated: 1672173548, 64 | userId: "dummy", 65 | }, 66 | { 67 | created: 1672173548, 68 | id: "dummy", 69 | text: "[A1],[A2],[A3]", 70 | updated: 1672173548, 71 | userId: "dummy", 72 | }, 73 | { 74 | created: 1672173548, 75 | id: "dummy", 76 | text: "https://scrapbox.io/api/pages/takker-dist/A", 77 | updated: 1672173548, 78 | userId: "dummy", 79 | }, 80 | ], 81 | project: "takker-dist", 82 | titleLc: "a", 83 | updated: 1672173550, 84 | isLinkedCorrect: false, 85 | }; 86 | 87 | await t.step("双方ともlinkedなし", () => { 88 | const prev: Bubble = { 89 | descriptions: [ 90 | "[A1],[A2],[A3]", 91 | "https://scrapbox.io/api/pages/takker-dist/A", 92 | ], 93 | exists: true, 94 | image: null, 95 | lines: [ 96 | { 97 | created: 1672173548, 98 | id: "dummy", 99 | text: "A", 100 | updated: 1672173548, 101 | userId: "dummy", 102 | }, 103 | { 104 | created: 1672173548, 105 | id: "dummy", 106 | text: "[A1],[A2],[A3]", 107 | updated: 1672173548, 108 | userId: "dummy", 109 | }, 110 | { 111 | created: 1672173548, 112 | id: "dummy", 113 | text: "https://scrapbox.io/api/pages/takker-dist/A", 114 | updated: 1672173548, 115 | userId: "dummy", 116 | }, 117 | ], 118 | project: "takker-dist", 119 | titleLc: "a", 120 | updated: 1672173548, 121 | isLinkedCorrect: false, 122 | }; 123 | const updated = update(prev, next); 124 | assertNotStrictEquals(updated, prev); 125 | assertNotStrictEquals(updated, next); 126 | assertNotEquals(updated, prev); 127 | assertEquals(updated, next); 128 | 129 | assertStrictEquals(updated?.lines, prev.lines); 130 | assertStrictEquals(updated?.descriptions, next.descriptions); 131 | }); 132 | 133 | await t.step("古い方にlinkedあり", () => { 134 | const prev: Bubble = { 135 | descriptions: [ 136 | "[A1],[A2],[A3]", 137 | "https://scrapbox.io/api/pages/takker-dist/A", 138 | ], 139 | exists: true, 140 | image: null, 141 | lines: [ 142 | { 143 | created: 1672173548, 144 | id: "dummy", 145 | text: "A", 146 | updated: 1672173548, 147 | userId: "dummy", 148 | }, 149 | { 150 | created: 1672173548, 151 | id: "dummy", 152 | text: "[A1],[A2],[A3]", 153 | updated: 1672173548, 154 | userId: "dummy", 155 | }, 156 | { 157 | created: 1672173548, 158 | id: "dummy", 159 | text: "https://scrapbox.io/api/pages/takker-dist/A", 160 | updated: 1672173548, 161 | userId: "dummy", 162 | }, 163 | ], 164 | linked: ["c4", "c3"], 165 | project: "takker-dist", 166 | titleLc: "a", 167 | updated: 1672173548, 168 | isLinkedCorrect: true, 169 | }; 170 | const updated = update(prev, next); 171 | assertNotStrictEquals(updated, prev); 172 | assertNotStrictEquals(updated, next); 173 | assertNotEquals(updated, prev); 174 | assertNotEquals(updated, next); 175 | assertEquals(updated, { ...next, linked: prev.linked }); 176 | 177 | assertStrictEquals(updated?.lines, prev.lines); 178 | assertStrictEquals(updated?.descriptions, next.descriptions); 179 | }); 180 | }); 181 | 182 | await t.step("更新日時が同じ場合", async (t) => { 183 | const next: Bubble = { 184 | descriptions: [ 185 | "[A1],[A2],[A3]", 186 | "https://scrapbox.io/api/pages/takker-dist/A", 187 | ], 188 | exists: true, 189 | image: null, 190 | lines: [ 191 | { 192 | created: 1672173548, 193 | id: "dummy", 194 | text: "A", 195 | updated: 1672173548, 196 | userId: "dummy", 197 | }, 198 | { 199 | created: 1672173548, 200 | id: "dummy", 201 | text: "[A1],[A2],[A3]", 202 | updated: 1672173548, 203 | userId: "dummy", 204 | }, 205 | { 206 | created: 1672173548, 207 | id: "dummy", 208 | text: "https://scrapbox.io/api/pages/takker-dist/A", 209 | updated: 1672173548, 210 | userId: "dummy", 211 | }, 212 | ], 213 | linked: ["e2", "e3"], 214 | project: "takker-dist", 215 | titleLc: "a", 216 | updated: 1672173548, 217 | isLinkedCorrect: true, 218 | }; 219 | 220 | await t.step("linkedのみ違う", () => { 221 | const prev: Bubble = { 222 | descriptions: [ 223 | "[A1],[A2],[A3]", 224 | "https://scrapbox.io/api/pages/takker-dist/A", 225 | ], 226 | exists: true, 227 | image: null, 228 | lines: [ 229 | { 230 | created: 1672173548, 231 | id: "dummy", 232 | text: "A", 233 | updated: 1672173548, 234 | userId: "dummy", 235 | }, 236 | { 237 | created: 1672173548, 238 | id: "dummy", 239 | text: "[A1],[A2],[A3]", 240 | updated: 1672173548, 241 | userId: "dummy", 242 | }, 243 | { 244 | created: 1672173548, 245 | id: "dummy", 246 | text: "https://scrapbox.io/api/pages/takker-dist/A", 247 | updated: 1672173548, 248 | userId: "dummy", 249 | }, 250 | ], 251 | linked: ["e2", "e4"], 252 | project: "takker-dist", 253 | titleLc: "a", 254 | updated: 1672173548, 255 | isLinkedCorrect: true, 256 | }; 257 | const updated = update(prev, next); 258 | assertNotStrictEquals(updated, prev); 259 | assertNotStrictEquals(updated, next); 260 | assertEquals(updated, next); 261 | 262 | assertStrictEquals(updated?.lines, prev.lines); 263 | assertStrictEquals(updated?.descriptions, prev.descriptions); 264 | assertStrictEquals(updated?.linked, next.linked); 265 | }); 266 | }); 267 | }); 268 | -------------------------------------------------------------------------------- /app.min.css.ts: -------------------------------------------------------------------------------- 1 | // deno-fmt-ignore-file 2 | export const CSS = String.raw`*{box-sizing:border-box}a{background-color:transparent;text-decoration:none;cursor:pointer}img{display:inline-block;max-width:100%;max-height:100px}code{font-family:var(--code-text-font, Menlo, Monaco, Consolas, "Courier New", monospace);font-size:90%;color:var(--code-color, #342d9c);background-color:var(--code-bg, rgba(0,0,0,.04));padding:0;white-space:pre-wrap;word-wrap:break-word}blockquote{background-color:var(--quote-bg-color, rgba(0,0,0,.05));display:block;border-left:solid 4px #a0a0a0;padding-left:4px;margin:0}strong{font-weight:700}iframe{display:inline-block;margin:3px 0;vertical-align:middle;max-width:100%;width:640px;height:360px;border:0}audio{display:inline-block;vertical-align:middle;white-space:initial;max-width:100%}.formula{margin:auto 6px}.formula.error code{color:#fd7373}.katex-display{display:inline-block!important;margin:0!important;text-align:inherit!important}.error .katex-display{display:none}.cli{border-radius:4px}.cli .prefix{color:#9c6248}.helpfeel{background-color:#fbebdd;border-radius:4px;padding:3px!important}.helpfeel .prefix{color:#f17c00}.helpfeel .entry{color:#cc5020}.code-block{display:block;line-height:1.7em;background-color:var(--code-bg, rgba(0,0,0,.04))}.code-block-start{font-family:Menlo,Monaco,Consolas,Courier New,monospace;color:#342d9c;background-color:#ffcfc6;font-size:.9em;padding:1px 2px}.code-block-start a{color:#342d9c;text-decoration:underline}code.code-block,.table-block.table-block-row{padding-left:1em}.copy{font-family:"Font Awesome 5 Free";cursor:pointer}.table-block{white-space:nowrap}.table-block-start{padding:1px 2px;font-size:.9em;background-color:#ffcfc6}.table-block-start a{color:#342d9c;text-decoration:underline}.cell{margin:0;padding:0 2px 0 8px;box-sizing:content-box;display:inline-block;white-space:pre}.cell:nth-child(odd){background-color:#0000000a}.cell:nth-child(2n){background-color:#0000000f}.strong-image{max-height:100%}.icon{height:11px;vertical-align:middle}.strong-icon{height:13.2px}.tool-button{margin-left:1em;cursor:pointer;font-size:.9em}.deco-\/{font-style:italic}.deco-\*-1{font-weight:700}.deco-\*-2{font-weight:700;font-size:1.2em}.deco-\*-3{font-weight:700;font-size:1.44em}.deco-\*-4{font-weight:700;font-size:1.73em}.deco-\*-5{font-weight:700;font-size:2.07em}.deco-\*-6{font-weight:700;font-size:2.49em}.deco-\*-7{font-weight:700;font-size:3em}.deco-\*-8{font-weight:700;font-size:3.58em}.deco-\*-9{font-weight:700;font-size:4.3em}.deco-\*-10{font-weight:700;font-size:5.16em}.deco--{text-decoration:line-through}.deco-_{text-decoration:underline}.page-link{color:var(--page-link-color, #5e8af7)}a.page-link:hover{color:var(--page-link-hover-color, #2d67f5)}.empty-page-link{color:var(--empty-page-link-color, #fd7373)}a.empty-page-link:hover{color:var(--empty-page-link-hover-color, #fd7373)}.link{color:var(--page-link-color, #5e8af7);text-decoration:underline}a.link:hover{color:var(--page-link-color-hover-color, #2d67f5)}.link img{padding-bottom:3px;border-style:none none solid;border-width:1.5px;border-color:#8fadf9}.permalink{background-color:var(--line-permalink-color, rgba(234,218,74,.75))}.status-bar{display:inline-block;position:absolute;background-color:var(--page-bg, #fefefe);cursor:default}.status-bar>*{border:1px solid var(--status-bar-border-color, #a9aaaf)}.status-bar.top-left{top:0;left:0}.status-bar.top-left>*{border-top:none;border-left:none}.status-bar.top-left :last-of-type{border-bottom-right-radius:3px}.status-bar.top-right{top:0;right:0}.status-bar.top-right>*{border-top:none;border-right:none}.status-bar.top-right :last-of-type{border-bottom-left-radius:3px}.status-bar.bottom-right{bottom:0;right:0}.status-bar.bottom-right>*{border-bottom:none;border-right:none}.status-bar.bottom-right :last-of-type{border-top-left-radius:3px}.status-bar.bottom-left{bottom:0;left:0}.status-bar.bottom-left>*{border-bottom:none;border-left:none}.status-bar.bottom-left :last-of-type{border-top-right-radius:3px}.text-bubble{font-size:11px;line-height:1.42857;user-select:text;position:absolute;color:var(--page-text-color, #4a4a4a);box-shadow:0 6px 12px #0000002d;display:flex;flex-direction:column;z-index:9000;&.no-scroll{overflow-y:hidden}[data-theme=default-dark]{--text-bubble-border-color: hsl(0, 0%, 39%)}[data-theme=default-minimal]{--text-bubble-border-color: hsl(0, 0%, 89%)}[data-theme=paper-light]{--text-bubble-border-color: hsl(53, 8%, 58%)}[data-theme=paper-dark-dark]{--text-bubble-border-color: hsl(203, 42%, 17%)}[data-theme=blue]{--text-bubble-border-color: hsl(227, 68%, 62%)}[data-theme=purple]{--text-bubble-border-color: hsl(267, 39%, 60%)}[data-theme=green]{--text-bubble-border-color: hsl(136, 29%, 50%)}[data-theme=orange]{--text-bubble-border-color: hsl(43, 71%, 51%)}[data-theme=red]{--text-bubble-border-color: hsl(4, 58%, 56%)}[data-theme=spring]{--text-bubble-border-color: hsl(72, 64%, 57%)}[data-theme=kyoto]{--text-bubble-border-color: hsl(331, 21%, 26%)}[data-theme=newyork]{--text-bubble-border-color: hsl(176, 29%, 67%)}[role=tabpanel]{padding:5px 0 5px 5px;border-radius:4px;background-color:var(--page-bg, #fefefe);max-height:80vh;overflow-y:auto}[role=tabpanel],button{border:1px solid var(--text-bubble-border-color, hsl(221, 15%, 25%))}button{cursor:default;color:inherit;font-size:inherit;line-height:inherit;background-color:var(--page-bg, #fefefe);border-radius:4px 4px 0 0}button[aria-selected=true]{background-color:var(--text-bubble-border-color, hsl(221, 15%, 25%))}}.project-badge{text-decoration:none;color:var(--tool-text-color, #363c49)}.related-page-card[data-theme=default-dark]{--card-title-bg: hsl(0, 0%, 39%)}.related-page-card[data-theme=default-minimal]{--card-title-bg: hsl(0, 0%, 89%)}.related-page-card[data-theme=paper-light]{--card-title-bg: hsl(53, 8%, 58%)}.related-page-card[data-theme=paper-dark-dark]{--card-title-bg: hsl(203, 42%, 17%)}.related-page-card[data-theme=blue]{--card-title-bg: hsl(227, 68%, 62%)}.related-page-card[data-theme=purple]{--card-title-bg: hsl(267, 39%, 60%)}.text-bubble[data-theme=green]{--card-title-bg: hsl(136, 29%, 50%)}.related-page-card[data-theme=orange]{--card-title-bg: hsl(43, 71%, 51%)}.related-page-card[data-theme=red]{--card-title-bg: hsl(4, 58%, 56%)}.related-page-card[data-theme=spring]{--card-title-bg: hsl(72, 64%, 57%)}.related-page-card[data-theme=kyoto]{--card-title-bg: hsl(331, 21%, 26%)}.related-page-card[data-theme=newyork]{--card-title-bg: hsl(176, 29%, 67%)}.related-page-card{display:block;position:relative;height:inherit;width:inherit;overflow:hidden;text-overflow:ellipsis;font-family:Roboto,Helvetica,Arial,Hiragino Sans,sans-serif;background-color:var(--card-bg, #fff);color:var(--card-title-color, #555);word-break:break-word;text-decoration:none}.related-page-card:hover{box-shadow:var(--card-box-hover-shadow, 0 2px 0 rgba(0,0,0,.23))}.related-page-card:focus{outline:0;box-shadow:0 0 0 3px #66afe999;border-color:#66afe9;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.related-page-card.hover{opacity:0;position:absolute;width:100%;height:100%;top:0;left:0;background-color:var(--card-hover-bg, rgba(0,0,0,.05));mix-blend-mode:multiply;z-index:1;transition:background-color .1s}.related-page-card:hover .hover{opacity:1}.related-page-card:active .hover{opacity:1;background-color:var(--card-active-bg, rgba(0,0,0,.1))}.related-page-card .content{height:calc(100% - 5px);width:inherit;display:flex;flex-direction:column;overflow:hidden}.related-page-card .content .header{width:100%;color:#396bdd;text-overflow:ellipsis;border-top:var(--card-title-bg, #f2f2f3) solid 10px;padding:8px 10px}.related-page-card .content .header .title{font-size:11px;line-height:16px;font-weight:700;max-height:48px;color:var(--card-title-color, #363c49);margin:0;overflow:hidden;display:block;-webkit-line-clamp:3;-webkit-box-orient:vertical;text-overflow:ellipsis}.related-page-card .content .description{line-height:16px;padding:8px 10px 0;font-size:10px;white-space:pre-line;column-count:1;column-gap:2em;column-width:10em;height:inherit;color:var(--card-description-color, gray);flex-shrink:16;overflow:hidden}.related-page-card .content .thumbnail{display:block;width:100%;margin:0 auto;padding:0 5px}.related-page-card .content .description p{margin:0;display:block}.related-page-card .content .description code{font-family:Menlo,Monaco,Consolas,Courier New,monospace;font-size:90%;color:var(--code-color, #342d9c);background-color:var(--code-bg, rgba(0,0,0,.04));padding:0;white-space:pre-wrap;word-wrap:break-word}.related-page-card .content .description .icon{height:9px;vertical-align:middle}.related-page-card .content .description .page-link{background-color:transparent;text-decoration:none;cursor:pointer;color:var(--page-link-color, #5e8af7)}.card-bubble{background-color:var(--page-bg, #FFF);box-shadow:0 2px 2px #00000024,0 3px 1px -2px #0003,0 1px 5px #0000001f;position:absolute;max-width:80vw;box-sizing:content-box;z-index:9000;font-size:11px;line-height:1.42857;display:flex;padding:0;margin:0;list-style:none;overflow-x:auto;overflow-y:visible}.card-bubble li{display:block;position:relative;float:none;margin:5px;box-sizing:border-box;box-shadow:var(--card-box-shadow, 0 2px 0 rgba(0,0,0,.12));border-radius:2px;width:120px;height:120px} 3 | `; -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "specifiers": { 4 | "jsr:@core/iterutil@0.9": "0.9.0", 5 | "jsr:@core/unknownutil@^4.3.0": "4.3.0", 6 | "jsr:@cosense/std@0.31": "0.31.0", 7 | "jsr:@cosense/types@0.11": "0.11.7", 8 | "jsr:@cosense/types@~0.11.3": "0.11.7", 9 | "jsr:@progfay/scrapbox-parser@10": "10.0.2", 10 | "jsr:@progfay/scrapbox-parser@^10.0.2": "10.0.2", 11 | "jsr:@std/assert@*": "1.0.16", 12 | "jsr:@std/assert@^1.0.15": "1.0.16", 13 | "jsr:@std/async@1": "1.0.15", 14 | "jsr:@std/async@^1.0.14": "1.0.15", 15 | "jsr:@std/encoding@^1.0.10": "1.0.10", 16 | "jsr:@std/fs@^1.0.19": "1.0.20", 17 | "jsr:@std/internal@^1.0.12": "1.0.12", 18 | "jsr:@std/path@^1.1.2": "1.1.3", 19 | "jsr:@std/path@^1.1.3": "1.1.3", 20 | "jsr:@std/testing@*": "1.0.16", 21 | "jsr:@takker/md5@0.1": "0.1.0", 22 | "npm:esbuild@0.27": "0.27.0", 23 | "npm:immer@11": "11.0.0", 24 | "npm:option-t@53": "53.0.0", 25 | "npm:option-t@55": "55.1.0", 26 | "npm:preact@10": "10.27.2" 27 | }, 28 | "jsr": { 29 | "@core/iterutil@0.9.0": { 30 | "integrity": "29a4ba1af8e79c2d63d96df4948bb995afb5256568401711ae97a1ef06cc67a8" 31 | }, 32 | "@core/unknownutil@4.3.0": { 33 | "integrity": "538a3687ffa81028e91d148818047df219663d0da671d906cecd165581ae55cc" 34 | }, 35 | "@cosense/std@0.31.0": { 36 | "integrity": "8d598c3deb4024b14afabf6d516ca8864c90d74cb1609040161320c2981c04c6", 37 | "dependencies": [ 38 | "jsr:@core/iterutil", 39 | "jsr:@core/unknownutil", 40 | "jsr:@cosense/types@~0.11.3", 41 | "jsr:@progfay/scrapbox-parser@^10.0.2", 42 | "jsr:@std/async@^1.0.14", 43 | "jsr:@std/encoding", 44 | "jsr:@takker/md5", 45 | "npm:option-t@53" 46 | ] 47 | }, 48 | "@cosense/types@0.11.7": { 49 | "integrity": "78452bfb5d975865e3f4ced7bfe952b9254ec6f819b271077b1b1414dc9b69aa" 50 | }, 51 | "@progfay/scrapbox-parser@10.0.2": { 52 | "integrity": "759db7c70c1f0bcc4e84853a3da59a6c248eaa87b54c927ea71382adbbfbf076" 53 | }, 54 | "@std/assert@1.0.16": { 55 | "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", 56 | "dependencies": [ 57 | "jsr:@std/internal" 58 | ] 59 | }, 60 | "@std/async@1.0.15": { 61 | "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" 62 | }, 63 | "@std/encoding@1.0.10": { 64 | "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 65 | }, 66 | "@std/fs@1.0.20": { 67 | "integrity": "e953206aae48d46ee65e8783ded459f23bec7dd1f3879512911c35e5484ea187", 68 | "dependencies": [ 69 | "jsr:@std/path@^1.1.3" 70 | ] 71 | }, 72 | "@std/internal@1.0.12": { 73 | "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 74 | }, 75 | "@std/path@1.1.3": { 76 | "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", 77 | "dependencies": [ 78 | "jsr:@std/internal" 79 | ] 80 | }, 81 | "@std/testing@1.0.16": { 82 | "integrity": "a917ffdeb5924c9be436dc78bc32e511760e14d3a96e49c607fc5ecca86d0092", 83 | "dependencies": [ 84 | "jsr:@std/assert@^1.0.15", 85 | "jsr:@std/fs", 86 | "jsr:@std/internal", 87 | "jsr:@std/path@^1.1.2" 88 | ] 89 | }, 90 | "@takker/md5@0.1.0": { 91 | "integrity": "4c423d8247aadf7bcb1eb83c727bf28c05c21906e916517395d00aa157b6eae0" 92 | } 93 | }, 94 | "npm": { 95 | "@esbuild/aix-ppc64@0.27.0": { 96 | "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", 97 | "os": ["aix"], 98 | "cpu": ["ppc64"] 99 | }, 100 | "@esbuild/android-arm64@0.27.0": { 101 | "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", 102 | "os": ["android"], 103 | "cpu": ["arm64"] 104 | }, 105 | "@esbuild/android-arm@0.27.0": { 106 | "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", 107 | "os": ["android"], 108 | "cpu": ["arm"] 109 | }, 110 | "@esbuild/android-x64@0.27.0": { 111 | "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", 112 | "os": ["android"], 113 | "cpu": ["x64"] 114 | }, 115 | "@esbuild/darwin-arm64@0.27.0": { 116 | "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", 117 | "os": ["darwin"], 118 | "cpu": ["arm64"] 119 | }, 120 | "@esbuild/darwin-x64@0.27.0": { 121 | "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", 122 | "os": ["darwin"], 123 | "cpu": ["x64"] 124 | }, 125 | "@esbuild/freebsd-arm64@0.27.0": { 126 | "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", 127 | "os": ["freebsd"], 128 | "cpu": ["arm64"] 129 | }, 130 | "@esbuild/freebsd-x64@0.27.0": { 131 | "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", 132 | "os": ["freebsd"], 133 | "cpu": ["x64"] 134 | }, 135 | "@esbuild/linux-arm64@0.27.0": { 136 | "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", 137 | "os": ["linux"], 138 | "cpu": ["arm64"] 139 | }, 140 | "@esbuild/linux-arm@0.27.0": { 141 | "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", 142 | "os": ["linux"], 143 | "cpu": ["arm"] 144 | }, 145 | "@esbuild/linux-ia32@0.27.0": { 146 | "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", 147 | "os": ["linux"], 148 | "cpu": ["ia32"] 149 | }, 150 | "@esbuild/linux-loong64@0.27.0": { 151 | "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", 152 | "os": ["linux"], 153 | "cpu": ["loong64"] 154 | }, 155 | "@esbuild/linux-mips64el@0.27.0": { 156 | "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", 157 | "os": ["linux"], 158 | "cpu": ["mips64el"] 159 | }, 160 | "@esbuild/linux-ppc64@0.27.0": { 161 | "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", 162 | "os": ["linux"], 163 | "cpu": ["ppc64"] 164 | }, 165 | "@esbuild/linux-riscv64@0.27.0": { 166 | "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", 167 | "os": ["linux"], 168 | "cpu": ["riscv64"] 169 | }, 170 | "@esbuild/linux-s390x@0.27.0": { 171 | "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", 172 | "os": ["linux"], 173 | "cpu": ["s390x"] 174 | }, 175 | "@esbuild/linux-x64@0.27.0": { 176 | "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", 177 | "os": ["linux"], 178 | "cpu": ["x64"] 179 | }, 180 | "@esbuild/netbsd-arm64@0.27.0": { 181 | "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", 182 | "os": ["netbsd"], 183 | "cpu": ["arm64"] 184 | }, 185 | "@esbuild/netbsd-x64@0.27.0": { 186 | "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", 187 | "os": ["netbsd"], 188 | "cpu": ["x64"] 189 | }, 190 | "@esbuild/openbsd-arm64@0.27.0": { 191 | "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", 192 | "os": ["openbsd"], 193 | "cpu": ["arm64"] 194 | }, 195 | "@esbuild/openbsd-x64@0.27.0": { 196 | "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", 197 | "os": ["openbsd"], 198 | "cpu": ["x64"] 199 | }, 200 | "@esbuild/openharmony-arm64@0.27.0": { 201 | "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", 202 | "os": ["openharmony"], 203 | "cpu": ["arm64"] 204 | }, 205 | "@esbuild/sunos-x64@0.27.0": { 206 | "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", 207 | "os": ["sunos"], 208 | "cpu": ["x64"] 209 | }, 210 | "@esbuild/win32-arm64@0.27.0": { 211 | "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", 212 | "os": ["win32"], 213 | "cpu": ["arm64"] 214 | }, 215 | "@esbuild/win32-ia32@0.27.0": { 216 | "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", 217 | "os": ["win32"], 218 | "cpu": ["ia32"] 219 | }, 220 | "@esbuild/win32-x64@0.27.0": { 221 | "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", 222 | "os": ["win32"], 223 | "cpu": ["x64"] 224 | }, 225 | "esbuild@0.27.0": { 226 | "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", 227 | "optionalDependencies": [ 228 | "@esbuild/aix-ppc64", 229 | "@esbuild/android-arm", 230 | "@esbuild/android-arm64", 231 | "@esbuild/android-x64", 232 | "@esbuild/darwin-arm64", 233 | "@esbuild/darwin-x64", 234 | "@esbuild/freebsd-arm64", 235 | "@esbuild/freebsd-x64", 236 | "@esbuild/linux-arm", 237 | "@esbuild/linux-arm64", 238 | "@esbuild/linux-ia32", 239 | "@esbuild/linux-loong64", 240 | "@esbuild/linux-mips64el", 241 | "@esbuild/linux-ppc64", 242 | "@esbuild/linux-riscv64", 243 | "@esbuild/linux-s390x", 244 | "@esbuild/linux-x64", 245 | "@esbuild/netbsd-arm64", 246 | "@esbuild/netbsd-x64", 247 | "@esbuild/openbsd-arm64", 248 | "@esbuild/openbsd-x64", 249 | "@esbuild/openharmony-arm64", 250 | "@esbuild/sunos-x64", 251 | "@esbuild/win32-arm64", 252 | "@esbuild/win32-ia32", 253 | "@esbuild/win32-x64" 254 | ], 255 | "scripts": true, 256 | "bin": true 257 | }, 258 | "immer@11.0.0": { 259 | "integrity": "sha512-XtRG4SINt4dpqlnJvs70O2j6hH7H0X8fUzFsjMn1rwnETaxwp83HLNimXBjZ78MrKl3/d3/pkzDH0o0Lkxm37Q==" 260 | }, 261 | "option-t@53.0.0": { 262 | "integrity": "sha512-qsyo1cFXfUN1MVnxTrpcy+V4tJYEBvEQmeiDkC6m5W/V2JiNfs4V/qXmDGZg4k9XRR1EWkv3jT8TXSZnhGPBLA==" 263 | }, 264 | "option-t@55.1.0": { 265 | "integrity": "sha512-9zYDDLVyGXhhnJJyeZpzwqce0T5JP0tmAfxY4/uVq7b9SRa91jEANYgWXqNDJiewP78OhGYsRz8kB/2TnGfQFw==" 266 | }, 267 | "preact@10.27.2": { 268 | "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==" 269 | } 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /__snapshots__/convert.test.ts.snap: -------------------------------------------------------------------------------- 1 | export const snapshot = {}; 2 | 3 | snapshot[`convert > one project > 逆・順・双方向・空リンク+中身あり・空headword+複数パス2 hop links 1`] = ` 4 | Map(12) { 5 | "/takker-dist/a" => { 6 | descriptions: [ 7 | "[A1],[A2],[A3]", 8 | "https://scrapbox.io/api/pages/takker-dist/A", 9 | "[https://kakeru.app/9616a5d5938df1d726b8a2e07ebbf581 https://i.kakeru.app/9616a5d5938df1d726b8a2e07ebbf581.svg]", 10 | ], 11 | exists: true, 12 | image: "https://i.kakeru.app/9616a5d5938df1d726b8a2e07ebbf581.svg", 13 | isLinkedCorrect: false, 14 | lines: [ 15 | { 16 | created: 1734060505, 17 | id: "dummy", 18 | text: "A", 19 | updated: 1734060505, 20 | userId: "dummy", 21 | }, 22 | { 23 | created: 1734060505, 24 | id: "dummy", 25 | text: "[A1],[A2],[A3]", 26 | updated: 1734060505, 27 | userId: "dummy", 28 | }, 29 | { 30 | created: 1734060505, 31 | id: "dummy", 32 | text: "https://scrapbox.io/api/pages/takker-dist/A", 33 | updated: 1734060505, 34 | userId: "dummy", 35 | }, 36 | { 37 | created: 1734060505, 38 | id: "dummy", 39 | text: "[https://kakeru.app/9616a5d5938df1d726b8a2e07ebbf581 https://i.kakeru.app/9616a5d5938df1d726b8a2e07ebbf581.svg]", 40 | updated: 1734060505, 41 | userId: "dummy", 42 | }, 43 | ], 44 | project: "takker-dist", 45 | titleLc: "a", 46 | updated: 1734060505, 47 | }, 48 | "/takker-dist/a1" => { 49 | descriptions: [], 50 | exists: false, 51 | image: null, 52 | isLinkedCorrect: false, 53 | lines: [ 54 | { 55 | created: 0, 56 | id: "dummy", 57 | text: "A1", 58 | updated: 0, 59 | userId: "dummy", 60 | }, 61 | ], 62 | linked: [ 63 | "b", 64 | "a", 65 | ], 66 | project: "takker-dist", 67 | titleLc: "a1", 68 | updated: 0, 69 | }, 70 | "/takker-dist/b" => { 71 | descriptions: [ 72 | "[B1], [B2], [B3]", 73 | "[A1]", 74 | "https://scrapbox.io/api/pages/takker-dist/B", 75 | ], 76 | exists: true, 77 | image: null, 78 | isLinkedCorrect: true, 79 | lines: [ 80 | { 81 | created: 1672173349, 82 | id: "63ab57239db1b2001edc43b0", 83 | text: "B", 84 | updated: 1672173349, 85 | userId: "5ef2bdebb60650001e1280f0", 86 | }, 87 | { 88 | created: 1672173349, 89 | id: "63ab57261280f000002d941d", 90 | text: "[B1], [B2], [B3]", 91 | updated: 1672173361, 92 | userId: "5ef2bdebb60650001e1280f0", 93 | }, 94 | { 95 | created: 1672173407, 96 | id: "63ab575f1280f000002d941f", 97 | text: "[A1]", 98 | updated: 1672173410, 99 | userId: "5ef2bdebb60650001e1280f0", 100 | }, 101 | { 102 | created: 1672173351, 103 | id: "63ab57271280f000002d941e", 104 | text: "https://scrapbox.io/api/pages/takker-dist/B", 105 | updated: 1673081924, 106 | userId: "5ef2bdebb60650001e1280f0", 107 | }, 108 | { 109 | created: 1673081924, 110 | id: "63b934441280f00000606f45", 111 | text: "", 112 | updated: 1673081924, 113 | userId: "5ef2bdebb60650001e1280f0", 114 | }, 115 | ], 116 | linked: [ 117 | "i", 118 | "b1", 119 | ], 120 | project: "takker-dist", 121 | projectLinked: [], 122 | titleLc: "b", 123 | updated: 1673081924, 124 | }, 125 | "/takker-dist/b1" => { 126 | descriptions: [ 127 | "[B]", 128 | ], 129 | exists: true, 130 | image: null, 131 | isLinkedCorrect: false, 132 | lines: [ 133 | { 134 | created: 1673081852, 135 | id: "dummy", 136 | text: "B1", 137 | updated: 1673081852, 138 | userId: "dummy", 139 | }, 140 | { 141 | created: 1673081852, 142 | id: "dummy", 143 | text: "[B]", 144 | updated: 1673081852, 145 | userId: "dummy", 146 | }, 147 | ], 148 | linked: [ 149 | "b", 150 | "i", 151 | "k", 152 | "j", 153 | "e", 154 | "d", 155 | "c", 156 | ], 157 | project: "takker-dist", 158 | titleLc: "b1", 159 | updated: 1673081852, 160 | }, 161 | "/takker-dist/b2" => { 162 | descriptions: [], 163 | exists: false, 164 | image: null, 165 | isLinkedCorrect: false, 166 | lines: [ 167 | { 168 | created: 0, 169 | id: "dummy", 170 | text: "B2", 171 | updated: 0, 172 | userId: "dummy", 173 | }, 174 | ], 175 | linked: [ 176 | "b", 177 | "e", 178 | ], 179 | project: "takker-dist", 180 | titleLc: "b2", 181 | updated: 0, 182 | }, 183 | "/takker-dist/b3" => { 184 | descriptions: [], 185 | exists: false, 186 | image: null, 187 | isLinkedCorrect: false, 188 | lines: [ 189 | { 190 | created: 0, 191 | id: "dummy", 192 | text: "B3", 193 | updated: 0, 194 | userId: "dummy", 195 | }, 196 | ], 197 | linked: [ 198 | "b", 199 | ], 200 | project: "takker-dist", 201 | titleLc: "b3", 202 | updated: 0, 203 | }, 204 | "/takker-dist/c" => { 205 | descriptions: [ 206 | "[C1], [C2], [C3]", 207 | "[A2], [A3]", 208 | "[B1]", 209 | ], 210 | exists: true, 211 | image: null, 212 | isLinkedCorrect: false, 213 | lines: [ 214 | { 215 | created: 1673081866, 216 | id: "dummy", 217 | text: "C", 218 | updated: 1673081866, 219 | userId: "dummy", 220 | }, 221 | { 222 | created: 1673081866, 223 | id: "dummy", 224 | text: "[C1], [C2], [C3]", 225 | updated: 1673081866, 226 | userId: "dummy", 227 | }, 228 | { 229 | created: 1673081866, 230 | id: "dummy", 231 | text: "[A2], [A3]", 232 | updated: 1673081866, 233 | userId: "dummy", 234 | }, 235 | { 236 | created: 1673081866, 237 | id: "dummy", 238 | text: "[B1]", 239 | updated: 1673081866, 240 | userId: "dummy", 241 | }, 242 | ], 243 | project: "takker-dist", 244 | titleLc: "c", 245 | updated: 1673081866, 246 | }, 247 | "/takker-dist/d" => { 248 | descriptions: [ 249 | "[B1]", 250 | ], 251 | exists: true, 252 | image: null, 253 | isLinkedCorrect: false, 254 | lines: [ 255 | { 256 | created: 1673081879, 257 | id: "dummy", 258 | text: "D", 259 | updated: 1673081879, 260 | userId: "dummy", 261 | }, 262 | { 263 | created: 1673081879, 264 | id: "dummy", 265 | text: "[B1]", 266 | updated: 1673081879, 267 | userId: "dummy", 268 | }, 269 | ], 270 | project: "takker-dist", 271 | titleLc: "d", 272 | updated: 1673081879, 273 | }, 274 | "/takker-dist/e" => { 275 | descriptions: [ 276 | "[B1], [B2]", 277 | ], 278 | exists: true, 279 | image: null, 280 | isLinkedCorrect: false, 281 | lines: [ 282 | { 283 | created: 1673081909, 284 | id: "dummy", 285 | text: "E", 286 | updated: 1673081909, 287 | userId: "dummy", 288 | }, 289 | { 290 | created: 1673081909, 291 | id: "dummy", 292 | text: "[B1], [B2]", 293 | updated: 1673081909, 294 | userId: "dummy", 295 | }, 296 | ], 297 | project: "takker-dist", 298 | titleLc: "e", 299 | updated: 1673081909, 300 | }, 301 | "/takker-dist/i" => { 302 | descriptions: [ 303 | "[B] [B1] [K]", 304 | "https://scrapbox.io/api/pages/takker-dist/I", 305 | ], 306 | exists: true, 307 | image: null, 308 | isLinkedCorrect: false, 309 | lines: [ 310 | { 311 | created: 1673572411, 312 | id: "dummy", 313 | text: "I", 314 | updated: 1673572411, 315 | userId: "dummy", 316 | }, 317 | { 318 | created: 1673572411, 319 | id: "dummy", 320 | text: "[B] [B1] [K]", 321 | updated: 1673572411, 322 | userId: "dummy", 323 | }, 324 | { 325 | created: 1673572411, 326 | id: "dummy", 327 | text: "https://scrapbox.io/api/pages/takker-dist/I", 328 | updated: 1673572411, 329 | userId: "dummy", 330 | }, 331 | ], 332 | linked: [ 333 | "k", 334 | "j", 335 | ], 336 | project: "takker-dist", 337 | titleLc: "i", 338 | updated: 1673572411, 339 | }, 340 | "/takker-dist/j" => { 341 | descriptions: [ 342 | "[I] [B1]", 343 | ], 344 | exists: true, 345 | image: null, 346 | isLinkedCorrect: false, 347 | lines: [ 348 | { 349 | created: 1673572354, 350 | id: "dummy", 351 | text: "J", 352 | updated: 1673572354, 353 | userId: "dummy", 354 | }, 355 | { 356 | created: 1673572354, 357 | id: "dummy", 358 | text: "[I] [B1]", 359 | updated: 1673572354, 360 | userId: "dummy", 361 | }, 362 | ], 363 | project: "takker-dist", 364 | titleLc: "j", 365 | updated: 1673572354, 366 | }, 367 | "/takker-dist/k" => { 368 | descriptions: [ 369 | "[I] [B1]", 370 | ], 371 | exists: true, 372 | image: null, 373 | isLinkedCorrect: false, 374 | lines: [ 375 | { 376 | created: 1673572392, 377 | id: "dummy", 378 | text: "K", 379 | updated: 1673572392, 380 | userId: "dummy", 381 | }, 382 | { 383 | created: 1673572392, 384 | id: "dummy", 385 | text: "[I] [B1]", 386 | updated: 1673572392, 387 | userId: "dummy", 388 | }, 389 | ], 390 | project: "takker-dist", 391 | titleLc: "k", 392 | updated: 1673572392, 393 | }, 394 | } 395 | `; 396 | 397 | snapshot[`convert > one project > (逆リンク|順リンク|双方向リンク)+2 hop links 1`] = ` 398 | Map(12) { 399 | "/takker-dist/a" => { 400 | descriptions: [ 401 | "[A1],[A2],[A3]", 402 | "https://scrapbox.io/api/pages/takker-dist/A", 403 | "[https://kakeru.app/9616a5d5938df1d726b8a2e07ebbf581 https://i.kakeru.app/9616a5d5938df1d726b8a2e07ebbf581.svg]", 404 | ], 405 | exists: true, 406 | image: "https://i.kakeru.app/9616a5d5938df1d726b8a2e07ebbf581.svg", 407 | isLinkedCorrect: false, 408 | lines: [ 409 | { 410 | created: 1734060505, 411 | id: "dummy", 412 | text: "A", 413 | updated: 1734060505, 414 | userId: "dummy", 415 | }, 416 | { 417 | created: 1734060505, 418 | id: "dummy", 419 | text: "[A1],[A2],[A3]", 420 | updated: 1734060505, 421 | userId: "dummy", 422 | }, 423 | { 424 | created: 1734060505, 425 | id: "dummy", 426 | text: "https://scrapbox.io/api/pages/takker-dist/A", 427 | updated: 1734060505, 428 | userId: "dummy", 429 | }, 430 | { 431 | created: 1734060505, 432 | id: "dummy", 433 | text: "[https://kakeru.app/9616a5d5938df1d726b8a2e07ebbf581 https://i.kakeru.app/9616a5d5938df1d726b8a2e07ebbf581.svg]", 434 | updated: 1734060505, 435 | userId: "dummy", 436 | }, 437 | ], 438 | project: "takker-dist", 439 | titleLc: "a", 440 | updated: 1734060505, 441 | }, 442 | "/takker-dist/a1" => { 443 | descriptions: [], 444 | exists: false, 445 | image: null, 446 | isLinkedCorrect: false, 447 | lines: [ 448 | { 449 | created: 0, 450 | id: "dummy", 451 | text: "A1", 452 | updated: 0, 453 | userId: "dummy", 454 | }, 455 | ], 456 | linked: [ 457 | "b", 458 | "a", 459 | ], 460 | project: "takker-dist", 461 | titleLc: "a1", 462 | updated: 0, 463 | }, 464 | "/takker-dist/b" => { 465 | descriptions: [ 466 | "[B1], [B2], [B3]", 467 | "[A1]", 468 | "https://scrapbox.io/api/pages/takker-dist/B", 469 | ], 470 | exists: true, 471 | image: null, 472 | isLinkedCorrect: true, 473 | lines: [ 474 | { 475 | created: 1672173349, 476 | id: "63ab57239db1b2001edc43b0", 477 | text: "B", 478 | updated: 1672173349, 479 | userId: "5ef2bdebb60650001e1280f0", 480 | }, 481 | { 482 | created: 1672173349, 483 | id: "63ab57261280f000002d941d", 484 | text: "[B1], [B2], [B3]", 485 | updated: 1672173361, 486 | userId: "5ef2bdebb60650001e1280f0", 487 | }, 488 | { 489 | created: 1672173407, 490 | id: "63ab575f1280f000002d941f", 491 | text: "[A1]", 492 | updated: 1672173410, 493 | userId: "5ef2bdebb60650001e1280f0", 494 | }, 495 | { 496 | created: 1672173351, 497 | id: "63ab57271280f000002d941e", 498 | text: "https://scrapbox.io/api/pages/takker-dist/B", 499 | updated: 1673081924, 500 | userId: "5ef2bdebb60650001e1280f0", 501 | }, 502 | { 503 | created: 1759205419, 504 | id: "68db582c00000000001dd307", 505 | text: "", 506 | updated: 1759205419, 507 | userId: "5ef2bdebb60650001e1280f0", 508 | }, 509 | ], 510 | linked: [ 511 | "i", 512 | "b1", 513 | ], 514 | project: "takker-dist", 515 | projectLinked: [], 516 | titleLc: "b", 517 | updated: 1759205419, 518 | }, 519 | "/takker-dist/b1" => { 520 | descriptions: [ 521 | "[B]", 522 | ], 523 | exists: true, 524 | image: null, 525 | isLinkedCorrect: false, 526 | lines: [ 527 | { 528 | created: 1673081852, 529 | id: "dummy", 530 | text: "B1", 531 | updated: 1673081852, 532 | userId: "dummy", 533 | }, 534 | { 535 | created: 1673081852, 536 | id: "dummy", 537 | text: "[B]", 538 | updated: 1673081852, 539 | userId: "dummy", 540 | }, 541 | ], 542 | linked: [ 543 | "b", 544 | "i", 545 | "k", 546 | "j", 547 | "e", 548 | "d", 549 | "c", 550 | ], 551 | project: "takker-dist", 552 | titleLc: "b1", 553 | updated: 1673081852, 554 | }, 555 | "/takker-dist/b2" => { 556 | descriptions: [], 557 | exists: false, 558 | image: null, 559 | isLinkedCorrect: false, 560 | lines: [ 561 | { 562 | created: 0, 563 | id: "dummy", 564 | text: "B2", 565 | updated: 0, 566 | userId: "dummy", 567 | }, 568 | ], 569 | linked: [ 570 | "b", 571 | "e", 572 | ], 573 | project: "takker-dist", 574 | titleLc: "b2", 575 | updated: 0, 576 | }, 577 | "/takker-dist/b3" => { 578 | descriptions: [], 579 | exists: false, 580 | image: null, 581 | isLinkedCorrect: false, 582 | lines: [ 583 | { 584 | created: 0, 585 | id: "dummy", 586 | text: "B3", 587 | updated: 0, 588 | userId: "dummy", 589 | }, 590 | ], 591 | linked: [ 592 | "b", 593 | ], 594 | project: "takker-dist", 595 | titleLc: "b3", 596 | updated: 0, 597 | }, 598 | "/takker-dist/c" => { 599 | descriptions: [ 600 | "[C1], [C2], [C3]", 601 | "[A2], [A3]", 602 | "[B1]", 603 | ], 604 | exists: true, 605 | image: null, 606 | isLinkedCorrect: false, 607 | lines: [ 608 | { 609 | created: 1673081866, 610 | id: "dummy", 611 | text: "C", 612 | updated: 1673081866, 613 | userId: "dummy", 614 | }, 615 | { 616 | created: 1673081866, 617 | id: "dummy", 618 | text: "[C1], [C2], [C3]", 619 | updated: 1673081866, 620 | userId: "dummy", 621 | }, 622 | { 623 | created: 1673081866, 624 | id: "dummy", 625 | text: "[A2], [A3]", 626 | updated: 1673081866, 627 | userId: "dummy", 628 | }, 629 | { 630 | created: 1673081866, 631 | id: "dummy", 632 | text: "[B1]", 633 | updated: 1673081866, 634 | userId: "dummy", 635 | }, 636 | ], 637 | project: "takker-dist", 638 | titleLc: "c", 639 | updated: 1673081866, 640 | }, 641 | "/takker-dist/d" => { 642 | descriptions: [ 643 | "[B1]", 644 | ], 645 | exists: true, 646 | image: null, 647 | isLinkedCorrect: false, 648 | lines: [ 649 | { 650 | created: 1673081879, 651 | id: "dummy", 652 | text: "D", 653 | updated: 1673081879, 654 | userId: "dummy", 655 | }, 656 | { 657 | created: 1673081879, 658 | id: "dummy", 659 | text: "[B1]", 660 | updated: 1673081879, 661 | userId: "dummy", 662 | }, 663 | ], 664 | project: "takker-dist", 665 | titleLc: "d", 666 | updated: 1673081879, 667 | }, 668 | "/takker-dist/e" => { 669 | descriptions: [ 670 | "[B1], [B2]", 671 | ], 672 | exists: true, 673 | image: null, 674 | isLinkedCorrect: false, 675 | lines: [ 676 | { 677 | created: 1673081909, 678 | id: "dummy", 679 | text: "E", 680 | updated: 1673081909, 681 | userId: "dummy", 682 | }, 683 | { 684 | created: 1673081909, 685 | id: "dummy", 686 | text: "[B1], [B2]", 687 | updated: 1673081909, 688 | userId: "dummy", 689 | }, 690 | ], 691 | project: "takker-dist", 692 | titleLc: "e", 693 | updated: 1673081909, 694 | }, 695 | "/takker-dist/i" => { 696 | descriptions: [ 697 | "[B] [B1] [K]", 698 | "https://scrapbox.io/api/pages/takker-dist/I", 699 | ], 700 | exists: true, 701 | image: null, 702 | isLinkedCorrect: false, 703 | lines: [ 704 | { 705 | created: 1673572411, 706 | id: "dummy", 707 | text: "I", 708 | updated: 1673572411, 709 | userId: "dummy", 710 | }, 711 | { 712 | created: 1673572411, 713 | id: "dummy", 714 | text: "[B] [B1] [K]", 715 | updated: 1673572411, 716 | userId: "dummy", 717 | }, 718 | { 719 | created: 1673572411, 720 | id: "dummy", 721 | text: "https://scrapbox.io/api/pages/takker-dist/I", 722 | updated: 1673572411, 723 | userId: "dummy", 724 | }, 725 | ], 726 | linked: [ 727 | "k", 728 | "j", 729 | ], 730 | project: "takker-dist", 731 | titleLc: "i", 732 | updated: 1673572411, 733 | }, 734 | "/takker-dist/j" => { 735 | descriptions: [ 736 | "[I] [B1]", 737 | ], 738 | exists: true, 739 | image: null, 740 | isLinkedCorrect: false, 741 | lines: [ 742 | { 743 | created: 1673572354, 744 | id: "dummy", 745 | text: "J", 746 | updated: 1673572354, 747 | userId: "dummy", 748 | }, 749 | { 750 | created: 1673572354, 751 | id: "dummy", 752 | text: "[I] [B1]", 753 | updated: 1673572354, 754 | userId: "dummy", 755 | }, 756 | ], 757 | project: "takker-dist", 758 | titleLc: "j", 759 | updated: 1673572354, 760 | }, 761 | "/takker-dist/k" => { 762 | descriptions: [ 763 | "[I] [B1]", 764 | ], 765 | exists: true, 766 | image: null, 767 | isLinkedCorrect: false, 768 | lines: [ 769 | { 770 | created: 1673572392, 771 | id: "dummy", 772 | text: "K", 773 | updated: 1673572392, 774 | userId: "dummy", 775 | }, 776 | { 777 | created: 1673572392, 778 | id: "dummy", 779 | text: "[I] [B1]", 780 | updated: 1673572392, 781 | userId: "dummy", 782 | }, 783 | ], 784 | project: "takker-dist", 785 | titleLc: "k", 786 | updated: 1673572392, 787 | }, 788 | } 789 | `; 790 | 791 | snapshot[`convert > two projects > 逆・順・双方向External links 1`] = ` 792 | Map(4) { 793 | "/takker-dist/f" => { 794 | descriptions: [ 795 | "[/takker-dist2/B]", 796 | "[/takker-dist2/C]", 797 | "[/takker-dist2/G]", 798 | "https://scrapbox.io/api/pages/takker-dist/F", 799 | ], 800 | exists: true, 801 | image: null, 802 | isLinkedCorrect: true, 803 | lines: [ 804 | { 805 | created: 1673336497, 806 | id: "63bd16afdb71fb001d058f0e", 807 | text: "F", 808 | updated: 1673336497, 809 | userId: "5ef2bdebb60650001e1280f0", 810 | }, 811 | { 812 | created: 1673336497, 813 | id: "63bd16b11280f00000b00965", 814 | text: "[/takker-dist2/B]", 815 | updated: 1673336501, 816 | userId: "5ef2bdebb60650001e1280f0", 817 | }, 818 | { 819 | created: 1673336501, 820 | id: "63bd16b51280f00000b00966", 821 | text: "[/takker-dist2/C]", 822 | updated: 1673336571, 823 | userId: "5ef2bdebb60650001e1280f0", 824 | }, 825 | { 826 | created: 1673336580, 827 | id: "63bd17041280f00000b0096b", 828 | text: "[/takker-dist2/G]", 829 | updated: 1673336854, 830 | userId: "5ef2bdebb60650001e1280f0", 831 | }, 832 | { 833 | created: 1673336594, 834 | id: "63bd17111280f00000b0096c", 835 | text: "https://scrapbox.io/api/pages/takker-dist/F", 836 | updated: 1673336640, 837 | userId: "5ef2bdebb60650001e1280f0", 838 | }, 839 | { 840 | created: 1673336640, 841 | id: "63bd17401280f00000b0096e", 842 | text: "", 843 | updated: 1673336640, 844 | userId: "5ef2bdebb60650001e1280f0", 845 | }, 846 | ], 847 | linked: [], 848 | project: "takker-dist", 849 | projectLinked: [ 850 | "/takker-dist2/a", 851 | ], 852 | titleLc: "f", 853 | updated: 1673336854, 854 | }, 855 | "/takker-dist2/a" => { 856 | descriptions: [ 857 | "[/takker-dist/F]", 858 | ], 859 | exists: true, 860 | image: null, 861 | isLinkedCorrect: false, 862 | lines: [ 863 | { 864 | created: 1673336561, 865 | id: "dummy", 866 | text: "A", 867 | updated: 1673336561, 868 | userId: "dummy", 869 | }, 870 | { 871 | created: 1673336561, 872 | id: "dummy", 873 | text: "[/takker-dist/F]", 874 | updated: 1673336561, 875 | userId: "dummy", 876 | }, 877 | ], 878 | project: "takker-dist2", 879 | titleLc: "a", 880 | updated: 1673336561, 881 | }, 882 | "/takker-dist2/b" => { 883 | descriptions: [ 884 | "[B1] [B2]", 885 | ], 886 | exists: true, 887 | image: null, 888 | isLinkedCorrect: false, 889 | lines: [ 890 | { 891 | created: 1673336514, 892 | id: "dummy", 893 | text: "B", 894 | updated: 1673336514, 895 | userId: "dummy", 896 | }, 897 | { 898 | created: 1673336514, 899 | id: "dummy", 900 | text: "[B1] [B2]", 901 | updated: 1673336514, 902 | userId: "dummy", 903 | }, 904 | ], 905 | project: "takker-dist2", 906 | titleLc: "b", 907 | updated: 1673336514, 908 | }, 909 | "/takker-dist2/c" => { 910 | descriptions: [ 911 | "[/takker-dist/F]", 912 | ], 913 | exists: true, 914 | image: null, 915 | isLinkedCorrect: false, 916 | lines: [ 917 | { 918 | created: 1673336584, 919 | id: "dummy", 920 | text: "C", 921 | updated: 1673336584, 922 | userId: "dummy", 923 | }, 924 | { 925 | created: 1673336584, 926 | id: "dummy", 927 | text: "[/takker-dist/F]", 928 | updated: 1673336584, 929 | userId: "dummy", 930 | }, 931 | ], 932 | project: "takker-dist2", 933 | titleLc: "c", 934 | updated: 1673336584, 935 | }, 936 | } 937 | `; 938 | -------------------------------------------------------------------------------- /Page.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:preact@10 */ 3 | import { 4 | type ComponentChildren, 5 | createContext, 6 | type h, 7 | useCallback, 8 | useContext, 9 | useEffect, 10 | useMemo, 11 | useRef, 12 | useState, 13 | } from "./deps/preact.tsx"; 14 | import { useKaTeX } from "./useKaTeX.ts"; 15 | import type { 16 | CodeBlock as CodeBlockType, 17 | CommandLineNode, 18 | DecorationNode, 19 | FormulaNode, 20 | GoogleMapNode, 21 | HashTagNode, 22 | IconNode, 23 | ImageNode, 24 | Line as LineType, 25 | LinkNode, 26 | Node as NodeType, 27 | StrongIconNode, 28 | Table as TableType, 29 | } from "./deps/scrapbox-parser.ts"; 30 | import { parseLink } from "./parseLink.ts"; 31 | import { hasLink } from "./hasLink.ts"; 32 | import { toId } from "./id.ts"; 33 | import { stayHovering } from "./stayHovering.ts"; 34 | import type { BubbleOperators } from "./useBubbles.ts"; 35 | import type { LinkTo } from "./types.ts"; 36 | import { useBubbleData } from "./useBubbleData.ts"; 37 | import { isEmptyLink } from "./storage.ts"; 38 | import { calcBubblePosition } from "./position.ts"; 39 | import { parse } from "./deps/scrapbox-parser.ts"; 40 | import { 41 | type AnchorFMNode, 42 | type AudioNode, 43 | encodeTitleURI, 44 | parseAbsoluteLink, 45 | type SpotifyNode, 46 | type VideoNode, 47 | type VimeoNode, 48 | type YoutubeListNode, 49 | type YoutubeNode, 50 | } from "./deps/scrapbox-std.ts"; 51 | import type { Scrapbox } from "./deps/scrapbox.ts"; 52 | import { useTheme } from "./useTheme.ts"; 53 | import { delay } from "./deps/async.ts"; 54 | declare const scrapbox: Scrapbox; 55 | 56 | declare global { 57 | interface Window { 58 | /** CSRF token */ 59 | _csrf?: string; 60 | } 61 | } 62 | 63 | export interface PageProps extends BubbleOperators { 64 | title: string; 65 | project: string; 66 | whiteList: Set; 67 | delay: number; 68 | lines: { text: string; id: string }[] | string[]; 69 | noIndent?: boolean; 70 | hash?: string; 71 | linkTo?: LinkTo; 72 | 73 | prefetch: (project: string, title: string) => void; 74 | } 75 | 76 | // 限定のcontext 77 | const context = createContext< 78 | & { 79 | title: string; 80 | project: string; 81 | whiteList: Set; 82 | delay: number; 83 | prefetch: (project: string, title: string) => void; 84 | } 85 | & BubbleOperators 86 | >({ 87 | title: "", 88 | project: "", 89 | whiteList: new Set(), 90 | bubble: () => {}, 91 | hide: () => {}, 92 | delay: 0, 93 | prefetch: () => {}, 94 | }); 95 | 96 | export const Page = ( 97 | { lines, project, title, whiteList, noIndent, hash, linkTo, ...props }: 98 | PageProps, 99 | ): h.JSX.Element => { 100 | const lineIds = useMemo( 101 | () => lines.flatMap((line) => typeof line === "string" ? [] : [line.id]), 102 | [lines], 103 | ); 104 | const blocks = useMemo(() => { 105 | let counter = 0; 106 | return parse( 107 | lines.map((line) => typeof line === "string" ? line : line.text).join( 108 | "\n", 109 | ), 110 | { hasTitle: true }, 111 | ).map((block) => { 112 | switch (block.type) { 113 | case "title": 114 | case "line": 115 | return { 116 | ...block, 117 | id: lineIds[counter++], 118 | }; 119 | case "codeBlock": { 120 | const start = counter; 121 | counter += block.content.split("\n").length + 1; 122 | return { 123 | ...block, 124 | ids: lineIds.slice(start, counter), 125 | }; 126 | } 127 | case "table": { 128 | const start = counter; 129 | counter += block.cells.length + 1; 130 | return { 131 | ...block, 132 | ids: lineIds.slice(start, counter), 133 | }; 134 | } 135 | } 136 | }); 137 | }, [lines, lineIds]); 138 | 139 | const scrollId = useMemo(() => { 140 | if (hash && lineIds.includes(hash)) return hash; 141 | if (!linkTo) return; 142 | 143 | return (blocks.find((block) => { 144 | if (block.type !== "line") return false; 145 | 146 | return hasLink(linkTo, block.nodes); 147 | }) as LineType & { id: string } | undefined)?.id; 148 | }, [blocks, lineIds, hash, linkTo?.project, linkTo?.titleLc]); 149 | 150 | // リンク先にスクロールする 151 | const ref = useRef(null); 152 | useEffect(() => { 153 | if (!scrollId) return; 154 | 155 | const targetLine = ref.current?.querySelector(`[data-id="${scrollId}"]`); 156 | const scrollY = globalThis.scrollY; 157 | targetLine?.scrollIntoView?.({ block: "center" }); 158 | globalThis.scroll(0, scrollY); 159 | }, [scrollId]); 160 | 161 | const theme = useTheme(project); 162 | 163 | return ( 164 |
165 | 168 | {blocks.map((block) => { 169 | switch (block.type) { 170 | case "title": 171 | return ( 172 | <> 173 | 180 | 189 | {block.text} 190 | 191 | 192 |
193 | 194 | ); 195 | case "codeBlock": { 196 | return ( 197 | 204 | ); 205 | } 206 | case "table": { 207 | return ( 208 | 215 | ); 216 | } 217 | case "line": 218 | return ( 219 | 226 | {block.nodes.length > 0 227 | ? block.nodes.map((node) => ( 228 | 229 | )) 230 | :
} 231 |
232 | ); 233 | } 234 | })} 235 | 236 | 237 | ); 238 | }; 239 | 240 | type LineProps = { 241 | index: string; 242 | indent: number; 243 | permalink?: boolean; 244 | noIndent?: boolean; 245 | children: ComponentChildren; 246 | }; 247 | 248 | const Line = ({ index, indent, noIndent, children, permalink }: LineProps) => ( 249 |
255 | {children} 256 |
257 | ); 258 | 259 | type CodeBlockProps = { 260 | block: CodeBlockType; 261 | noIndent?: boolean; 262 | ids: string[]; 263 | scrollId?: string; 264 | }; 265 | const CodeBlock = ( 266 | { block: { fileName, content, indent }, ids, scrollId }: CodeBlockProps, 267 | ) => { 268 | const { project, title } = useContext(context); 269 | const [buttonLabel, setButtonLabel] = useState("\uf0c5"); 270 | const handleClick = useCallback( 271 | async (e: h.JSX.TargetedMouseEvent) => { 272 | e.preventDefault(); 273 | e.stopPropagation(); 274 | try { 275 | await navigator.clipboard.writeText(content); 276 | setButtonLabel("Copied"); 277 | await delay(1000); 278 | setButtonLabel("\uf0c5"); 279 | } catch (e: unknown) { 280 | alert(`Failed to copy the code block\nError: ${e}`); 281 | } 282 | }, 283 | [content], 284 | ); 285 | 286 | return ( 287 | <> 288 | 289 | 290 | 291 | 295 | {fileName} 296 | 297 | 298 | 299 | {buttonLabel} 300 | 301 | 302 | 303 | <> 304 | {content.split("\n").map((line, index) => ( 305 | 310 | 311 | {line} 312 | 313 | 314 | ))} 315 | 316 | 317 | ); 318 | }; 319 | 320 | type TableProps = { 321 | block: TableType; 322 | noIndent?: boolean; 323 | scrollId?: string; 324 | ids: string[]; 325 | }; 326 | const Table = ( 327 | { 328 | block: { fileName, cells, indent }, 329 | ids, 330 | scrollId, 331 | }: TableProps, 332 | ) => { 333 | const { project, title } = useContext(context); 334 | 335 | return ( 336 | <> 337 | 338 | 339 | 340 | 346 | {fileName} 347 | 348 | 349 | 350 | 351 | <> 352 | {cells.map((cell, i) => ( 353 | 358 | 359 | {cell.map((row, index) => ( 360 | 361 | {row.map((node) => )} 362 | 363 | ))} 364 | 365 | 366 | ))} 367 | 368 | 369 | ); 370 | }; 371 | 372 | type NodeProps = { 373 | node: NodeType; 374 | }; 375 | const Node = ({ node }: NodeProps) => { 376 | switch (node.type) { 377 | case "code": 378 | return {node.text}; 379 | case "formula": 380 | return ; 381 | case "commandLine": 382 | return ; 383 | case "helpfeel": 384 | return ( 385 | 386 | ?{" "} 387 | {node.text} 388 | 389 | ); 390 | case "quote": 391 | return ( 392 |
393 | {node.nodes.map((node) => )} 394 |
395 | ); 396 | case "strong": 397 | return ( 398 | 399 | {node.nodes.map((node) => )} 400 | 401 | ); 402 | case "decoration": 403 | return ; 404 | case "plain": 405 | case "blank": 406 | return <>{node.text}; 407 | case "hashTag": 408 | return ; 409 | case "link": 410 | return ; 411 | case "googleMap": 412 | return ; 413 | case "icon": 414 | return ; 415 | case "strongIcon": 416 | return ; 417 | case "image": 418 | return ; 419 | case "strongImage": 420 | return ; 421 | case "numberList": 422 | return ( 423 | <> 424 | {`${node.number}. `} 425 | {node.nodes.map((node) => )} 426 | 427 | ); 428 | } 429 | }; 430 | 431 | type FormulaProps = { node: FormulaNode }; 432 | const Formula = ({ node: { formula } }: FormulaProps) => { 433 | const { ref, error } = useKaTeX(formula); 434 | 435 | return ( 436 | 437 | {error 438 | ? {formula} 439 | : } 440 | 441 | ); 442 | }; 443 | type DecorationProps = { node: DecorationNode }; 444 | const Decoration = ( 445 | { node: { decos, nodes } }: DecorationProps, 446 | ) => ( 447 | `deco-${deco}`).join(" ")}> 448 | {nodes.map((node) => )} 449 | 450 | ); 451 | 452 | type CommandLineProps = { node: CommandLineNode }; 453 | const CommandLine = ( 454 | { node }: CommandLineProps, 455 | ) => { 456 | const [buttonLabel, setButtonLabel] = useState("\uf0c5"); 457 | const handleClick = useCallback( 458 | async (e: h.JSX.TargetedMouseEvent) => { 459 | e.preventDefault(); 460 | e.stopPropagation(); 461 | try { 462 | await navigator.clipboard.writeText(node.text); 463 | setButtonLabel("Copied"); 464 | await delay(1000); 465 | setButtonLabel("\uf0c5"); 466 | } catch (e: unknown) { 467 | alert(`Failed to copy the code block\nError: ${e}`); 468 | } 469 | }, 470 | [node.text], 471 | ); 472 | 473 | return ( 474 | <> 475 | 476 | {node.symbol}{" "} 477 | {node.text} 478 | 479 | 480 | 481 | {buttonLabel} 482 | 483 | 484 | 485 | ); 486 | }; 487 | type GoogleMapProps = { node: GoogleMapNode }; 488 | const GoogleMap = ( 489 | { node: { place, latitude, longitude, zoom } }: GoogleMapProps, 490 | ) => ( 491 | 492 | 497 | 503 | 504 | 505 | ); 506 | type IconProps = { 507 | node: IconNode; 508 | strong?: false; 509 | } | { 510 | node: StrongIconNode; 511 | strong: true; 512 | }; 513 | const Icon = ( 514 | { node: { pathType, path }, strong }: IconProps, 515 | ) => { 516 | const { project: _project } = useContext(context); 517 | const [project, title] = pathType === "relative" 518 | ? [ 519 | _project, 520 | path, 521 | ] 522 | : path.match(/\/([\w\-]+)\/(.+)$/)?.slice?.(1) ?? [_project, path]; 523 | const titleLc = encodeTitleURI(title); 524 | 525 | return ( 526 | 531 | {title} 536 | 537 | ); 538 | }; 539 | type ImageProps = { node: ImageNode }; 540 | const Image = ({ node: { link, src } }: ImageProps) => { 541 | const href = link || 542 | // linkが空文字のとき 543 | (/https:\/\/gyazo\.com\/[^\/]+\/thumb\/1000/.test(src) 544 | ? src.slice(0, -"/thumb/1000".length) 545 | : src); 546 | 547 | return ( 548 | 554 | 555 | 556 | ); 557 | }; 558 | 559 | type HashTagProps = { node: HashTagNode }; 560 | const HashTag = ({ node: { href } }: HashTagProps) => { 561 | const { project } = useContext(context); 562 | const emptyLink = useEmptyLink(project, href); 563 | const handleHover = useHover(project, href, "hashtag"); 564 | 565 | return ( 566 | 574 | #{href} 575 | 576 | ); 577 | }; 578 | type LinkProps = { node: LinkNode }; 579 | const Link = ( 580 | { node: { pathType, ...node } }: LinkProps, 581 | ) => { 582 | switch (pathType) { 583 | case "relative": 584 | case "root": 585 | return ; 586 | case "absolute": { 587 | const linkNode = parseAbsoluteLink({ pathType, ...node }); 588 | switch (linkNode.type) { 589 | case "youtube": 590 | return ; 591 | case "vimeo": 592 | return ; 593 | case "spotify": 594 | return ; 595 | case "anchor-fm": 596 | return ; 597 | case "audio": 598 | return