mirror of
https://github.com/4513echo/obsidian-embed-plus.git
synced 2026-07-22 06:55:40 +00:00
feat: Initial commit
This commit is contained in:
commit
dc933ba718
14 changed files with 4312 additions and 0 deletions
17
.github/workflows/ci.yml
vendored
Normal file
17
.github/workflows/ci.yml
vendored
Normal 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
29
.github/workflows/release.yml
vendored
Normal 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
24
.gitignore
vendored
Normal 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
1
.vite-hooks/pre-commit
Executable file
|
|
@ -0,0 +1 @@
|
|||
vp staged
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
8
manifest.json
Normal 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
20
package.json
Normal 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
3823
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
15
pnpm-workspace.yaml
Normal file
15
pnpm-workspace.yaml
Normal 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
6
public/styles.css
Normal 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
122
src/bluesky.ts
Normal 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
105
src/main.ts
Normal 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
26
tsconfig.json
Normal 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
95
vite.config.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue