feat: Initial commit

This commit is contained in:
4513ECHO 2026-04-10 01:16:04 +09:00
commit dc933ba718
14 changed files with 4312 additions and 0 deletions

17
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,17 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1
with:
cache: true
- run: vp check

29
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,29 @@
name: Release
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: voidzero-dev/setup-vp@v1
with:
cache: true
- run: vp build --mode prod
- name: Validate version
run: |
tag_version="${GITHUB_REF#refs/tags/}"
manifest_version="$(jq .version < dist/manifest.json)"
[[ "$tag_version" != "v$manifest_version" ]] && exit 1
- uses: softprops/action-gh-release@v2
with:
draft: true
working_directory: dist
files: |
main.js
manifest.json
styles.css

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
.vite-hooks/pre-commit Executable file
View file

@ -0,0 +1 @@
vp staged

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hibiki
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

8
manifest.json Normal file
View file

@ -0,0 +1,8 @@
{
"id": "embed-plus",
"name": "Embed Plus",
"description": "Embed more websites, with external-image syntax and live-preview support.",
"version": "0.1.0",
"minAppVersion": "1.8.0",
"author": "Hibiki"
}

20
package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "obsidian-embed-plus",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {},
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.0",
"obsidian": "^1.12.3"
},
"devDependencies": {
"eslint-plugin-obsidianmd": "^0.1.9",
"typescript": "~6.0.2",
"vite": "catalog:",
"vite-plus": "catalog:"
},
"packageManager": "pnpm@10.33.0"
}

3823
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

15
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,15 @@
catalog:
vite: npm:@voidzero-dev/vite-plus-core@latest
vitest: npm:@voidzero-dev/vite-plus-test@latest
vite-plus: ^0.1.16
overrides:
vite: "catalog:"
vitest: "catalog:"
peerDependencyRules:
allowAny:
- vite
- vitest
allowedVersions:
vite: "*"
vitest: "*"
tagVersionPrefix: ""

6
public/styles.css Normal file
View file

@ -0,0 +1,6 @@
img[src^="https://bsky.app/profile/"]:has(+ .external-embed) {
display: none !important;
}
.image-embed:has(img[src^="https://bsky.app/profile/"]):has(+ .external-embed) {
display: none !important;
}

122
src/bluesky.ts Normal file
View file

@ -0,0 +1,122 @@
import { EditorView, WidgetType } from "@codemirror/view";
import { requestUrl } from "obsidian";
const EMBED_URL = "https://embed.bsky.app";
const urlPattern = new URLPattern({
pathname: "/profile/:handle/post/:post",
baseURL: "https://bsky.app",
});
const didCache = new Map<string, string>();
async function resolveHandle(handle: string): Promise<string> {
if (didCache.has(handle)) {
return didCache.get(handle)!;
}
const url = new URL("/xrpc/com.atproto.identity.resolveHandle", "https://api.bsky.app");
url.searchParams.set("handle", handle);
const payload: unknown = await requestUrl(url.toString()).json;
if (
typeof payload === "object" &&
payload &&
"did" in payload &&
typeof payload.did === "string"
) {
didCache.set(handle, payload.did);
return payload.did;
}
throw new Error(`Failed to resolve bluesky handle: ${handle}`);
}
async function resolveSrc(url: string): Promise<string> {
const matched = urlPattern.exec(url);
if (!matched) {
throw new Error("Invalid Bluesky URL");
}
const { handle, post } = matched.pathname.groups;
if (!handle || !post) {
throw new Error("Invalid Bluesky URL");
}
const did = await resolveHandle(handle);
return `${EMBED_URL}/embed/${did}/app.bsky.feed.post/${post}`;
}
function createWidget(src: string, dom: HTMLElement): HTMLElement {
const id = Date.now().toString();
const searchParams = new URLSearchParams({
id,
colorMode: document.body.classList.contains("theme-dark") ? "dark" : "light",
});
const iframe = dom.createEl("iframe", {
cls: "external-embed",
attr: {
src: src + "?" + searchParams.toString(),
"data-bluesky-id": id,
},
});
iframe.setAttribute("style", `height: ${BlueskyWidget.getHeight(src)}px;`);
return iframe;
}
export async function createElement(url: string, dom: HTMLElement): Promise<HTMLElement> {
const src = await resolveSrc(url);
return createWidget(src, dom);
}
export class BlueskyWidget extends WidgetType {
static #widgetCache = new Map<string, BlueskyWidget>();
static #heightCache = new Map<string, number>();
static #id = 0;
#src: string;
static {
addEventListener("message", (event) => {
if (event.origin !== EMBED_URL) {
return;
}
const { id, height } = event.data;
if (!id || !height) {
return;
}
const embed = document.querySelector(`[data-bluesky-id="${id}"]`);
if (embed) {
this.setHeight(id, height);
embed.setAttribute("style", `height: ${height}px;`);
}
});
}
static async create(url: string): Promise<BlueskyWidget> {
const cacheHit = this.#widgetCache.get(url);
if (cacheHit) {
return cacheHit;
}
const src = await resolveSrc(url);
const widget = new this(src);
this.#widgetCache.set(url, widget);
return widget;
}
static setHeight(id: string, height: number): void {
this.#heightCache.set(id, height);
}
static getHeight(id: string): number {
// TODO: Cache height received from iframe and return it
return this.#heightCache.get(id) ?? 350;
}
static prepareId(): number {
this.#id++;
return this.#id;
}
constructor(src: string) {
super();
this.#src = src;
}
toDOM(view: EditorView): HTMLElement {
return createWidget(this.#src, view.dom);
}
}

105
src/main.ts Normal file
View file

@ -0,0 +1,105 @@
import { Plugin } from "obsidian";
import { type Range, StateEffect, StateField } from "@codemirror/state";
import { Decoration, type DecorationSet, EditorView, ViewPlugin } from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import { BlueskyWidget, createElement } from "./bluesky.ts";
export default class extends Plugin {
override onload() {
console.log("loading Embed Plus");
this.registerMarkdownPostProcessor(async (element, _context) => {
const embeds = element.findAll("img[src^='https://bsky.app/profile/']");
for (const embed of embeds) {
embed.replaceWith(await createElement(embed.getAttr("src")!, element));
}
});
this.registerEditorExtension([viewPlugin, decorationField]);
}
}
const stateEffect = StateEffect.define<{ decorations: DecorationSet }>({});
const decorationField = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(stateEffect)) {
return effect.value.decorations;
}
}
return value;
},
provide(field) {
return EditorView.decorations.from(field);
},
});
type MaybePromise<T> = T | Promise<T>;
function buildDecorations(view: EditorView): MaybePromise<Range<Decoration>>[] {
const urls: { from: number; to: number; url: string }[] = [];
const cursor = syntaxTree(view.state).cursor();
do {
if (cursor.name !== "formatting_formatting-image_image_image-marker") {
continue;
}
const { from } = cursor;
cursor.nextSibling(); // Move to "formatting_formatting-image_image_image-alt-text_link"
if (view.state.sliceDoc(cursor.from, cursor.to) !== "[]") {
do {
cursor.nextSibling();
} while (cursor.type.name !== "formatting_formatting-image_image_image-alt-text_link");
}
cursor.nextSibling(); // Move to "formatting_formatting-link-string_string_url"
cursor.nextSibling(); // Move to "string_url"
const url = view.state.sliceDoc(cursor.from, cursor.to);
if (!url.startsWith("https://bsky.app/profile/")) {
continue;
}
cursor.nextSibling(); // Move to "formatting_formatting-link-string_string_url"
urls.push({ from, to: cursor.to, url });
} while (cursor.next());
const widgets: MaybePromise<Range<Decoration>>[] = [];
for (const { to, url } of urls) {
widgets.push(
BlueskyWidget.create(url).then((widget) =>
Decoration.widget({
widget,
side: 1,
block: true,
}).range(to),
),
);
}
return widgets;
}
const viewPlugin = ViewPlugin.define(() => ({
update(update) {
if (!update.docChanged && !update.viewportChanged) {
return;
}
// TODO: use "async sometimes" pattern
for (const deco of buildDecorations(update.view)) {
if (deco instanceof Promise) {
void deco.then((decoration) =>
update.view.dispatch({
effects: stateEffect.of({
decorations: Decoration.set([decoration]),
}),
}),
);
} else {
update.view.dispatch({
effects: stateEffect.of({
decorations: Decoration.set([deco]),
}),
});
}
}
},
}));

