mirror of
https://github.com/wzmf399443/github-pages-share.git
synced 2026-07-22 08:27:19 +00:00
1456 lines
56 KiB
JavaScript
1456 lines
56 KiB
JavaScript
"use strict";
|
|
var __defProp = Object.defineProperty;
|
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
var __export = (target, all) => {
|
|
for (var name in all)
|
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
};
|
|
var __copyProps = (to, from, except, desc) => {
|
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
for (let key of __getOwnPropNames(from))
|
|
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
}
|
|
return to;
|
|
};
|
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
|
|
// src/main.ts
|
|
var main_exports = {};
|
|
__export(main_exports, {
|
|
default: () => GithubPagesSharePlugin
|
|
});
|
|
module.exports = __toCommonJS(main_exports);
|
|
var import_obsidian6 = require("obsidian");
|
|
|
|
// src/settings.ts
|
|
var import_obsidian2 = require("obsidian");
|
|
|
|
// src/github.ts
|
|
var import_obsidian = require("obsidian");
|
|
var API_BASE = "https://api.github.com";
|
|
var BOOTSTRAP_README = "# Published with GitHub Pages Share\n\nThis repository hosts notes published from Obsidian.\n";
|
|
var GithubApiError = class extends Error {
|
|
constructor(message, status) {
|
|
super(message);
|
|
this.name = "GithubApiError";
|
|
this.status = status;
|
|
}
|
|
};
|
|
function parseRepo(repo) {
|
|
const parts = repo.split("/").map((part) => part.trim());
|
|
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
if (!/^[A-Za-z0-9_.-]+$/.test(parts[0]) || !/^[A-Za-z0-9_.-]+$/.test(parts[1])) return null;
|
|
return { owner: parts[0], repo: parts[1] };
|
|
}
|
|
function encodeRepoPath(path) {
|
|
return path.split("/").filter((segment) => segment.length > 0).map((segment) => encodeURIComponent(segment)).join("/");
|
|
}
|
|
function textEncode(content) {
|
|
return new TextEncoder().encode(content).buffer;
|
|
}
|
|
var REF_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3];
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
|
}
|
|
function extractErrorMessage(status, json) {
|
|
if (json && typeof json === "object" && "message" in json) {
|
|
const message = json.message;
|
|
if (typeof message === "string" && message.length > 0) {
|
|
return message;
|
|
}
|
|
}
|
|
return `GitHub API request failed (${status}).`;
|
|
}
|
|
function isEmptyRepoResponse(status, json) {
|
|
return status === 409 && /empty/i.test(extractErrorMessage(status, json));
|
|
}
|
|
function isEmptyRepoError(error) {
|
|
return error instanceof GithubApiError && error.status === 409 && /empty/i.test(error.message);
|
|
}
|
|
var _GithubClient = class _GithubClient {
|
|
constructor(settings) {
|
|
const parsed = parseRepo(settings.repo);
|
|
if (!parsed) {
|
|
throw new GithubApiError("Repository must be set as owner/name in settings.", 0);
|
|
}
|
|
this.owner = parsed.owner;
|
|
this.repo = parsed.repo;
|
|
this.token = settings.token;
|
|
this.branch = settings.branch || "main";
|
|
}
|
|
headers(extra) {
|
|
return {
|
|
Authorization: `Bearer ${this.token}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
...extra
|
|
};
|
|
}
|
|
/** Returns the current blob sha for a repo path, or null if the file does not exist yet. */
|
|
async getFileSha(path) {
|
|
var _a;
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodeRepoPath(path)}?ref=${encodeURIComponent(this.branch)}`;
|
|
const response = await (0, import_obsidian.requestUrl)({ url, method: "GET", headers: this.headers(), throw: false });
|
|
if (response.status === 404 || isEmptyRepoResponse(response.status, response.json)) return null;
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
return (_a = data.sha) != null ? _a : null;
|
|
}
|
|
/**
|
|
* Fetches a repo file's decoded UTF-8 contents, or null if the file does not exist yet.
|
|
* Uses atob + TextDecoder instead of Node Buffer so this stays mobile-compatible.
|
|
*/
|
|
async getFileContent(path) {
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodeRepoPath(path)}?ref=${encodeURIComponent(this.branch)}`;
|
|
const response = await (0, import_obsidian.requestUrl)({ url, method: "GET", headers: this.headers(), throw: false });
|
|
if (response.status === 404 || isEmptyRepoResponse(response.status, response.json)) return null;
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
if (!data || typeof data.content !== "string" || data.encoding !== "base64") return null;
|
|
const binary = atob(data.content.replace(/\n/g, ""));
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return new TextDecoder("utf-8").decode(bytes);
|
|
}
|
|
/**
|
|
* Commits a batch of files. Two code paths:
|
|
*
|
|
* 1. **Empty repo** (zero commits): the Git Data API rejects every blob/tree/commit call with
|
|
* 409, so we first push a single README via the Contents API — one PUT that creates the
|
|
* initial commit and the branch. That makes the repo non-empty, and we then fall through to
|
|
* the standard flow for the actual files. (Bootstrapping every file with its own PUT instead
|
|
* raced GitHub's lagging ref replica — the 2nd+ PUTs saw a still-empty repo and 409'd — so we
|
|
* keep the Contents API to exactly one write.)
|
|
*
|
|
* 2. **Repo with commits**: standard Git Data API flow — create blobs, build a tree on top of
|
|
* the current branch tree (base_tree inheritance preserves untouched paths), create a commit,
|
|
* then move the branch ref. The only conflict point is the final ref update, which a
|
|
* concurrent commit turns into a 422 non-fast-forward that we retry by rebuilding on the new
|
|
* head.
|
|
*
|
|
* Blobs go through base64 so text and binary attachments share one mobile-safe path.
|
|
*
|
|
* Two kinds of transient conflict are retried with the same backoff:
|
|
* - **422 non-fast-forward** (a concurrent writer moved the ref): our remembered head is proven
|
|
* stale, so we stop trusting it and re-read the (by then caught-up) head next attempt.
|
|
* - **409 empty-repo** right after a bootstrap (the Git Data replica hasn't yet seen the README
|
|
* commit): the head we just created is authoritative, so we KEEP trusting it and simply wait
|
|
* for the replica — dropping to a null/orphan head here would fork a second root commit.
|
|
*/
|
|
async commitFiles(files, message) {
|
|
var _a;
|
|
if (files.length === 0) return;
|
|
const branchKey = `${this.owner}/${this.repo}/${this.branch}`;
|
|
let initialHead = await this.getBranchHeadSha();
|
|
if (initialHead.kind === "empty-repo") {
|
|
const readmeSha = await this.putFile("README.md", BOOTSTRAP_README, "Initialize repository");
|
|
_GithubClient.lastPushedHead.set(branchKey, readmeSha);
|
|
initialHead = { kind: "head", sha: readmeSha };
|
|
}
|
|
const MAX_ATTEMPTS = REF_RETRY_DELAYS_MS.length + 1;
|
|
let trustRemembered = true;
|
|
let blobShas = null;
|
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
if (!blobShas) blobShas = await Promise.all(files.map((file) => this.createBlob(file.content)));
|
|
const entries = files.map((file, i) => ({ path: file.path, sha: blobShas[i] }));
|
|
const branchHead = attempt === 1 ? initialHead : await this.getBranchHeadSha();
|
|
const apiHead = branchHead.kind === "head" ? branchHead.sha : null;
|
|
const remembered = _GithubClient.lastPushedHead.get(branchKey);
|
|
const headSha = trustRemembered && remembered && remembered !== apiHead ? remembered : (_a = apiHead != null ? apiHead : remembered) != null ? _a : null;
|
|
const baseTreeSha = headSha ? await this.getCommitTreeSha(headSha) : null;
|
|
const treeSha = await this.createTree(baseTreeSha, entries);
|
|
const commitSha = await this.createCommit(message, treeSha, headSha);
|
|
const res = await this.updateOrCreateBranchRef(commitSha, headSha !== null);
|
|
if (res.status < 400) {
|
|
_GithubClient.lastPushedHead.set(branchKey, commitSha);
|
|
return;
|
|
}
|
|
throw new GithubApiError(extractErrorMessage(res.status, res.json), res.status);
|
|
} catch (error) {
|
|
const nonFastForward = error instanceof GithubApiError && error.status === 422;
|
|
const emptyRepoLag = isEmptyRepoError(error);
|
|
if (!nonFastForward && !emptyRepoLag || attempt === MAX_ATTEMPTS) throw error;
|
|
if (nonFastForward) {
|
|
trustRemembered = false;
|
|
_GithubClient.lastPushedHead.delete(branchKey);
|
|
}
|
|
await sleep(REF_RETRY_DELAYS_MS[attempt - 1]);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Returns the branch head, distinguishing three cases:
|
|
*
|
|
* - `{ kind: "head", sha }` — the branch exists and points at sha.
|
|
* - `{ kind: "no-branch" }` — the repo has commits but this branch does not exist yet (404), or
|
|
* the ref response was missing an object sha (defensive).
|
|
* - `{ kind: "empty-repo" }` — the repo has zero commits (409 "Git Repository is empty."); Git
|
|
* Data API cannot create blobs/trees/commits against an empty repo, so callers must bootstrap
|
|
* via the Contents API instead.
|
|
*
|
|
* Other errors still throw.
|
|
*/
|
|
async getBranchHeadSha() {
|
|
var _a;
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/git/ref/heads/${encodeURIComponent(this.branch)}`;
|
|
const response = await (0, import_obsidian.requestUrl)({ url, method: "GET", headers: this.headers(), throw: false });
|
|
if (isEmptyRepoResponse(response.status, response.json)) return { kind: "empty-repo" };
|
|
if (response.status === 404) return { kind: "no-branch" };
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
const sha = (_a = data.object) == null ? void 0 : _a.sha;
|
|
return sha ? { kind: "head", sha } : { kind: "no-branch" };
|
|
}
|
|
/**
|
|
* Creates or updates a single file via the Contents API. This is the only endpoint that works
|
|
* against a repo with zero commits — the Git Data API (blobs, trees, commits, refs) returns 409
|
|
* "Git Repository is empty." for such repos, so the first-ever commit must go through this path.
|
|
* Used only by the empty-repo bootstrap in commitFiles; it does not carry a `sha` parameter and
|
|
* therefore performs no conflict retry (on a brand-new repo the path cannot already exist).
|
|
* Returns the new commit sha.
|
|
*/
|
|
async putFile(path, content, message) {
|
|
var _a;
|
|
const buffer = typeof content === "string" ? textEncode(content) : content;
|
|
const encoded = (0, import_obsidian.arrayBufferToBase64)(buffer);
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodeRepoPath(path)}`;
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: "PUT",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify({ message, content: encoded, branch: this.branch }),
|
|
throw: false
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
const sha = (_a = data.commit) == null ? void 0 : _a.sha;
|
|
if (!sha) throw new GithubApiError("GitHub put-file response missing commit sha.", response.status);
|
|
return sha;
|
|
}
|
|
/** Returns the tree sha for a commit. */
|
|
async getCommitTreeSha(commitSha) {
|
|
var _a;
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/git/commits/${commitSha}`;
|
|
const response = await (0, import_obsidian.requestUrl)({ url, method: "GET", headers: this.headers(), throw: false });
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
const treeSha = (_a = data.tree) == null ? void 0 : _a.sha;
|
|
if (!treeSha) throw new GithubApiError("GitHub commit response missing tree sha.", response.status);
|
|
return treeSha;
|
|
}
|
|
/** Creates a blob from text or binary content and returns its sha. */
|
|
async createBlob(content) {
|
|
const buffer = typeof content === "string" ? textEncode(content) : content;
|
|
const encoded = (0, import_obsidian.arrayBufferToBase64)(buffer);
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/git/blobs`;
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: "POST",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify({ content: encoded, encoding: "base64" }),
|
|
throw: false
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
if (!data.sha) throw new GithubApiError("GitHub blob response missing sha.", response.status);
|
|
return data.sha;
|
|
}
|
|
/** Builds a new tree from the given entries, inheriting untouched paths from base_tree. */
|
|
async createTree(baseTreeSha, entries) {
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/git/trees`;
|
|
const body = {
|
|
tree: entries.map((entry) => ({ path: entry.path, mode: "100644", type: "blob", sha: entry.sha }))
|
|
};
|
|
if (baseTreeSha) body.base_tree = baseTreeSha;
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: "POST",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify(body),
|
|
throw: false
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
if (!data.sha) throw new GithubApiError("GitHub tree response missing sha.", response.status);
|
|
return data.sha;
|
|
}
|
|
/** Creates a commit; parentSha is null only for the first commit in an empty repo. */
|
|
async createCommit(message, treeSha, parentSha) {
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/git/commits`;
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: "POST",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify({ message, tree: treeSha, parents: parentSha ? [parentSha] : [] }),
|
|
throw: false
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
if (!data.sha) throw new GithubApiError("GitHub commit response missing sha.", response.status);
|
|
return data.sha;
|
|
}
|
|
/**
|
|
* Points the branch at the new commit. Updates the existing ref with force:false so a concurrent
|
|
* commit surfaces as a 422 non-fast-forward instead of being clobbered, or creates the ref when
|
|
* the branch has no commits yet. Returns the raw response so the caller can retry on 422.
|
|
*/
|
|
async updateOrCreateBranchRef(commitSha, refExists) {
|
|
const url = refExists ? `${API_BASE}/repos/${this.owner}/${this.repo}/git/refs/heads/${encodeURIComponent(this.branch)}` : `${API_BASE}/repos/${this.owner}/${this.repo}/git/refs`;
|
|
const body = refExists ? { sha: commitSha, force: false } : { ref: `refs/heads/${this.branch}`, sha: commitSha };
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: refExists ? "PATCH" : "POST",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify(body),
|
|
throw: false
|
|
});
|
|
return { status: response.status, json: response.json };
|
|
}
|
|
/** Fetches repo metadata; also used by "Test connection". */
|
|
async getRepo() {
|
|
var _a, _b;
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}`;
|
|
const response = await (0, import_obsidian.requestUrl)({ url, method: "GET", headers: this.headers(), throw: false });
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
return { defaultBranch: (_a = data.default_branch) != null ? _a : this.branch, private: (_b = data.private) != null ? _b : false };
|
|
}
|
|
/** Enables GitHub Pages for the configured branch. A 409 means Pages is already enabled. */
|
|
async enablePages() {
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/pages`;
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: "POST",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify({ source: { branch: this.branch, path: "/" } }),
|
|
throw: false
|
|
});
|
|
if (response.status === 409) return;
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
}
|
|
/**
|
|
* Idempotently deletes a file from the repo. A 404 on the sha lookup (never existed) or on
|
|
* the delete call (already removed by a concurrent run) is treated as success so retries are
|
|
* safe; other 4xx/5xx responses throw GithubApiError.
|
|
*/
|
|
async deleteFile(path, message) {
|
|
const sha = await this.getFileSha(path);
|
|
if (!sha) return;
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/contents/${encodeRepoPath(path)}`;
|
|
const response = await (0, import_obsidian.requestUrl)({
|
|
url,
|
|
method: "DELETE",
|
|
headers: this.headers({ "Content-Type": "application/json" }),
|
|
body: JSON.stringify({ message, sha, branch: this.branch }),
|
|
throw: false
|
|
});
|
|
if (response.status === 404) return;
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
_GithubClient.lastPushedHead.delete(`${this.owner}/${this.repo}/${this.branch}`);
|
|
}
|
|
/** Returns the live Pages URL, or null if Pages is not enabled. */
|
|
async getPagesInfo() {
|
|
const url = `${API_BASE}/repos/${this.owner}/${this.repo}/pages`;
|
|
const response = await (0, import_obsidian.requestUrl)({ url, method: "GET", headers: this.headers(), throw: false });
|
|
if (response.status === 404) return null;
|
|
if (response.status >= 400) {
|
|
throw new GithubApiError(extractErrorMessage(response.status, response.json), response.status);
|
|
}
|
|
const data = response.json;
|
|
return data.html_url ? { url: data.html_url } : null;
|
|
}
|
|
};
|
|
/**
|
|
* Remembers the last commit sha this process pushed, keyed by `owner/repo/branch`. Right after
|
|
* our own commit GitHub's ref-read replica often still returns the previous head; trusting this
|
|
* value as the parent avoids a phantom non-fast-forward for sequential single-writer updates.
|
|
* Static so it survives the per-publish GithubClient instances.
|
|
*/
|
|
_GithubClient.lastPushedHead = /* @__PURE__ */ new Map();
|
|
var GithubClient = _GithubClient;
|
|
|
|
// src/settings.ts
|
|
var DEFAULT_SETTINGS = {
|
|
token: "",
|
|
repo: "",
|
|
branch: "main",
|
|
notesFolder: "notes",
|
|
assetsFolder: "assets",
|
|
baseUrl: "",
|
|
autoUpdate: true,
|
|
pagesConfirmed: false,
|
|
registry: {}
|
|
};
|
|
function deriveBaseUrl(settings) {
|
|
const parsed = parseRepo(settings.repo);
|
|
if (!parsed) return "";
|
|
return `https://${parsed.owner}.github.io/${parsed.repo}`;
|
|
}
|
|
function normalizeFolderName(value, fallback) {
|
|
const trimmed = value.trim().replace(/^\/+|\/+$/g, "");
|
|
if (!trimmed) return fallback;
|
|
const segments = trimmed.split("/");
|
|
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) return fallback;
|
|
return segments.join("/");
|
|
}
|
|
var GithubPagesShareSettingTab = class extends import_obsidian2.PluginSettingTab {
|
|
constructor(app, plugin) {
|
|
super(app, plugin);
|
|
this.pagesBaseUrlInput = null;
|
|
this.plugin = plugin;
|
|
}
|
|
display() {
|
|
const { containerEl } = this;
|
|
containerEl.empty();
|
|
new import_obsidian2.Setting(containerEl).setName("GitHub connection").setHeading();
|
|
new import_obsidian2.Setting(containerEl).setName("Personal access token").setDesc(
|
|
"Fine-grained token with contents read/write on the target repo. Stored as plain text in this vault's plugin data."
|
|
).addText((text) => {
|
|
text.inputEl.type = "password";
|
|
text.setPlaceholder("Paste token here").setValue(this.plugin.settings.token).onChange(async (value) => {
|
|
this.plugin.settings.token = value.trim();
|
|
await this.plugin.saveSettings();
|
|
});
|
|
});
|
|
new import_obsidian2.Setting(containerEl).setName("Repository").setDesc("GitHub repository as owner/name.").addText(
|
|
(text) => text.setPlaceholder("Owner/name").setValue(this.plugin.settings.repo).onChange(async (value) => {
|
|
var _a;
|
|
const newRepo = value.trim();
|
|
const prevRepo = this.plugin.settings.repo;
|
|
const changed = !!newRepo && newRepo !== prevRepo;
|
|
const oldDerived = deriveBaseUrl(this.plugin.settings);
|
|
this.plugin.settings.repo = newRepo;
|
|
if (changed) {
|
|
const newDerived = deriveBaseUrl(this.plugin.settings);
|
|
const current = this.plugin.settings.baseUrl;
|
|
const onDefault = !current || current === oldDerived;
|
|
if (onDefault) {
|
|
this.plugin.settings.baseUrl = newDerived;
|
|
(_a = this.pagesBaseUrlInput) == null ? void 0 : _a.setValue(newDerived);
|
|
}
|
|
this.plugin.settings.pagesConfirmed = false;
|
|
}
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian2.Setting(containerEl).setName("Branch").setDesc("Branch that GitHub Pages serves from.").addText(
|
|
(text) => text.setPlaceholder("Branch name").setValue(this.plugin.settings.branch).onChange(async (value) => {
|
|
this.plugin.settings.branch = value.trim() || "main";
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian2.Setting(containerEl).setName("Notes folder").setDesc("Folder in the repo where published notes are saved.").addText(
|
|
(text) => text.setPlaceholder("Folder name").setValue(this.plugin.settings.notesFolder).onChange(async (value) => {
|
|
this.plugin.settings.notesFolder = normalizeFolderName(value, "notes");
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian2.Setting(containerEl).setName("Assets folder").setDesc("Folder in the repo where uploaded images are saved.").addText(
|
|
(text) => text.setPlaceholder("Folder name").setValue(this.plugin.settings.assetsFolder).onChange(async (value) => {
|
|
this.plugin.settings.assetsFolder = normalizeFolderName(value, "assets");
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
const derivedUrl = deriveBaseUrl(this.plugin.settings) || "https://owner.github.io/repo";
|
|
new import_obsidian2.Setting(containerEl).setName("Pages base URL").setDesc(`Shareable link prefix. Leave blank to use ${derivedUrl}.`).addText((text) => {
|
|
this.pagesBaseUrlInput = text;
|
|
text.setPlaceholder(derivedUrl).setValue(this.plugin.settings.baseUrl).onChange(async (value) => {
|
|
this.plugin.settings.baseUrl = value.trim().replace(/\/+$/, "");
|
|
await this.plugin.saveSettings();
|
|
});
|
|
});
|
|
new import_obsidian2.Setting(containerEl).setName("Auto-update published notes").setDesc("Republish a note automatically a short while after you save changes to it.").addToggle(
|
|
(toggle) => toggle.setValue(this.plugin.settings.autoUpdate).onChange(async (value) => {
|
|
this.plugin.settings.autoUpdate = value;
|
|
await this.plugin.saveSettings();
|
|
})
|
|
);
|
|
new import_obsidian2.Setting(containerEl).setName("Test connection").setDesc("Verify the token and repository are valid.").addButton(
|
|
(button) => button.setButtonText("Test connection").onClick(() => {
|
|
void this.testConnection(button);
|
|
})
|
|
);
|
|
}
|
|
async testConnection(button) {
|
|
button.setDisabled(true);
|
|
try {
|
|
const client = new GithubClient(this.plugin.settings);
|
|
const repo = await client.getRepo();
|
|
new import_obsidian2.Notice(
|
|
repo.private ? "Connection works. Note: repo is private, so free GitHub Pages will not serve it." : "Connection works."
|
|
);
|
|
} catch (error) {
|
|
new import_obsidian2.Notice(error instanceof Error ? error.message : "Could not connect to GitHub.");
|
|
} finally {
|
|
button.setDisabled(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// src/publisher.ts
|
|
var import_obsidian5 = require("obsidian");
|
|
|
|
// src/modal.ts
|
|
var import_obsidian3 = require("obsidian");
|
|
var PublishResultModal = class extends import_obsidian3.Modal {
|
|
constructor(app, options) {
|
|
super(app);
|
|
this.statusEl = null;
|
|
this.options = options;
|
|
}
|
|
onOpen() {
|
|
const { contentEl, titleEl } = this;
|
|
titleEl.setText("Note published");
|
|
if (this.options.pagesNotEnabled) {
|
|
const warning = contentEl.createDiv({ cls: "gps-publish-result-warning" });
|
|
warning.createDiv({
|
|
text: "GitHub Pages is not enabled yet, so this link will not work."
|
|
});
|
|
const setupButton = warning.createEl("button", {
|
|
text: "Set up pages repo now",
|
|
cls: "gps-publish-result-setup"
|
|
});
|
|
setupButton.addEventListener("click", () => {
|
|
var _a, _b;
|
|
this.close();
|
|
(_b = (_a = this.options).onSetupRepo) == null ? void 0 : _b.call(_a);
|
|
});
|
|
}
|
|
contentEl.createDiv({ cls: "gps-publish-result-link", text: this.options.link });
|
|
this.statusEl = contentEl.createDiv({
|
|
cls: "gps-publish-result-status",
|
|
text: this.options.copiedToClipboard ? "Link copied to clipboard." : "Copying to the clipboard failed. Copy the link manually."
|
|
});
|
|
const buttons = contentEl.createDiv({ cls: "gps-publish-result-buttons" });
|
|
const copyButton = buttons.createEl("button", { text: "Copy link" });
|
|
copyButton.addEventListener("click", () => {
|
|
void (async () => {
|
|
var _a, _b;
|
|
try {
|
|
await navigator.clipboard.writeText(this.options.link);
|
|
(_a = this.statusEl) == null ? void 0 : _a.setText("Link copied to clipboard.");
|
|
} catch (e) {
|
|
(_b = this.statusEl) == null ? void 0 : _b.setText("Copying to the clipboard failed. Copy the link manually.");
|
|
}
|
|
})();
|
|
});
|
|
const openButton = buttons.createEl("button", { text: "Open in browser" });
|
|
openButton.addEventListener("click", () => {
|
|
window.open(this.options.link, "_blank");
|
|
});
|
|
contentEl.createDiv({
|
|
cls: "gps-publish-result-hint",
|
|
text: "GitHub Pages can take one to two minutes to build before the link works."
|
|
});
|
|
}
|
|
onClose() {
|
|
this.statusEl = null;
|
|
this.contentEl.empty();
|
|
}
|
|
};
|
|
var UnpublishConfirmModal = class extends import_obsidian3.Modal {
|
|
constructor(app, options) {
|
|
super(app);
|
|
this.options = options;
|
|
}
|
|
onOpen() {
|
|
const { contentEl, titleEl } = this;
|
|
titleEl.setText("Unpublish note?");
|
|
contentEl.createDiv({
|
|
text: `This will delete "${this.options.noteName}" from your GitHub Pages repo.`
|
|
});
|
|
if (this.options.attachmentCount > 0) {
|
|
const noun = this.options.attachmentCount === 1 ? "image" : "images";
|
|
contentEl.createDiv({
|
|
text: `It will also remove ${this.options.attachmentCount} uploaded ${noun} that no other published note references.`
|
|
});
|
|
}
|
|
const buttons = contentEl.createDiv({ cls: "gps-publish-result-buttons" });
|
|
const cancelButton = buttons.createEl("button", { text: "Cancel" });
|
|
cancelButton.addEventListener("click", () => {
|
|
this.close();
|
|
});
|
|
const unpublishButton = buttons.createEl("button", {
|
|
text: "Unpublish",
|
|
cls: "gps-danger-button"
|
|
});
|
|
unpublishButton.addEventListener("click", () => {
|
|
this.options.onConfirm();
|
|
this.close();
|
|
});
|
|
}
|
|
onClose() {
|
|
this.contentEl.empty();
|
|
}
|
|
};
|
|
|
|
// src/transform.ts
|
|
var import_obsidian4 = require("obsidian");
|
|
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set(["png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "avif"]);
|
|
var SLUG_CHAR = /[\p{L}\p{N}]/u;
|
|
var CALLOUT_HEAD = /^(\s*)>\s*\[!(\w+)\]([+-]?)(?:\s+(.*))?$/;
|
|
var FENCE_LINE = /^\s*```/;
|
|
var SAFE_TYPE = /^\w+$/;
|
|
function titleCase(s) {
|
|
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
function escapeHtmlAttribute(s) {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
function shortHash(input) {
|
|
let hash = 2166136261;
|
|
for (let i = 0; i < input.length; i++) {
|
|
hash ^= input.charCodeAt(i);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
}
|
|
function slugify(input, fallbackSeed) {
|
|
let out = "";
|
|
for (const ch of input.toLowerCase()) {
|
|
out += SLUG_CHAR.test(ch) ? ch : "-";
|
|
}
|
|
const collapsed = out.split("-").filter((part) => part.length > 0).join("-");
|
|
return collapsed || `note-${shortHash(fallbackSeed != null ? fallbackSeed : input)}`;
|
|
}
|
|
function slugifyFileName(name, fallbackSeed) {
|
|
const dotIndex = name.lastIndexOf(".");
|
|
if (dotIndex <= 0) return slugify(name, fallbackSeed);
|
|
const base = name.slice(0, dotIndex);
|
|
const ext = name.slice(dotIndex + 1).toLowerCase();
|
|
return `${slugify(base, fallbackSeed)}.${ext}`;
|
|
}
|
|
function transformCallouts(content) {
|
|
var _a, _b, _c, _d;
|
|
if (!content.includes("> [")) return content;
|
|
const lines = content.split("\n");
|
|
const out = [];
|
|
let inFence = false;
|
|
let i = 0;
|
|
while (i < lines.length) {
|
|
const line = lines[i];
|
|
if (FENCE_LINE.test(line)) {
|
|
inFence = !inFence;
|
|
out.push(line);
|
|
i++;
|
|
continue;
|
|
}
|
|
if (inFence) {
|
|
out.push(line);
|
|
i++;
|
|
continue;
|
|
}
|
|
const match = CALLOUT_HEAD.exec(line);
|
|
if (!match) {
|
|
out.push(line);
|
|
i++;
|
|
continue;
|
|
}
|
|
const indent = (_a = match[1]) != null ? _a : "";
|
|
const rawType = ((_b = match[2]) != null ? _b : "note").toLowerCase();
|
|
const fold = (_c = match[3]) != null ? _c : "";
|
|
const title = ((_d = match[4]) != null ? _d : "").trim();
|
|
const safeType = SAFE_TYPE.test(rawType) ? rawType : "note";
|
|
const prefix = `${indent}>`;
|
|
let j = i + 1;
|
|
while (j < lines.length) {
|
|
const bl = lines[j];
|
|
if (bl.trim() === "") break;
|
|
if (bl === prefix || bl.startsWith(`${prefix} `) || bl.startsWith(prefix)) {
|
|
j++;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
const bodyLines = [];
|
|
for (let k = i + 1; k < j; k++) {
|
|
const bl = lines[k];
|
|
if (bl === prefix) {
|
|
bodyLines.push("");
|
|
} else if (bl.startsWith(`${prefix} `)) {
|
|
bodyLines.push(bl.slice(prefix.length + 1));
|
|
} else if (bl.startsWith(prefix)) {
|
|
bodyLines.push(bl.slice(prefix.length));
|
|
}
|
|
}
|
|
const inner = transformCallouts(bodyLines.join("\n"));
|
|
if (out.length > 0 && out[out.length - 1] !== "") {
|
|
out.push("");
|
|
}
|
|
if (fold === "+" || fold === "-") {
|
|
const openAttr = fold === "+" ? " open" : "";
|
|
const summary = title || titleCase(safeType);
|
|
out.push(`${indent}<details class="callout callout-${safeType}" markdown="1"${openAttr}>`);
|
|
out.push(`${indent}<summary>${escapeHtmlAttribute(summary)}</summary>`);
|
|
if (inner.trim().length > 0) {
|
|
out.push("");
|
|
for (const innerLine of inner.split("\n")) {
|
|
out.push(innerLine.length > 0 ? indent + innerLine : "");
|
|
}
|
|
out.push("");
|
|
}
|
|
out.push(`${indent}</details>`);
|
|
} else {
|
|
out.push(`${indent}<blockquote class="callout callout-${safeType}" markdown="1">`);
|
|
if (title) {
|
|
out.push(`${indent}<strong class="callout-title">${escapeHtmlAttribute(title)}</strong>`);
|
|
}
|
|
if (inner.trim().length > 0) {
|
|
out.push("");
|
|
for (const innerLine of inner.split("\n")) {
|
|
out.push(innerLine.length > 0 ? indent + innerLine : "");
|
|
}
|
|
out.push("");
|
|
}
|
|
out.push(`${indent}</blockquote>`);
|
|
}
|
|
i = j;
|
|
}
|
|
return out.join("\n");
|
|
}
|
|
function escapeYamlString(value) {
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
function isExternalLink(link) {
|
|
return link.includes("://") || link.startsWith("mailto:");
|
|
}
|
|
function findFrontmatter(content) {
|
|
const none = { exists: false, insertOffset: 0, body: "" };
|
|
if (!content.startsWith("---")) return none;
|
|
const firstLineEnd = content.indexOf("\n");
|
|
if (firstLineEnd === -1 || content.slice(0, firstLineEnd).trim() !== "---") return none;
|
|
let cursor = firstLineEnd + 1;
|
|
while (cursor <= content.length) {
|
|
const nextNewline = content.indexOf("\n", cursor);
|
|
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
|
const line = content.slice(cursor, lineEnd).trim();
|
|
if (line === "---") {
|
|
return { exists: true, insertOffset: firstLineEnd + 1, body: content.slice(firstLineEnd + 1, cursor) };
|
|
}
|
|
if (nextNewline === -1) break;
|
|
cursor = nextNewline + 1;
|
|
}
|
|
return none;
|
|
}
|
|
function ensureFrontmatter(content, title) {
|
|
const frontmatter = findFrontmatter(content);
|
|
if (!frontmatter.exists) {
|
|
return `---
|
|
title: "${escapeYamlString(title)}"
|
|
---
|
|
|
|
${content}`;
|
|
}
|
|
const hasTitle = frontmatter.body.split("\n").some((line) => line.trimStart().startsWith("title:"));
|
|
if (hasTitle) return content;
|
|
return `${content.slice(0, frontmatter.insertOffset)}title: "${escapeYamlString(title)}"
|
|
${content.slice(frontmatter.insertOffset)}`;
|
|
}
|
|
function linkReplacementText(target, display, registry) {
|
|
const record = registry[target.path];
|
|
if (record) {
|
|
return `[${display}](${record.slug}.html)`;
|
|
}
|
|
return display;
|
|
}
|
|
function transformNote(app, file, rawContent, settings) {
|
|
var _a, _b, _c, _d, _e, _f;
|
|
const cache = app.metadataCache.getFileCache(file);
|
|
const replacements = [];
|
|
const attachmentsByPath = /* @__PURE__ */ new Map();
|
|
const notesDepth = settings.notesFolder.split("/").filter((segment) => segment.length > 0).length;
|
|
const upToRoot = "../".repeat(Math.max(notesDepth, 1));
|
|
for (const embed of (_a = cache == null ? void 0 : cache.embeds) != null ? _a : []) {
|
|
if (isExternalLink(embed.link)) continue;
|
|
const target = app.metadataCache.getFirstLinkpathDest(embed.link, file.path);
|
|
if (!target) {
|
|
replacements.push({
|
|
start: embed.position.start.offset,
|
|
end: embed.position.end.offset,
|
|
text: (_b = embed.displayText) != null ? _b : embed.link
|
|
});
|
|
continue;
|
|
}
|
|
if (IMAGE_EXTENSIONS.has(target.extension.toLowerCase())) {
|
|
const repoFileName = slugifyFileName(target.name, target.path);
|
|
const repoPath = (0, import_obsidian4.normalizePath)(`${settings.assetsFolder}/${repoFileName}`);
|
|
attachmentsByPath.set(target.path, { file: target, repoPath });
|
|
const alt = (_c = embed.displayText) != null ? _c : target.basename;
|
|
replacements.push({
|
|
start: embed.position.start.offset,
|
|
end: embed.position.end.offset,
|
|
text: ``
|
|
});
|
|
} else {
|
|
replacements.push({
|
|
start: embed.position.start.offset,
|
|
end: embed.position.end.offset,
|
|
text: linkReplacementText(target, (_d = embed.displayText) != null ? _d : embed.link, settings.registry)
|
|
});
|
|
}
|
|
}
|
|
for (const link of (_e = cache == null ? void 0 : cache.links) != null ? _e : []) {
|
|
if (isExternalLink(link.link)) continue;
|
|
const display = (_f = link.displayText) != null ? _f : link.link;
|
|
const target = app.metadataCache.getFirstLinkpathDest(link.link, file.path);
|
|
if (!target) {
|
|
replacements.push({ start: link.position.start.offset, end: link.position.end.offset, text: display });
|
|
continue;
|
|
}
|
|
replacements.push({
|
|
start: link.position.start.offset,
|
|
end: link.position.end.offset,
|
|
text: linkReplacementText(target, display, settings.registry)
|
|
});
|
|
}
|
|
replacements.sort((a, b) => b.start - a.start);
|
|
let body = rawContent;
|
|
for (const replacement of replacements) {
|
|
body = body.slice(0, replacement.start) + replacement.text + body.slice(replacement.end);
|
|
}
|
|
body = transformCallouts(body);
|
|
const content = ensureFrontmatter(body, file.basename);
|
|
return { content, attachments: Array.from(attachmentsByPath.values()) };
|
|
}
|
|
|
|
// src/callouts.css.ts
|
|
var CALLOUTS_CSS = `.callout {
|
|
padding: 12px 16px;
|
|
margin: 16px 0;
|
|
border-left: 4px solid #448aff;
|
|
border-radius: 4px;
|
|
background: #e3f2fd;
|
|
color: inherit;
|
|
overflow: hidden;
|
|
}
|
|
.callout > *:first-child {
|
|
margin-top: 0;
|
|
}
|
|
.callout > *:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.callout-title {
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.callout-icon {
|
|
display: inline-block;
|
|
width: 1.25em;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.callout details > summary,
|
|
.callout > summary {
|
|
cursor: pointer;
|
|
}
|
|
.callout-note {
|
|
border-color: #448aff;
|
|
background: #e3f2fd;
|
|
}
|
|
.callout-abstract,
|
|
.callout-summary,
|
|
.callout-tldr {
|
|
border-color: #00b8d4;
|
|
background: #e0f7fa;
|
|
}
|
|
.callout-info {
|
|
border-color: #00b8d4;
|
|
background: #e0f7fa;
|
|
}
|
|
.callout-todo {
|
|
border-color: #00b8d4;
|
|
background: #e0f7fa;
|
|
}
|
|
.callout-tip,
|
|
.callout-hint,
|
|
.callout-important {
|
|
border-color: #00bfa5;
|
|
background: #e0f2f1;
|
|
}
|
|
.callout-success,
|
|
.callout-check,
|
|
.callout-done {
|
|
border-color: #00c853;
|
|
background: #e8f5e9;
|
|
}
|
|
.callout-question,
|
|
.callout-help,
|
|
.callout-faq {
|
|
border-color: #64dd17;
|
|
background: #f1f8e9;
|
|
}
|
|
.callout-warning,
|
|
.callout-caution,
|
|
.callout-attention {
|
|
border-color: #ff9100;
|
|
background: #fff3e0;
|
|
}
|
|
.callout-failure,
|
|
.callout-fail,
|
|
.callout-missing {
|
|
border-color: #ff5252;
|
|
background: #ffebee;
|
|
}
|
|
.callout-danger,
|
|
.callout-error {
|
|
border-color: #ff1744;
|
|
background: #ffebee;
|
|
}
|
|
.callout-bug {
|
|
border-color: #f50057;
|
|
background: #ffebee;
|
|
}
|
|
.callout-example {
|
|
border-color: #7c4dff;
|
|
background: #ede7f6;
|
|
}
|
|
.callout-quote,
|
|
.callout-cite {
|
|
border-color: #9e9e9e;
|
|
background: #f5f5f5;
|
|
}
|
|
`;
|
|
|
|
// src/publisher.ts
|
|
var HEAD_CUSTOM_MARKER = "<!-- gps-callouts-v1 -->";
|
|
var HEAD_CUSTOM_LINK = `<link rel="stylesheet" href="{{ '/assets/callouts.css' | relative_url }}">`;
|
|
var HEAD_CUSTOM_BASE = `<script type="module">
|
|
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11.16.0/dist/mermaid.esm.min.mjs";
|
|
document.querySelectorAll("pre code.language-mermaid, pre > code.language-mermaid").forEach((code) => {
|
|
const pre = code.closest("pre");
|
|
const container = document.createElement("pre");
|
|
container.className = "mermaid";
|
|
container.textContent = code.textContent;
|
|
pre.replaceWith(container);
|
|
});
|
|
mermaid.initialize({ startOnLoad: true });
|
|
<\/script>
|
|
${HEAD_CUSTOM_LINK}
|
|
${HEAD_CUSTOM_MARKER}
|
|
`;
|
|
var LONG_NOTICE_MS = 15e3;
|
|
function describeError(error) {
|
|
if (error instanceof GithubApiError) {
|
|
if (error.status === 401) return "GitHub rejected the token. Check the personal access token in settings.";
|
|
if (error.status === 403) return "GitHub denied the request. Check the token's repo permissions.";
|
|
if (error.status === 404) return "Repository not found. Check the owner/name and token access in settings.";
|
|
if (error.status === 409) return "GitHub reported a conflict while saving. Please try again.";
|
|
if (error.status === 422 && /fast.forward/i.test(error.message))
|
|
return "Another publish updated the repo at the same time. Please try again.";
|
|
return `GitHub error: ${error.message}`;
|
|
}
|
|
if (error instanceof Error) return error.message;
|
|
return "Unknown error while talking to GitHub.";
|
|
}
|
|
function buildShareLink(settings, slug) {
|
|
const base = (settings.baseUrl || deriveBaseUrl(settings)).replace(/\/+$/, "");
|
|
return `${base}/${settings.notesFolder}/${slug}.html`;
|
|
}
|
|
async function buildJekyllConfigFile(client, settings) {
|
|
var _a;
|
|
const existingSha = await client.getFileSha("_config.yml");
|
|
if (existingSha) return null;
|
|
const repoName = (_a = settings.repo.split("/")[1]) != null ? _a : settings.repo;
|
|
const yaml = `theme: jekyll-theme-primer
|
|
title: "${escapeYamlString(repoName)}"
|
|
`;
|
|
return { path: "_config.yml", content: yaml };
|
|
}
|
|
async function buildCalloutStylesFile(client) {
|
|
const existingSha = await client.getFileSha("assets/callouts.css");
|
|
if (existingSha) return null;
|
|
return { path: "assets/callouts.css", content: CALLOUTS_CSS };
|
|
}
|
|
async function buildHeadCustomFile(client) {
|
|
const existing = await client.getFileContent("_includes/head-custom.html");
|
|
if (existing === null) {
|
|
return { path: "_includes/head-custom.html", content: HEAD_CUSTOM_BASE };
|
|
}
|
|
if (existing.includes(HEAD_CUSTOM_MARKER)) return null;
|
|
const updated = existing.endsWith("\n") ? `${existing}${HEAD_CUSTOM_LINK}
|
|
${HEAD_CUSTOM_MARKER}
|
|
` : `${existing}
|
|
${HEAD_CUSTOM_LINK}
|
|
${HEAD_CUSTOM_MARKER}
|
|
`;
|
|
return { path: "_includes/head-custom.html", content: updated };
|
|
}
|
|
async function buildIndexPageFile(client, settings) {
|
|
var _a;
|
|
const existingSha = await client.getFileSha("index.md");
|
|
if (existingSha) return null;
|
|
const repoName = (_a = settings.repo.split("/")[1]) != null ? _a : settings.repo;
|
|
const markdown = `---
|
|
title: "${escapeYamlString(repoName)}"
|
|
---
|
|
|
|
Notes published from Obsidian appear here. Maintain this index manually as you publish more notes.
|
|
`;
|
|
return { path: "index.md", content: markdown };
|
|
}
|
|
function hasConnectionSettings(settings) {
|
|
return settings.token.length > 0 && settings.repo.length > 0;
|
|
}
|
|
var inFlightPublishes = /* @__PURE__ */ new Set();
|
|
async function publishNote(plugin, file, options = {}) {
|
|
var _a, _b;
|
|
const quiet = (_a = options.quiet) != null ? _a : false;
|
|
if (file.extension !== "md") {
|
|
if (!quiet) new import_obsidian5.Notice("Only Markdown notes can be published.");
|
|
return false;
|
|
}
|
|
const settings = plugin.settings;
|
|
if (!hasConnectionSettings(settings)) {
|
|
if (!quiet) new import_obsidian5.Notice("Set a GitHub token and repository in the plugin settings first.");
|
|
return false;
|
|
}
|
|
if (inFlightPublishes.has(file.path)) {
|
|
if (!quiet) new import_obsidian5.Notice("This note is already being published.");
|
|
return false;
|
|
}
|
|
let client;
|
|
try {
|
|
client = new GithubClient(settings);
|
|
} catch (error) {
|
|
if (!quiet) new import_obsidian5.Notice(describeError(error));
|
|
return false;
|
|
}
|
|
inFlightPublishes.add(file.path);
|
|
const progress = quiet ? null : new import_obsidian5.Notice(`Publishing ${file.basename}...`, 0);
|
|
try {
|
|
const rawContent = await plugin.app.vault.cachedRead(file);
|
|
const existing = settings.registry[file.path];
|
|
const slug = (_b = existing == null ? void 0 : existing.slug) != null ? _b : slugify(file.basename, file.path);
|
|
const repoPath = (0, import_obsidian5.normalizePath)(`${settings.notesFolder}/${slug}.md`);
|
|
const { content, attachments } = transformNote(plugin.app, file, rawContent, settings);
|
|
const scaffolding = (await Promise.all([
|
|
buildJekyllConfigFile(client, settings),
|
|
buildHeadCustomFile(client),
|
|
buildCalloutStylesFile(client)
|
|
])).filter((repoFile) => repoFile !== null);
|
|
const files = [...scaffolding];
|
|
for (let i = 0; i < attachments.length; i++) {
|
|
progress == null ? void 0 : progress.setMessage(`Reading image ${i + 1} of ${attachments.length}...`);
|
|
const attachment = attachments[i];
|
|
const data = await plugin.app.vault.readBinary(attachment.file);
|
|
files.push({ path: attachment.repoPath, content: data });
|
|
}
|
|
files.push({ path: repoPath, content });
|
|
const message = attachments.length > 0 ? `Publish note ${file.basename} (+${attachments.length} asset${attachments.length > 1 ? "s" : ""})` : `Publish note ${file.basename}`;
|
|
progress == null ? void 0 : progress.setMessage("Committing to GitHub...");
|
|
await client.commitFiles(files, message);
|
|
const record = {
|
|
repoPath,
|
|
slug,
|
|
attachments: attachments.map((a) => a.repoPath)
|
|
};
|
|
settings.registry[file.path] = record;
|
|
await plugin.saveSettings();
|
|
if (!quiet) {
|
|
let pagesNotEnabled = false;
|
|
if (!settings.pagesConfirmed) {
|
|
progress == null ? void 0 : progress.setMessage("Checking pages status...");
|
|
try {
|
|
const pagesInfo = await client.getPagesInfo();
|
|
if (pagesInfo) {
|
|
settings.pagesConfirmed = true;
|
|
settings.baseUrl = pagesInfo.url.replace(/\/+$/, "");
|
|
await plugin.saveSettings();
|
|
} else {
|
|
pagesNotEnabled = true;
|
|
}
|
|
} catch (e) {
|
|
}
|
|
}
|
|
const link = buildShareLink(settings, slug);
|
|
let copiedToClipboard = false;
|
|
try {
|
|
await navigator.clipboard.writeText(link);
|
|
copiedToClipboard = true;
|
|
} catch (e) {
|
|
}
|
|
progress == null ? void 0 : progress.hide();
|
|
new PublishResultModal(plugin.app, {
|
|
link,
|
|
copiedToClipboard,
|
|
pagesNotEnabled,
|
|
onSetupRepo: () => {
|
|
void setupRepo(plugin);
|
|
}
|
|
}).open();
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
progress == null ? void 0 : progress.hide();
|
|
new import_obsidian5.Notice(quiet ? `Auto-update failed for ${file.basename}: ${describeError(error)}` : describeError(error));
|
|
return false;
|
|
} finally {
|
|
progress == null ? void 0 : progress.hide();
|
|
inFlightPublishes.delete(file.path);
|
|
}
|
|
}
|
|
async function copyPublishedLink(plugin, file) {
|
|
const record = plugin.settings.registry[file.path];
|
|
if (!record) {
|
|
new import_obsidian5.Notice("This note has not been published yet.");
|
|
return;
|
|
}
|
|
const link = buildShareLink(plugin.settings, record.slug);
|
|
await navigator.clipboard.writeText(link);
|
|
new import_obsidian5.Notice(`Link copied to clipboard:
|
|
${link}`);
|
|
}
|
|
function collectReferencedAttachments(registry, excludePath) {
|
|
var _a;
|
|
const referenced = /* @__PURE__ */ new Set();
|
|
for (const [path, record] of Object.entries(registry)) {
|
|
if (path === excludePath) continue;
|
|
for (const attachment of (_a = record.attachments) != null ? _a : []) {
|
|
referenced.add(attachment);
|
|
}
|
|
}
|
|
return referenced;
|
|
}
|
|
async function unpublishNote(plugin, file) {
|
|
var _a;
|
|
const settings = plugin.settings;
|
|
const record = settings.registry[file.path];
|
|
if (!record) {
|
|
new import_obsidian5.Notice("This note has not been published.");
|
|
return;
|
|
}
|
|
const referenced = collectReferencedAttachments(settings.registry, file.path);
|
|
const ownAttachments = (_a = record.attachments) != null ? _a : [];
|
|
const orphans = ownAttachments.filter((path) => !referenced.has(path));
|
|
const confirmed = await new Promise((resolve) => {
|
|
let resolved = false;
|
|
const modal = new UnpublishConfirmModal(plugin.app, {
|
|
noteName: file.basename,
|
|
attachmentCount: orphans.length,
|
|
onConfirm: () => {
|
|
resolved = true;
|
|
resolve(true);
|
|
modal.close();
|
|
}
|
|
});
|
|
const baseOnClose = modal.onClose.bind(modal);
|
|
modal.onClose = () => {
|
|
baseOnClose();
|
|
if (!resolved) resolve(false);
|
|
};
|
|
modal.open();
|
|
});
|
|
if (!confirmed) return;
|
|
let client;
|
|
try {
|
|
client = new GithubClient(settings);
|
|
} catch (error) {
|
|
new import_obsidian5.Notice(describeError(error));
|
|
return;
|
|
}
|
|
const progress = new import_obsidian5.Notice(`Unpublishing ${file.basename}...`, 0);
|
|
const attachmentFailures = [];
|
|
let orphansRemoved = 0;
|
|
try {
|
|
for (const repoPath of orphans) {
|
|
try {
|
|
await client.deleteFile(repoPath, `Remove orphan asset ${repoPath}`);
|
|
orphansRemoved++;
|
|
} catch (e) {
|
|
attachmentFailures.push(repoPath);
|
|
}
|
|
}
|
|
await client.deleteFile(record.repoPath, `Unpublish note ${file.basename}`);
|
|
delete settings.registry[file.path];
|
|
await plugin.saveSettings();
|
|
plugin.clearAutoUpdateTimer(file.path);
|
|
progress.hide();
|
|
let message = `Unpublished ${file.basename}.`;
|
|
if (orphansRemoved > 0) {
|
|
message += ` ${orphansRemoved} unused attachments removed.`;
|
|
}
|
|
new import_obsidian5.Notice(message);
|
|
if (attachmentFailures.length > 0) {
|
|
new import_obsidian5.Notice(
|
|
`Could not remove ${attachmentFailures.length} attachment(s); the note was unpublished anyway. Affected paths: ${attachmentFailures.join(", ")}.`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
progress.hide();
|
|
new import_obsidian5.Notice(describeError(error));
|
|
} finally {
|
|
progress.hide();
|
|
}
|
|
}
|
|
async function setupRepo(plugin) {
|
|
const settings = plugin.settings;
|
|
if (!hasConnectionSettings(settings)) {
|
|
new import_obsidian5.Notice("Set a GitHub token and repository in the plugin settings first.");
|
|
return;
|
|
}
|
|
let client;
|
|
try {
|
|
client = new GithubClient(settings);
|
|
} catch (error) {
|
|
new import_obsidian5.Notice(describeError(error));
|
|
return;
|
|
}
|
|
const progress = new import_obsidian5.Notice("Setting up pages repo...", 0);
|
|
try {
|
|
progress.setMessage("Checking repository...");
|
|
const repoInfo = await client.getRepo();
|
|
if (repoInfo.private) {
|
|
new import_obsidian5.Notice("Repository is private. Free GitHub Pages requires a public repository.");
|
|
}
|
|
progress.setMessage("Preparing repo files...");
|
|
const scaffolding = (await Promise.all([
|
|
buildJekyllConfigFile(client, settings),
|
|
buildIndexPageFile(client, settings),
|
|
buildHeadCustomFile(client),
|
|
buildCalloutStylesFile(client)
|
|
])).filter((repoFile) => repoFile !== null);
|
|
if (scaffolding.length > 0) {
|
|
progress.setMessage("Committing repo files...");
|
|
await client.commitFiles(scaffolding, "Set up GitHub Pages repo files");
|
|
}
|
|
progress.setMessage("Enabling pages...");
|
|
try {
|
|
await client.enablePages();
|
|
} catch (error) {
|
|
if (error instanceof GithubApiError && error.status === 403) {
|
|
const alreadyLive = await client.getPagesInfo().catch(() => null);
|
|
if (!alreadyLive) {
|
|
progress.hide();
|
|
new import_obsidian5.Notice(
|
|
"To complete GitHub Pages setup, open the repo's Settings tab, click Pages in the left sidebar, and enable Pages. Once Pages is enabled, you can start publishing.",
|
|
0
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
settings.pagesConfirmed = true;
|
|
await plugin.saveSettings();
|
|
progress.setMessage("Checking pages status...");
|
|
const pagesInfo = await client.getPagesInfo();
|
|
progress.hide();
|
|
if (pagesInfo) {
|
|
settings.baseUrl = pagesInfo.url.replace(/\/+$/, "");
|
|
await plugin.saveSettings();
|
|
new import_obsidian5.Notice(`Pages is set up: ${pagesInfo.url}`, LONG_NOTICE_MS);
|
|
} else {
|
|
new import_obsidian5.Notice("Repo files are ready, but pages status could not be confirmed yet.", LONG_NOTICE_MS);
|
|
}
|
|
} catch (error) {
|
|
progress.hide();
|
|
new import_obsidian5.Notice(describeError(error), LONG_NOTICE_MS);
|
|
} finally {
|
|
progress.hide();
|
|
}
|
|
}
|
|
async function publishFolder(plugin, folder) {
|
|
if (!hasConnectionSettings(plugin.settings)) {
|
|
new import_obsidian5.Notice("Set a GitHub token and repository in the plugin settings first.");
|
|
return;
|
|
}
|
|
const prefix = folder.path === "/" ? "" : `${folder.path}/`;
|
|
const files = plugin.app.vault.getMarkdownFiles().filter((file) => file.path.startsWith(prefix)).sort((a, b) => a.path.localeCompare(b.path));
|
|
if (files.length === 0) {
|
|
new import_obsidian5.Notice("No Markdown notes in this folder.");
|
|
return;
|
|
}
|
|
const progress = new import_obsidian5.Notice(`Publishing 0 / ${files.length} notes...`, 0);
|
|
const failures = [];
|
|
let successCount = 0;
|
|
try {
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
progress.setMessage(`Publishing ${i + 1} / ${files.length}: ${file.basename}...`);
|
|
try {
|
|
const ok = await publishNote(plugin, file, { quiet: true });
|
|
if (ok) {
|
|
successCount++;
|
|
} else {
|
|
failures.push(file.basename);
|
|
}
|
|
} catch (e) {
|
|
failures.push(file.basename);
|
|
}
|
|
}
|
|
} finally {
|
|
progress.hide();
|
|
}
|
|
if (failures.length === 0) {
|
|
new import_obsidian5.Notice(`Published ${files.length} notes to GitHub Pages.`);
|
|
} else if (successCount === 0) {
|
|
new import_obsidian5.Notice(`Failed to publish ${files.length} notes.`);
|
|
} else {
|
|
const sample = failures.slice(0, 3).join(", ");
|
|
new import_obsidian5.Notice(`Published ${successCount} of ${files.length} notes. ${failures.length} failed: ${sample}.`);
|
|
}
|
|
}
|
|
|
|
// src/main.ts
|
|
var AUTO_UPDATE_DEBOUNCE_MS = 15e3;
|
|
var GithubPagesSharePlugin = class extends import_obsidian6.Plugin {
|
|
constructor() {
|
|
super(...arguments);
|
|
this.settings = DEFAULT_SETTINGS;
|
|
this.autoUpdateTimers = /* @__PURE__ */ new Map();
|
|
}
|
|
async onload() {
|
|
await this.loadSettings();
|
|
this.addSettingTab(new GithubPagesShareSettingTab(this.app, this));
|
|
this.addCommand({
|
|
id: "publish-note",
|
|
name: "Publish current note",
|
|
checkCallback: (checking) => {
|
|
const file = this.app.workspace.getActiveFile();
|
|
if (!(file instanceof import_obsidian6.TFile) || file.extension !== "md") return false;
|
|
if (checking) return true;
|
|
void publishNote(this, file);
|
|
return true;
|
|
}
|
|
});
|
|
this.addCommand({
|
|
id: "copy-link",
|
|
name: "Copy published link",
|
|
checkCallback: (checking) => {
|
|
const file = this.app.workspace.getActiveFile();
|
|
if (!(file instanceof import_obsidian6.TFile) || file.extension !== "md") return false;
|
|
if (checking) return true;
|
|
void copyPublishedLink(this, file);
|
|
return true;
|
|
}
|
|
});
|
|
this.addCommand({
|
|
id: "setup-repo",
|
|
name: "Set up pages repo",
|
|
callback: () => {
|
|
void setupRepo(this);
|
|
}
|
|
});
|
|
this.addCommand({
|
|
id: "unpublish-note",
|
|
name: "Unpublish current note",
|
|
checkCallback: (checking) => {
|
|
const file = this.app.workspace.getActiveFile();
|
|
if (!(file instanceof import_obsidian6.TFile) || file.extension !== "md") return false;
|
|
if (!this.settings.registry[file.path]) return false;
|
|
if (checking) return true;
|
|
void unpublishNote(this, file);
|
|
return true;
|
|
}
|
|
});
|
|
this.addRibbonIcon("upload-cloud", "Publish current note", () => {
|
|
const file = this.app.workspace.getActiveFile();
|
|
if (file instanceof import_obsidian6.TFile && file.extension === "md") {
|
|
void publishNote(this, file);
|
|
} else {
|
|
new import_obsidian6.Notice("Open a Markdown note first.");
|
|
}
|
|
});
|
|
this.registerEvent(
|
|
this.app.workspace.on("file-menu", (menu, file) => {
|
|
if (!(file instanceof import_obsidian6.TFile) || file.extension !== "md") return;
|
|
menu.addItem((item) => {
|
|
item.setTitle("Publish to GitHub Pages").setIcon("upload-cloud").onClick(() => {
|
|
void publishNote(this, file);
|
|
});
|
|
});
|
|
if (this.settings.registry[file.path]) {
|
|
menu.addItem((item) => {
|
|
item.setTitle("Unpublish from GitHub Pages").setIcon("trash-2").onClick(() => {
|
|
void unpublishNote(this, file);
|
|
});
|
|
});
|
|
}
|
|
})
|
|
);
|
|
this.registerEvent(
|
|
this.app.workspace.on("file-menu", (menu, folder) => {
|
|
if (!(folder instanceof import_obsidian6.TFolder)) return;
|
|
menu.addItem((item) => {
|
|
item.setTitle("Publish folder").setIcon("upload-cloud").onClick(() => {
|
|
void publishFolder(this, folder);
|
|
});
|
|
});
|
|
})
|
|
);
|
|
this.registerEvent(
|
|
this.app.vault.on("modify", (file) => {
|
|
if (!(file instanceof import_obsidian6.TFile)) return;
|
|
this.scheduleAutoUpdate(file);
|
|
})
|
|
);
|
|
}
|
|
onunload() {
|
|
for (const timerId of this.autoUpdateTimers.values()) {
|
|
window.clearTimeout(timerId);
|
|
}
|
|
this.autoUpdateTimers.clear();
|
|
}
|
|
scheduleAutoUpdate(file) {
|
|
if (!this.settings.autoUpdate) return;
|
|
if (!this.settings.registry[file.path]) return;
|
|
const existingTimer = this.autoUpdateTimers.get(file.path);
|
|
if (existingTimer !== void 0) {
|
|
window.clearTimeout(existingTimer);
|
|
}
|
|
const timerId = window.setTimeout(() => {
|
|
this.autoUpdateTimers.delete(file.path);
|
|
if (!this.settings.registry[file.path]) return;
|
|
void publishNote(this, file, { quiet: true });
|
|
}, AUTO_UPDATE_DEBOUNCE_MS);
|
|
this.autoUpdateTimers.set(file.path, timerId);
|
|
}
|
|
/** Cancels any pending auto-update for `filePath`. Called by unpublishNote so the
|
|
* unpublish + a queued republish can never race. The map drops the entry either way. */
|
|
clearAutoUpdateTimer(filePath) {
|
|
const timerId = this.autoUpdateTimers.get(filePath);
|
|
if (timerId !== void 0) {
|
|
window.clearTimeout(timerId);
|
|
}
|
|
this.autoUpdateTimers.delete(filePath);
|
|
}
|
|
async loadSettings() {
|
|
const data = await this.loadData();
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, data, {
|
|
registry: Object.assign({}, DEFAULT_SETTINGS.registry, data == null ? void 0 : data.registry)
|
|
});
|
|
}
|
|
async saveSettings() {
|
|
await this.saveData(this.settings);
|
|
}
|
|
};
|