26
tsconfig.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es2024",
"module": "esnext",
"lib": ["es2024", "dom"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}

95
vite.config.ts Normal file
View file

@ -0,0 +1,95 @@
import { defineConfig, type UserConfig, type Plugin } from "vite-plus";
import { join } from "node:path";
const obsidianmdRules = {
"obsidianmd/commands/no-command-in-command-id": "error",
"obsidianmd/commands/no-command-in-command-name": "error",
"obsidianmd/commands/no-default-hotkeys": "error",
"obsidianmd/commands/no-plugin-id-in-command-id": "error",
"obsidianmd/commands/no-plugin-name-in-command-name": "error",
"obsidianmd/settings-tab/no-manual-html-headings": "error",
"obsidianmd/settings-tab/no-problematic-settings-headings": "error",
"obsidianmd/vault/iterate": "error",
"obsidianmd/detach-leaves": "error",
"obsidianmd/hardcoded-config-path": "error",
"obsidianmd/no-forbidden-elements": "error",
// "obsidianmd/no-plugin-as-component": "error",
"obsidianmd/no-sample-code": "error",
"obsidianmd/no-tfile-tfolder-cast": "error",
// "obsidianmd/no-view-references-in-plugin": "error",
"obsidianmd/no-static-styles-assignment": "error",
"obsidianmd/object-assign": "error",
"obsidianmd/platform": "error",
// "obsidianmd/prefer-file-manager-trash-file": "warn",
"obsidianmd/prefer-abstract-input-suggest": "error",
"obsidianmd/regex-lookbehind": "error",
"obsidianmd/sample-names": "error",
"obsidianmd/validate-manifest": "error",
"obsidianmd/validate-license": ["error"],
"obsidianmd/ui/sentence-case": ["error", { enforceCamelCaseLower: true }],
} satisfies NonNullable<UserConfig["lint"]>["rules"];
function copyManifest(): Plugin {
let root: string;
let mode: string;
return {
name: "create-manifest",
apply: "build",
configResolved(config) {
root = config.root;
mode = config.mode;
},
async renderStart() {
const manifest = JSON.parse(
await this.fs.readFile(join(root, "manifest.json"), { encoding: "utf8" }),
);
this.emitFile({
type: "asset",
fileName: "manifest.json",
source: mode === "prod" ? JSON.stringify(manifest) : JSON.stringify(manifest, null, 2),
});
},
};
}
// XXX: Access mode from config
// See https://github.com/voidzero-dev/vite-plus/issues/930
function modeWorkaround(): Plugin {
return {
name: "mode-workaround",
config(_config, { mode }) {
return {
build: {
sourcemap: mode === "prod" ? false : "inline",
minify: mode === "prod",
},
};
},
};
}
export default defineConfig({
plugins: [copyManifest(), modeWorkaround()],
staged: {
"*": "vp check --fix",
},
fmt: {},
lint: {
options: { typeAware: true, typeCheck: true },
jsPlugins: ["eslint-plugin-obsidianmd"],
rules: { ...obsidianmdRules },
},
build: {
target: "es2024",
lib: {
entry: "src/main.ts",
formats: ["cjs"],
},
rolldownOptions: {
external: ["obsidian", /^@codemirror/],
output: {
entryFileNames: "main.js",
},
},
},
});