3 Commits

Author SHA1 Message Date
Ad-closeNN b5775dbe8f del(config): 删除 Umami 统计服务 2025-08-13 22:16:14 +08:00
Ad-closeNN b5b44b3cb4 Merge branch 'main' into robin 2025-08-13 22:06:01 +08:00
Ad-closeNN d114372822 robin theme 2025-08-13 21:36:39 +08:00
198 changed files with 4626 additions and 10891 deletions
-1
View File
@@ -1 +0,0 @@
* text=auto
+4 -11
View File
@@ -26,14 +26,7 @@ package-lock.json
bun.lockb
yarn.lock
# src/content/.obsidian
.playwright-mcp
.serena
.claude
# .obsidian
.cache
build.log
.traces
# My test files
duolingo.py
duolingo copy.py
test.py
-8
View File
@@ -1,8 +0,0 @@
{
"newLinkFormat": "absolute",
"newFileLocation": "folder",
"newFileFolderPath": "src/content/posts",
"alwaysUpdateLinks": true,
"attachmentFolderPath": "/public/pic",
"useMarkdownLinks": true
}
-1
View File
@@ -1 +0,0 @@
{}
-5
View File
@@ -1,5 +0,0 @@
[
"fix-public-links",
"obsidian-auto-link-title",
"obsidian-paste-image-rename"
]
-33
View File
@@ -1,33 +0,0 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
-65
View File
@@ -1,65 +0,0 @@
const { Plugin } = require('obsidian');
module.exports = class FixPublicLinksPlugin extends Plugin {
async onload() {
console.log('Loading Fix Public Links plugin');
// 监听文件创建事件(粘贴图片时触发)
this.registerEvent(
this.app.vault.on('create', (file) => {
// 延迟执行,确保 Obsidian 已经插入了链接
setTimeout(() => {
this.fixPublicLinksInActiveFile();
}, 100);
})
);
// 添加命令:手动修复当前文件的所有链接
this.addCommand({
id: 'fix-public-links',
name: 'Fix public/ links in current file',
editorCallback: (editor) => {
this.fixPublicLinksInEditor(editor);
}
});
}
fixPublicLinksInActiveFile() {
const activeView = this.app.workspace.getActiveViewOfType(require('obsidian').MarkdownView);
if (!activeView) return;
const editor = activeView.editor;
this.fixPublicLinksInEditor(editor);
}
fixPublicLinksInEditor(editor) {
const cursor = editor.getCursor();
const lineCount = editor.lineCount();
let fixed = false;
// 遍历所有行
for (let i = 0; i < lineCount; i++) {
const line = editor.getLine(i);
// 匹配 Markdown 图片语法:![...](public/...)
const fixedLine = line.replace(/\]\(public\//g, '](/public/');
if (fixedLine !== line) {
editor.replaceRange(
fixedLine,
{ line: i, ch: 0 },
{ line: i, ch: line.length }
);
fixed = true;
}
}
if (fixed) {
console.log('Fixed public/ links in current file');
}
}
onunload() {
console.log('Unloading Fix Public Links plugin');
}
};
-10
View File
@@ -1,10 +0,0 @@
{
"id": "fix-public-links",
"name": "Fix Public Links",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "Automatically fix image links that start with public/ to /public/",
"author": "AcoFork",
"authorUrl": "https://blog.acofork.com",
"isDesktopOnly": false
}
-15
View File
@@ -1,15 +0,0 @@
{
"regex": {},
"lineRegex": {},
"linkRegex": {},
"linkLineRegex": {},
"imageRegex": {},
"enhanceDefaultPaste": true,
"shouldPreserveSelectionAsTitle": false,
"enhanceDropEvents": true,
"websiteBlacklist": "",
"maximumTitleLength": 0,
"useNewScraper": false,
"linkPreviewApiKey": "",
"useBetterPasteId": true
}
-771
View File
@@ -1,771 +0,0 @@
/*
THIS IS A GENERATED/BUNDLED FILE BY ROLLUP
if you want to view the source visit the plugins github repository
*/
'use strict';
var obsidian = require('obsidian');
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
const DEFAULT_SETTINGS = {
regex: /^(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/i,
lineRegex: /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi,
linkRegex: /^\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)$/i,
linkLineRegex: /\[([^\[\]]*)\]\((https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})\)/gi,
imageRegex: /\.(gif|jpe?g|tiff?|png|webp|bmp|tga|psd|ai)$/i,
enhanceDefaultPaste: true,
shouldPreserveSelectionAsTitle: false,
enhanceDropEvents: true,
websiteBlacklist: "",
maximumTitleLength: 0,
useNewScraper: false,
linkPreviewApiKey: "",
useBetterPasteId: false,
};
class AutoLinkTitleSettingTab extends obsidian.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
let { containerEl } = this;
containerEl.empty();
new obsidian.Setting(containerEl)
.setName("Enhance Default Paste")
.setDesc("Fetch the link title when pasting a link in the editor with the default paste command")
.addToggle((val) => val
.setValue(this.plugin.settings.enhanceDefaultPaste)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.enhanceDefaultPaste = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Enhance Drop Events")
.setDesc("Fetch the link title when drag and dropping a link from another program")
.addToggle((val) => val
.setValue(this.plugin.settings.enhanceDropEvents)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.enhanceDropEvents = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Maximum title length")
.setDesc("Set the maximum length of the title. Set to 0 to disable.")
.addText((val) => val
.setValue(this.plugin.settings.maximumTitleLength.toString(10))
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
const titleLength = Number(value);
this.plugin.settings.maximumTitleLength =
isNaN(titleLength) || titleLength < 0 ? 0 : titleLength;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Preserve selection as title")
.setDesc("Whether to prefer selected text as title over fetched title when pasting")
.addToggle((val) => val
.setValue(this.plugin.settings.shouldPreserveSelectionAsTitle)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.shouldPreserveSelectionAsTitle = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Website Blacklist")
.setDesc("List of strings (comma separated) that disable autocompleting website titles. Can be URLs or arbitrary text.")
.addTextArea((val) => val
.setValue(this.plugin.settings.websiteBlacklist)
.setPlaceholder("localhost, tiktok.com")
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
this.plugin.settings.websiteBlacklist = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Use New Scraper")
.setDesc("Use experimental new scraper, seems to work well on desktop but not mobile.")
.addToggle((val) => val
.setValue(this.plugin.settings.useNewScraper)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.useNewScraper = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("Use Better Fetching Placeholder")
.setDesc("Use a more readable placeholder when fetching the title of a link.")
.addToggle((val) => val
.setValue(this.plugin.settings.useBetterPasteId)
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
console.log(value);
this.plugin.settings.useBetterPasteId = value;
yield this.plugin.saveSettings();
})));
new obsidian.Setting(containerEl)
.setName("LinkPreview API Key")
.setDesc("API key for the LinkPreview.net service. Get one at https://my.linkpreview.net/access_keys")
.addText((text) => text
.setValue(this.plugin.settings.linkPreviewApiKey || "")
.onChange((value) => __awaiter(this, void 0, void 0, function* () {
const trimmedValue = value.trim();
if (trimmedValue.length > 0 && trimmedValue.length !== 32) {
new obsidian.Notice("LinkPreview API key must be 32 characters long");
this.plugin.settings.linkPreviewApiKey = "";
}
else {
this.plugin.settings.linkPreviewApiKey = trimmedValue;
}
yield this.plugin.saveSettings();
})));
}
}
class CheckIf {
static isMarkdownLinkAlready(editor) {
let cursor = editor.getCursor();
// Check if the characters before the url are ]( to indicate a markdown link
var titleEnd = editor.getRange({ ch: cursor.ch - 2, line: cursor.line }, { ch: cursor.ch, line: cursor.line });
return titleEnd == "](";
}
static isAfterQuote(editor) {
let cursor = editor.getCursor();
// Check if the characters before the url are " or ' to indicate we want the url directly
// This is common in elements like <a href="linkhere"></a>
var beforeChar = editor.getRange({ ch: cursor.ch - 1, line: cursor.line }, { ch: cursor.ch, line: cursor.line });
return beforeChar == "\"" || beforeChar == "'";
}
static isUrl(text) {
let urlRegex = new RegExp(DEFAULT_SETTINGS.regex);
return urlRegex.test(text);
}
static isImage(text) {
let imageRegex = new RegExp(DEFAULT_SETTINGS.imageRegex);
return imageRegex.test(text);
}
static isLinkedUrl(text) {
let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex);
return urlRegex.test(text);
}
}
class EditorExtensions {
static getSelectedText(editor) {
if (!editor.somethingSelected()) {
let wordBoundaries = this.getWordBoundaries(editor);
editor.setSelection(wordBoundaries.start, wordBoundaries.end);
}
return editor.getSelection();
}
static cursorWithinBoundaries(cursor, match) {
let startIndex = match.index;
let endIndex = match.index + match[0].length;
return startIndex <= cursor.ch && cursor.ch <= endIndex;
}
static getWordBoundaries(editor) {
let cursor = editor.getCursor();
// If its a normal URL token this is not a markdown link
// In this case we can simply overwrite the link boundaries as-is
let lineText = editor.getLine(cursor.line);
// First check if we're in a link
let linksInLine = lineText.matchAll(DEFAULT_SETTINGS.linkLineRegex);
for (let match of linksInLine) {
if (this.cursorWithinBoundaries(cursor, match)) {
return {
start: { line: cursor.line, ch: match.index },
end: { line: cursor.line, ch: match.index + match[0].length },
};
}
}
// If not, check if we're in just a standard ol' URL.
let urlsInLine = lineText.matchAll(DEFAULT_SETTINGS.lineRegex);
for (let match of urlsInLine) {
if (this.cursorWithinBoundaries(cursor, match)) {
return {
start: { line: cursor.line, ch: match.index },
end: { line: cursor.line, ch: match.index + match[0].length },
};
}
}
return {
start: cursor,
end: cursor,
};
}
static getEditorPositionFromIndex(content, index) {
let substr = content.substr(0, index);
let l = 0;
let offset = -1;
let r = -1;
for (; (r = substr.indexOf("\n", r + 1)) !== -1; l++, offset = r)
;
offset += 1;
let ch = content.substr(offset, index - offset).length;
return { line: l, ch: ch };
}
}
function blank$1(text) {
return text === undefined || text === null || text === '';
}
function notBlank$1(text) {
return !blank$1(text);
}
function scrape(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
const response = yield obsidian.requestUrl(url);
if (!response.headers['content-type'].includes('text/html'))
return getUrlFinalSegment$1(url);
const html = response.text;
const doc = new DOMParser().parseFromString(html, 'text/html');
const title = doc.querySelector('title');
if (blank$1(title === null || title === void 0 ? void 0 : title.innerText)) {
// If site is javascript based and has a no-title attribute when unloaded, use it.
var noTitle = title === null || title === void 0 ? void 0 : title.getAttr('no-title');
if (notBlank$1(noTitle)) {
return noTitle;
}
// Otherwise if the site has no title/requires javascript simply return Title Unknown
return url;
}
return title.innerText;
}
catch (ex) {
console.error(ex);
return '';
}
});
}
function getUrlFinalSegment$1(url) {
try {
const segments = new URL(url).pathname.split('/');
const last = segments.pop() || segments.pop(); // Handle potential trailing slash
return last;
}
catch (_) {
return 'File';
}
}
function getPageTitle$1(url) {
return __awaiter(this, void 0, void 0, function* () {
if (!(url.startsWith('http') || url.startsWith('https'))) {
url = 'https://' + url;
}
return scrape(url);
});
}
const electronPkg = require("electron");
function blank(text) {
return text === undefined || text === null || text === "";
}
function notBlank(text) {
return !blank(text);
}
// async wrapper to load a url and settle on load finish or fail
function load(window, url) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((resolve, reject) => {
window.webContents.on("did-finish-load", (event) => resolve(event));
window.webContents.on("did-fail-load", (event) => reject(event));
window.loadURL(url);
});
});
}
function electronGetPageTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
const { remote } = electronPkg;
const { BrowserWindow } = remote;
try {
const window = new BrowserWindow({
width: 1000,
height: 600,
webPreferences: {
webSecurity: false,
nodeIntegration: true,
images: false,
},
show: false,
});
window.webContents.setAudioMuted(true);
window.webContents.on("will-navigate", (event, newUrl) => {
event.preventDefault();
window.loadURL(newUrl);
});
yield load(window, url);
try {
const title = window.webContents.getTitle();
window.destroy();
if (notBlank(title)) {
return title;
}
else {
return url;
}
}
catch (ex) {
window.destroy();
return url;
}
}
catch (ex) {
console.error(ex);
return "";
}
});
}
function nonElectronGetPageTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
const html = yield obsidian.request({ url });
const doc = new DOMParser().parseFromString(html, "text/html");
const title = doc.querySelectorAll("title")[0];
if (title == null || blank(title === null || title === void 0 ? void 0 : title.innerText)) {
// If site is javascript based and has a no-title attribute when unloaded, use it.
var noTitle = title === null || title === void 0 ? void 0 : title.getAttr("no-title");
if (notBlank(noTitle)) {
return noTitle;
}
// Otherwise if the site has no title/requires javascript simply return Title Unknown
return url;
}
return title.innerText;
}
catch (ex) {
console.error(ex);
return "";
}
});
}
function getUrlFinalSegment(url) {
try {
const segments = new URL(url).pathname.split('/');
const last = segments.pop() || segments.pop(); // Handle potential trailing slash
return last;
}
catch (_) {
return "File";
}
}
function tryGetFileType(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
const response = yield fetch(url, { method: "HEAD" });
// Ensure site returns an ok status code before scraping
if (!response.ok) {
return "Site Unreachable";
}
// Ensure site is an actual HTML page and not a pdf or 3 gigabyte video file.
let contentType = response.headers.get("content-type");
if (!contentType.includes("text/html")) {
return getUrlFinalSegment(url);
}
return null;
}
catch (err) {
return null;
}
});
}
function getPageTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
// If we're on Desktop use the Electron scraper
if (!(url.startsWith("http") || url.startsWith("https"))) {
url = "https://" + url;
}
// Try to do a HEAD request to see if the site is reachable and if it's an HTML page
// If we error out due to CORS, we'll just try to scrape the page anyway.
let fileType = yield tryGetFileType(url);
if (fileType) {
return fileType;
}
if (electronPkg != null) {
return electronGetPageTitle(url);
}
else {
return nonElectronGetPageTitle(url);
}
});
}
class AutoLinkTitle extends obsidian.Plugin {
constructor() {
super(...arguments);
this.shortTitle = (title) => {
if (this.settings.maximumTitleLength === 0) {
return title;
}
if (title.length < this.settings.maximumTitleLength + 3) {
return title;
}
const shortenedTitle = `${title.slice(0, this.settings.maximumTitleLength)}...`;
return shortenedTitle;
};
}
onload() {
return __awaiter(this, void 0, void 0, function* () {
console.log("loading obsidian-auto-link-title");
yield this.loadSettings();
this.blacklist = this.settings.websiteBlacklist
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
// Listen to paste event
this.pasteFunction = this.pasteUrlWithTitle.bind(this);
// Listen to drop event
this.dropFunction = this.dropUrlWithTitle.bind(this);
this.addCommand({
id: "auto-link-title-paste",
name: "Paste URL and auto fetch title",
editorCallback: (editor) => this.manualPasteUrlWithTitle(editor),
hotkeys: [],
});
this.addCommand({
id: "auto-link-title-normal-paste",
name: "Normal paste (no fetching behavior)",
editorCallback: (editor) => this.normalPaste(editor),
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "v",
},
],
});
this.registerEvent(this.app.workspace.on("editor-paste", this.pasteFunction));
this.registerEvent(this.app.workspace.on("editor-drop", this.dropFunction));
this.addCommand({
id: "enhance-url-with-title",
name: "Enhance existing URL with link and title",
editorCallback: (editor) => this.addTitleToLink(editor),
hotkeys: [
{
modifiers: ["Mod", "Shift"],
key: "e",
},
],
});
this.addSettingTab(new AutoLinkTitleSettingTab(this.app, this));
});
}
addTitleToLink(editor) {
// Only attempt fetch if online
if (!navigator.onLine)
return;
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
// If the cursor is on a raw html link, convert to a markdown link and fetch title
if (CheckIf.isUrl(selectedText)) {
this.convertUrlToTitledLink(editor, selectedText);
}
// If the cursor is on the URL part of a markdown link, fetch title and replace existing link title
else if (CheckIf.isLinkedUrl(selectedText)) {
const link = this.getUrlFromLink(selectedText);
this.convertUrlToTitledLink(editor, link);
}
}
normalPaste(editor) {
return __awaiter(this, void 0, void 0, function* () {
let clipboardText = yield navigator.clipboard.readText();
if (clipboardText === null || clipboardText === "")
return;
editor.replaceSelection(clipboardText);
});
}
// Simulate standard paste but using editor.replaceSelection with clipboard text since we can't seem to dispatch a paste event.
manualPasteUrlWithTitle(editor) {
return __awaiter(this, void 0, void 0, function* () {
const clipboardText = yield navigator.clipboard.readText();
// Only attempt fetch if online
if (!navigator.onLine) {
editor.replaceSelection(clipboardText);
return;
}
if (clipboardText == null || clipboardText == "")
return;
// If its not a URL, we return false to allow the default paste handler to take care of it.
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
// to fetch the title is a waste of bandwidth.
if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) {
editor.replaceSelection(clipboardText);
return;
}
// If it looks like we're pasting the url into a markdown link already, don't fetch title
// as the user has already probably put a meaningful title, also it would lead to the title
// being inside the link.
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
editor.replaceSelection(clipboardText);
return;
}
// If url is pasted over selected text and setting is enabled, no need to fetch title,
// just insert a link
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
editor.replaceSelection(`[${selectedText}](${clipboardText})`);
return;
}
// At this point we're just pasting a link in a normal fashion, fetch its title.
this.convertUrlToTitledLink(editor, clipboardText);
return;
});
}
pasteUrlWithTitle(clipboard, editor) {
return __awaiter(this, void 0, void 0, function* () {
if (!this.settings.enhanceDefaultPaste) {
return;
}
if (clipboard.defaultPrevented)
return;
// Only attempt fetch if online
if (!navigator.onLine)
return;
let clipboardText = clipboard.clipboardData.getData("text/plain");
if (clipboardText === null || clipboardText === "")
return;
// If its not a URL, we return false to allow the default paste handler to take care of it.
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
// to fetch the title is a waste of bandwidth.
if (!CheckIf.isUrl(clipboardText) || CheckIf.isImage(clipboardText)) {
return;
}
// We've decided to handle the paste, stop propagation to the default handler.
clipboard.stopPropagation();
clipboard.preventDefault();
// If it looks like we're pasting the url into a markdown link already, don't fetch title
// as the user has already probably put a meaningful title, also it would lead to the title
// being inside the link.
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
editor.replaceSelection(clipboardText);
return;
}
// If url is pasted over selected text and setting is enabled, no need to fetch title,
// just insert a link
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
editor.replaceSelection(`[${selectedText}](${clipboardText})`);
return;
}
// At this point we're just pasting a link in a normal fashion, fetch its title.
this.convertUrlToTitledLink(editor, clipboardText);
return;
});
}
dropUrlWithTitle(dropEvent, editor) {
return __awaiter(this, void 0, void 0, function* () {
if (!this.settings.enhanceDropEvents) {
return;
}
if (dropEvent.defaultPrevented)
return;
// Only attempt fetch if online
if (!navigator.onLine)
return;
let dropText = dropEvent.dataTransfer.getData("text/plain");
if (dropText === null || dropText === "")
return;
// If its not a URL, we return false to allow the default paste handler to take care of it.
// Similarly, image urls don't have a meaningful <title> attribute so downloading it
// to fetch the title is a waste of bandwidth.
if (!CheckIf.isUrl(dropText) || CheckIf.isImage(dropText)) {
return;
}
// We've decided to handle the paste, stop propagation to the default handler.
dropEvent.stopPropagation();
dropEvent.preventDefault();
// If it looks like we're pasting the url into a markdown link already, don't fetch title
// as the user has already probably put a meaningful title, also it would lead to the title
// being inside the link.
if (CheckIf.isMarkdownLinkAlready(editor) || CheckIf.isAfterQuote(editor)) {
editor.replaceSelection(dropText);
return;
}
// If url is pasted over selected text and setting is enabled, no need to fetch title,
// just insert a link
let selectedText = (EditorExtensions.getSelectedText(editor) || "").trim();
if (selectedText && this.settings.shouldPreserveSelectionAsTitle) {
editor.replaceSelection(`[${selectedText}](${dropText})`);
return;
}
// At this point we're just pasting a link in a normal fashion, fetch its title.
this.convertUrlToTitledLink(editor, dropText);
return;
});
}
isBlacklisted(url) {
return __awaiter(this, void 0, void 0, function* () {
yield this.loadSettings();
this.blacklist = this.settings.websiteBlacklist
.split(/,|\n/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
return this.blacklist.some((site) => url.includes(site));
});
}
convertUrlToTitledLink(editor, url) {
return __awaiter(this, void 0, void 0, function* () {
if (yield this.isBlacklisted(url)) {
let domain = new URL(url).hostname;
editor.replaceSelection(`[${domain}](${url})`);
return;
}
// Generate a unique id for find/replace operations for the title.
const pasteId = this.getPasteId();
// Instantly paste so you don't wonder if paste is broken
editor.replaceSelection(`[${pasteId}](${url})`);
// Fetch title from site, replace Fetching Title with actual title
const title = yield this.fetchUrlTitle(url);
const escapedTitle = this.escapeMarkdown(title);
const shortenedTitle = this.shortTitle(escapedTitle);
const text = editor.getValue();
const start = text.indexOf(pasteId);
if (start < 0) {
console.log(`Unable to find text "${pasteId}" in current editor, bailing out; link ${url}`);
}
else {
const end = start + pasteId.length;
const startPos = EditorExtensions.getEditorPositionFromIndex(text, start);
const endPos = EditorExtensions.getEditorPositionFromIndex(text, end);
editor.replaceRange(shortenedTitle, startPos, endPos);
}
});
}
escapeMarkdown(text) {
var unescaped = text.replace(/\\(\*|_|`|~|\\|\[|\])/g, "$1"); // unescape any "backslashed" character
var escaped = unescaped.replace(/(\*|_|`|<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, [, ], <, and >
var escaped = unescaped.replace(/(\*|_|`|\||<|>|~|\\|\[|\])/g, "\\$1"); // escape *, _, `, ~, \, |, [, ], <, and >
return escaped;
}
fetchUrlTitleViaLinkPreview(url) {
return __awaiter(this, void 0, void 0, function* () {
if (this.settings.linkPreviewApiKey.length !== 32) {
console.error("LinkPreview API key is not 32 characters long, please check your settings");
return "";
}
try {
const apiEndpoint = `https://api.linkpreview.net/?q=${encodeURIComponent(url)}`;
const response = yield fetch(apiEndpoint, {
headers: {
"X-Linkpreview-Api-Key": this.settings.linkPreviewApiKey,
},
});
const data = yield response.json();
return data.title;
}
catch (error) {
console.error(error);
return "";
}
});
}
fetchUrlTitle(url) {
return __awaiter(this, void 0, void 0, function* () {
try {
let title = "";
title = yield this.fetchUrlTitleViaLinkPreview(url);
console.log(`Title via Link Preview: ${title}`);
if (title === "") {
console.log("Title via Link Preview failed, falling back to scraper");
if (this.settings.useNewScraper) {
console.log("Using new scraper");
title = yield getPageTitle$1(url);
}
else {
console.log("Using old scraper");
title = yield getPageTitle(url);
}
}
console.log(`Title: ${title}`);
title =
title.replace(/(\r\n|\n|\r)/gm, "").trim() ||
"Title Unavailable | Site Unreachable";
return title;
}
catch (error) {
console.error(error);
return "Error fetching title";
}
});
}
getUrlFromLink(link) {
let urlRegex = new RegExp(DEFAULT_SETTINGS.linkRegex);
return urlRegex.exec(link)[2];
}
getPasteId() {
var base = "Fetching Title";
if (this.settings.useBetterPasteId) {
return this.getBetterPasteId(base);
}
else {
return `${base}#${this.createBlockHash()}`;
}
}
getBetterPasteId(base) {
// After every character, add 0, 1 or 2 invisible characters
// so that to the user it looks just like the base string.
// The number of combinations is 3^14 = 4782969
let result = "";
var invisibleCharacter = "\u200B";
var maxInvisibleCharacters = 2;
for (var i = 0; i < base.length; i++) {
var count = Math.floor(Math.random() * (maxInvisibleCharacters + 1));
result += base.charAt(i) + invisibleCharacter.repeat(count);
}
return result;
}
// Custom hashid by @shabegom
createBlockHash() {
let result = "";
var characters = "abcdefghijklmnopqrstuvwxyz0123456789";
var charactersLength = characters.length;
for (var i = 0; i < 4; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
onunload() {
console.log("unloading obsidian-auto-link-title");
}
loadSettings() {
return __awaiter(this, void 0, void 0, function* () {
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
});
}
saveSettings() {
return __awaiter(this, void 0, void 0, function* () {
yield this.saveData(this.settings);
});
}
}
module.exports = AutoLinkTitle;
/* nosourcemap */
@@ -1,10 +0,0 @@
{
"id": "obsidian-auto-link-title",
"name": "Auto Link Title",
"version": "1.5.5",
"minAppVersion": "0.12.17",
"description": "This plugin automatically fetches the titles of links from the web",
"author": "Matt Furden",
"authorUrl": "https://github.com/zolrath",
"isDesktopOnly": false
}
-1
View File
@@ -1 +0,0 @@
/* no styles */
-11
View File
@@ -1,11 +0,0 @@
{
"imageNamePattern": "{{fileName}}",
"dupNumberAtStart": false,
"dupNumberDelimiter": "-",
"dupNumberAlways": true,
"autoRename": true,
"handleAllAttachments": true,
"excludeExtensionPattern": "",
"disableRenameNotice": false,
"useFileHashName": true
}
-965
View File
@@ -1,965 +0,0 @@
/* THIS IS A GENERATED/BUNDLED FILE BY ESBUILD */
var __defProp = Object.defineProperty;
var __defProps = Object.defineProperties;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropDescs = Object.getOwnPropertyDescriptors;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getOwnPropSymbols = Object.getOwnPropertySymbols;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __propIsEnum = Object.prototype.propertyIsEnumerable;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __spreadValues = (a, b) => {
for (var prop in b || (b = {}))
if (__hasOwnProp.call(b, prop))
__defNormalProp(a, prop, b[prop]);
if (__getOwnPropSymbols)
for (var prop of __getOwnPropSym )
__defNormalProp(a, prop, b[prop]);
return a;
};
var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b));
var __commonJS = (cb, mod) => function __require() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
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);
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
};
var rejected = (value) => {
try {
step(generator.throw(value));
} catch (e) {
reject(e);
}
};
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
step((generator = generator.apply(__this, __arguments)).next());
});
};
// package.json
var require_package = __commonJS({
"package.json"(exports, module2) {
module2.exports = {
name: "obsidian-paste-image-rename",
version: "1.6.1",
main: "main.js",
scripts: {
start: "node esbuild.config.mjs",
build: "tsc -noEmit -skipLibCheck && BUILD_ENV=production node esbuild.config.mjs && cp manifest.json build",
version: "node version-bump.mjs && git add manifest.json versions.json",
release: "npm run build && gh release create ${npm_package_version} build/*"
},
keywords: [],
author: "Reorx",
license: "MIT",
devDependencies: {
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
"builtin-modules": "^3.3.0",
esbuild: "0.16.17",
obsidian: "^1.1.1",
tslib: "2.5.0",
typescript: "4.9.4"
},
dependencies: {
"cash-dom": "^8.1.2"
}
};
}
});
// src/main.ts
var main_exports = {};
__export(main_exports, {
default: () => PasteImageRenamePlugin
});
module.exports = __toCommonJS(main_exports);
var import_obsidian2 = require("obsidian");
// src/batch.ts
var import_obsidian = require("obsidian");
// src/utils.ts
var DEBUG = false;
if (DEBUG)
console.log("DEBUG is enabled");
function debugLog(...args) {
if (DEBUG) {
console.log(new Date().toISOString().slice(11, 23), ...args);
}
}
function createElementTree(rootEl, opts) {
const result = {
el: rootEl.createEl(opts.tag, opts),
children: []
};
const children = opts.children || [];
for (const child of children) {
result.children.push(createElementTree(result.el, child));
}
return result;
}
var path = {
// Credit: @creationix/path.js
join(...partSegments) {
let parts = [];
for (let i = 0, l = partSegments.length; i < l; i++) {
parts = parts.concat(partSegments[i].split("/"));
}
const newParts = [];
for (let i = 0, l = parts.length; i < l; i++) {
const part = parts[i];
if (!part || part === ".")
continue;
else
newParts.push(part);
}
if (parts[0] === "")
newParts.unshift("");
return newParts.join("/");
},
// returns the last part of a path, e.g. 'foo.jpg'
basename(fullpath) {
const sp = fullpath.split("/");
return sp[sp.length - 1];
},
// return extension without dot, e.g. 'jpg'
extension(fullpath) {
const positions = [...fullpath.matchAll(new RegExp("\\.", "gi"))].map((a) => a.index);
return fullpath.slice(positions[positions.length - 1] + 1);
}
};
var filenameNotAllowedChars = /[^\p{L}0-9~`!@$&*()\-_=+{};'",<.>? ]/ug;
var sanitizer = {
filename(s) {
return s.replace(filenameNotAllowedChars, "").trim();
},
delimiter(s) {
s = this.filename(s);
if (!s)
s = "-";
return s;
}
};
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function lockInputMethodComposition(el) {
const state = {
lock: false
};
el.addEventListener("compositionstart", () => {
state.lock = true;
});
el.addEventListener("compositionend", () => {
state.lock = false;
});
return state;
}
function toHex(buffer) {
return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function sha256(buffer) {
return __async(this, null, function* () {
const digest = yield crypto.subtle.digest("SHA-256", buffer);
return toHex(digest);
});
}
// src/batch.ts
var ImageBatchRenameModal = class extends import_obsidian.Modal {
constructor(app, activeFile, renameFunc, onClose) {
super(app);
this.activeFile = activeFile;
this.renameFunc = renameFunc;
this.onCloseExtra = onClose;
this.state = {
namePattern: "",
extPattern: "",
nameReplace: "",
renameTasks: []
};
}
onOpen() {
this.containerEl.addClass("image-rename-modal");
const { contentEl, titleEl } = this;
titleEl.setText("Batch rename embeded files");
const namePatternSetting = new import_obsidian.Setting(contentEl).setName("Name pattern").setDesc("Please input the name pattern to match files (regex)").addText((text) => text.setValue(this.state.namePattern).onChange(
(value) => __async(this, null, function* () {
this.state.namePattern = value;
})
));
const npInputEl = namePatternSetting.controlEl.children[0];
npInputEl.focus();
const npInputState = lockInputMethodComposition(npInputEl);
npInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter" && !npInputState.lock) {
e.preventDefault();
if (!this.state.namePattern) {
errorEl.innerText = 'Error: "Name pattern" could not be empty';
errorEl.style.display = "block";
return;
}
this.matchImageNames(tbodyEl);
}
}));
const extPatternSetting = new import_obsidian.Setting(contentEl).setName("Extension pattern").setDesc("Please input the extension pattern to match files (regex)").addText((text) => text.setValue(this.state.extPattern).onChange(
(value) => __async(this, null, function* () {
this.state.extPattern = value;
})
));
const extInputEl = extPatternSetting.controlEl.children[0];
extInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter") {
e.preventDefault();
this.matchImageNames(tbodyEl);
}
}));
const nameReplaceSetting = new import_obsidian.Setting(contentEl).setName("Name replace").setDesc("Please input the string to replace the matched name (use $1, $2 for regex groups)").addText((text) => text.setValue(this.state.nameReplace).onChange(
(value) => __async(this, null, function* () {
this.state.nameReplace = value;
})
));
const nrInputEl = nameReplaceSetting.controlEl.children[0];
const nrInputState = lockInputMethodComposition(nrInputEl);
nrInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter" && !nrInputState.lock) {
e.preventDefault();
this.matchImageNames(tbodyEl);
}
}));
const matchedContainer = contentEl.createDiv({
cls: "matched-container"
});
const tableET = createElementTree(matchedContainer, {
tag: "table",
children: [
{
tag: "thead",
children: [
{
tag: "tr",
children: [
{
tag: "td",
text: "Original path"
},
{
tag: "td",
text: "Renamed Name"
}
]
}
]
},
{
tag: "tbody"
}
]
});
const tbodyEl = tableET.children[1].el;
const errorEl = contentEl.createDiv({
cls: "error",
attr: {
style: "display: none;"
}
});
new import_obsidian.Setting(contentEl).addButton((button) => {
button.setButtonText("Rename all").setClass("mod-cta").onClick(() => {
new ConfirmModal(
this.app,
"Confirm rename all",
`Are you sure? This will rename all the ${this.state.renameTasks.length} images matched the pattern.`,
() => {
this.renameAll();
this.close();
}
).open();
});
}).addButton((button) => {
button.setButtonText("Cancel").onClick(() => {
this.close();
});
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.onCloseExtra();
}
renameAll() {
return __async(this, null, function* () {
debugLog("renameAll", this.state);
for (const task of this.state.renameTasks) {
yield this.renameFunc(task.file, task.name);
}
});
}
matchImageNames(tbodyEl) {
const { state } = this;
const renameTasks = [];
tbodyEl.empty();
const fileCache = this.app.metadataCache.getFileCache(this.activeFile);
if (!fileCache || !fileCache.embeds)
return;
const namePatternRegex = new RegExp(state.namePattern, "g");
const extPatternRegex = new RegExp(state.extPattern);
fileCache.embeds.forEach((embed) => {
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, this.activeFile.path);
if (!file) {
console.warn("file not found", embed.link);
return;
}
if (state.extPattern) {
const m0 = extPatternRegex.exec(file.extension);
if (!m0)
return;
}
const stem = file.basename;
namePatternRegex.lastIndex = 0;
const m1 = namePatternRegex.exec(stem);
if (!m1)
return;
let renamedName = file.name;
if (state.nameReplace) {
namePatternRegex.lastIndex = 0;
renamedName = stem.replace(namePatternRegex, state.nameReplace);
renamedName = `${renamedName}.${file.extension}`;
}
renameTasks.push({
file,
name: renamedName
});
createElementTree(tbodyEl, {
tag: "tr",
children: [
{
tag: "td",
children: [
{
tag: "span",
text: file.name
},
{
tag: "div",
text: file.path,
attr: {
class: "file-path"
}
}
]
},
{
tag: "td",
children: [
{
tag: "span",
text: renamedName
},
{
tag: "div",
text: path.join(file.parent.path, renamedName),
attr: {
class: "file-path"
}
}
]
}
]
});
});
debugLog("new renameTasks", renameTasks);
state.renameTasks = renameTasks;
}
};
var ConfirmModal = class extends import_obsidian.Modal {
constructor(app, title, message, onConfirm) {
super(app);
this.title = title;
this.message = message;
this.onConfirm = onConfirm;
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText(this.title);
contentEl.createEl("p", {
text: this.message
});
new import_obsidian.Setting(contentEl).addButton((button) => {
button.setButtonText("Yes").setClass("mod-warning").onClick(() => {
this.onConfirm();
this.close();
});
}).addButton((button) => {
button.setButtonText("No").onClick(() => {
this.close();
});
});
}
};
// src/template.ts
var dateTmplRegex = /{{DATE:([^}]+)}}/gm;
var frontmatterTmplRegex = /{{frontmatter:([^}]+)}}/gm;
var replaceDateVar = (s, date) => {
const m = dateTmplRegex.exec(s);
if (!m)
return s;
return s.replace(m[0], date.format(m[1]));
};
var replaceFrontmatterVar = (s, frontmatter) => {
if (!frontmatter)
return s;
const m = frontmatterTmplRegex.exec(s);
if (!m)
return s;
return s.replace(m[0], frontmatter[m[1]] || "");
};
var renderTemplate = (tmpl, data, frontmatter) => {
const now = window.moment();
let text = tmpl;
let newtext;
while ((newtext = replaceDateVar(text, now)) != text) {
text = newtext;
}
while ((newtext = replaceFrontmatterVar(text, frontmatter)) != text) {
text = newtext;
}
text = text.replace(/{{imageNameKey}}/gm, data.imageNameKey).replace(/{{fileName}}/gm, data.fileName).replace(/{{dirName}}/gm, data.dirName).replace(/{{firstHeading}}/gm, data.firstHeading).replace(/{{fileHash}}/gm, data.fileHash);
return text;
};
// src/main.ts
var DEFAULT_SETTINGS = {
imageNamePattern: "{{fileName}}",
dupNumberAtStart: false,
dupNumberDelimiter: "-",
dupNumberAlways: false,
autoRename: false,
handleAllAttachments: false,
excludeExtensionPattern: "",
disableRenameNotice: false
};
var PASTED_IMAGE_PREFIX = "Pasted image ";
var PasteImageRenamePlugin = class extends import_obsidian2.Plugin {
constructor() {
super(...arguments);
this.modals = [];
}
onload() {
return __async(this, null, function* () {
const pkg = require_package();
console.log(`Plugin loading: ${pkg.name} ${pkg.version} BUILD_ENV=${"production"}`);
yield this.loadSettings();
this.registerEvent(
this.app.vault.on("create", (file) => {
if (!(file instanceof import_obsidian2.TFile))
return;
const timeGapMs = new Date().getTime() - file.stat.ctime;
if (timeGapMs > 1e3)
return;
if (isMarkdownFile(file))
return;
if (isPastedImage(file)) {
debugLog("pasted image created", file);
this.startRenameProcess(file, this.settings.autoRename);
} else {
if (this.settings.handleAllAttachments) {
debugLog("handleAllAttachments for file", file);
if (this.testExcludeExtension(file)) {
debugLog("excluded file by ext", file);
return;
}
this.startRenameProcess(file, this.settings.autoRename);
}
}
})
);
const startBatchRenameProcess = () => {
this.openBatchRenameModal();
};
this.addCommand({
id: "batch-rename-embeded-files",
name: "Batch rename embeded files (in the current file)",
callback: startBatchRenameProcess
});
if (DEBUG) {
this.addRibbonIcon("wand-glyph", "Batch rename embeded files", startBatchRenameProcess);
}
const batchRenameAllImages = () => {
this.batchRenameAllImages();
};
this.addCommand({
id: "batch-rename-all-images",
name: "Batch rename all images instantly (in the current file)",
callback: batchRenameAllImages
});
if (DEBUG) {
this.addRibbonIcon("wand-glyph", "Batch rename all images instantly (in the current file)", batchRenameAllImages);
}
this.addSettingTab(new SettingTab(this.app, this));
});
}
startRenameProcess(file, autoRename = false) {
return __async(this, null, function* () {
const activeFile = this.getActiveFile();
if (!activeFile) {
new import_obsidian2.Notice("Error: No active file found.");
return;
}
const { stem, newName, isMeaningful } = yield this.generateNewName(file, activeFile);
debugLog("generated newName:", newName, isMeaningful);
if (!isMeaningful || !autoRename) {
this.openRenameModal(file, isMeaningful ? stem : "", activeFile.path);
return;
}
this.renameFile(file, newName, activeFile.path, true);
});
}
renameFile(file, inputNewName, sourcePath, replaceCurrentLine) {
return __async(this, null, function* () {
const { name: newName } = yield this.deduplicateNewName(inputNewName, file);
debugLog("deduplicated newName:", newName);
const originName = file.name;
const linkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
const newPath = path.join(file.parent.path, newName);
try {
yield this.app.fileManager.renameFile(file, newPath);
} catch (err) {
new import_obsidian2.Notice(`Failed to rename ${newName}: ${err}`);
throw err;
}
if (!replaceCurrentLine) {
return;
}
const newLinkText = this.app.fileManager.generateMarkdownLink(file, sourcePath);
debugLog("replace text", linkText, newLinkText);
const editor = this.getActiveEditor();
if (!editor) {
new import_obsidian2.Notice(`Failed to rename ${newName}: no active editor`);
return;
}
const changes = [];
for (let lineNumber = 0; lineNumber < editor.lineCount(); lineNumber++) {
const line = editor.getLine(lineNumber);
const replacedLine = line.split(linkText).join(newLinkText);
if (line === replacedLine) {
continue;
}
debugLog("replace line", lineNumber, line, replacedLine);
changes.push({
from: { line: lineNumber, ch: 0 },
to: { line: lineNumber, ch: line.length },
text: replacedLine
});
}
if (changes.length > 0) {
editor.transaction({ changes });
}
if (!this.settings.disableRenameNotice) {
new import_obsidian2.Notice(`Renamed ${originName} to ${newName}`);
}
});
}
openRenameModal(file, newName, sourcePath) {
const modal = new ImageRenameModal(
this.app,
file,
newName,
(confirmedName) => {
debugLog("confirmedName:", confirmedName);
this.renameFile(file, confirmedName, sourcePath, true);
},
() => {
this.modals.splice(this.modals.indexOf(modal), 1);
}
);
this.modals.push(modal);
modal.open();
debugLog("modals count", this.modals.length);
}
openBatchRenameModal() {
const activeFile = this.getActiveFile();
const modal = new ImageBatchRenameModal(
this.app,
activeFile,
(file, name) => __async(this, null, function* () {
yield this.renameFile(file, name, activeFile.path);
}),
() => {
this.modals.splice(this.modals.indexOf(modal), 1);
}
);
this.modals.push(modal);
modal.open();
}
batchRenameAllImages() {
return __async(this, null, function* () {
const activeFile = this.getActiveFile();
const fileCache = this.app.metadataCache.getFileCache(activeFile);
if (!fileCache || !fileCache.embeds)
return;
const extPatternRegex = /jpe?g|png|gif|tiff|webp/i;
for (const embed of fileCache.embeds) {
const file = this.app.metadataCache.getFirstLinkpathDest(embed.link, activeFile.path);
if (!file) {
console.warn("file not found", embed.link);
return;
}
const m0 = extPatternRegex.exec(file.extension);
if (!m0)
return;
const { newName, isMeaningful } = yield this.generateNewName(file, activeFile);
debugLog("generated newName:", newName, isMeaningful);
if (!isMeaningful) {
new import_obsidian2.Notice("Failed to batch rename images: the generated name is not meaningful");
break;
}
yield this.renameFile(file, newName, activeFile.path, false);
}
});
}
// returns a new name for the input file, with extension
generateNewName(file, activeFile) {
return __async(this, null, function* () {
let imageNameKey = "";
let firstHeading = "";
let frontmatter;
const fileCache = this.app.metadataCache.getFileCache(activeFile);
if (fileCache) {
debugLog("frontmatter", fileCache.frontmatter);
frontmatter = fileCache.frontmatter;
imageNameKey = (frontmatter == null ? void 0 : frontmatter.imageNameKey) || "";
firstHeading = getFirstHeading(fileCache.headings);
} else {
console.warn("could not get file cache from active file", activeFile.name);
}
let fileHash = "";
if (this.settings.imageNamePattern.includes("{{fileHash}}")) {
const buffer = yield this.app.vault.readBinary(file);
fileHash = yield sha256(buffer);
}
const stem = renderTemplate(
this.settings.imageNamePattern,
{
imageNameKey,
fileName: activeFile.basename,
dirName: activeFile.parent.name,
firstHeading,
fileHash
},
frontmatter
);
const meaninglessRegex = new RegExp(`[${this.settings.dupNumberDelimiter}\\s]`, "gm");
return {
stem,
newName: stem + "." + file.extension,
isMeaningful: stem.replace(meaninglessRegex, "") !== ""
};
});
}
// newName: foo.ext
deduplicateNewName(newName, file) {
return __async(this, null, function* () {
const dir = file.parent.path;
const listed = yield this.app.vault.adapter.list(dir);
debugLog("sibling files", listed);
const newNameExt = path.extension(newName), newNameStem = newName.slice(0, newName.length - newNameExt.length - 1), newNameStemEscaped = escapeRegExp(newNameStem), delimiter = this.settings.dupNumberDelimiter, delimiterEscaped = escapeRegExp(delimiter);
let dupNameRegex;
if (this.settings.dupNumberAtStart) {
dupNameRegex = new RegExp(
`^(?<number>\\d+)${delimiterEscaped}(?<name>${newNameStemEscaped})\\.${newNameExt}$`
);
} else {
dupNameRegex = new RegExp(
`^(?<name>${newNameStemEscaped})${delimiterEscaped}(?<number>\\d+)\\.${newNameExt}$`
);
}
debugLog("dupNameRegex", dupNameRegex);
const dupNameNumbers = [];
let isNewNameExist = false;
for (let sibling of listed.files) {
sibling = path.basename(sibling);
if (sibling == newName) {
isNewNameExist = true;
continue;
}
const m = dupNameRegex.exec(sibling);
if (!m)
continue;
dupNameNumbers.push(parseInt(m.groups.number));
}
if (isNewNameExist || this.settings.dupNumberAlways) {
const newNumber = dupNameNumbers.length > 0 ? Math.max(...dupNameNumbers) + 1 : 1;
if (this.settings.dupNumberAtStart) {
newName = `${newNumber}${delimiter}${newNameStem}.${newNameExt}`;
} else {
newName = `${newNameStem}${delimiter}${newNumber}.${newNameExt}`;
}
}
return {
name: newName,
stem: newName.slice(0, newName.length - newNameExt.length - 1),
extension: newNameExt
};
});
}
getActiveFile() {
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
const file = view == null ? void 0 : view.file;
debugLog("active file", file == null ? void 0 : file.path);
return file;
}
getActiveEditor() {
const view = this.app.workspace.getActiveViewOfType(import_obsidian2.MarkdownView);
return view == null ? void 0 : view.editor;
}
onunload() {
this.modals.map((modal) => modal.close());
}
testExcludeExtension(file) {
const pattern = this.settings.excludeExtensionPattern;
if (!pattern)
return false;
return new RegExp(pattern).test(file.extension);
}
loadSettings() {
return __async(this, null, function* () {
this.settings = Object.assign({}, DEFAULT_SETTINGS, yield this.loadData());
});
}
saveSettings() {
return __async(this, null, function* () {
yield this.saveData(this.settings);
});
}
};
function getFirstHeading(headings) {
if (headings && headings.length > 0) {
for (const heading of headings) {
if (heading.level === 1) {
return heading.heading;
}
}
}
return "";
}
function isPastedImage(file) {
if (file instanceof import_obsidian2.TFile) {
if (file.name.startsWith(PASTED_IMAGE_PREFIX)) {
return true;
}
}
return false;
}
function isMarkdownFile(file) {
if (file instanceof import_obsidian2.TFile) {
if (file.extension === "md") {
return true;
}
}
return false;
}
var ImageRenameModal = class extends import_obsidian2.Modal {
constructor(app, src, stem, renameFunc, onClose) {
super(app);
this.src = src;
this.stem = stem;
this.renameFunc = renameFunc;
this.onCloseExtra = onClose;
}
onOpen() {
this.containerEl.addClass("image-rename-modal");
const { contentEl, titleEl } = this;
titleEl.setText("Rename image");
const imageContainer = contentEl.createDiv({
cls: "image-container"
});
imageContainer.createEl("img", {
attr: {
src: this.app.vault.getResourcePath(this.src)
}
});
let stem = this.stem;
const ext = this.src.extension;
const getNewName = (stem2) => stem2 + "." + ext;
const getNewPath = (stem2) => path.join(this.src.parent.path, getNewName(stem2));
const infoET = createElementTree(contentEl, {
tag: "ul",
cls: "info",
children: [
{
tag: "li",
children: [
{
tag: "span",
text: "Origin path"
},
{
tag: "span",
text: this.src.path
}
]
},
{
tag: "li",
children: [
{
tag: "span",
text: "New path"
},
{
tag: "span",
text: getNewPath(stem)
}
]
}
]
});
const doRename = () => __async(this, null, function* () {
debugLog("doRename", `stem=${stem}`);
this.renameFunc(getNewName(stem));
});
const nameSetting = new import_obsidian2.Setting(contentEl).setName("New name").setDesc("Please input the new name for the image (without extension)").addText((text) => text.setValue(stem).onChange(
(value) => __async(this, null, function* () {
stem = sanitizer.filename(value);
infoET.children[1].children[1].el.innerText = getNewPath(stem);
})
));
const nameInputEl = nameSetting.controlEl.children[0];
nameInputEl.focus();
const nameInputState = lockInputMethodComposition(nameInputEl);
nameInputEl.addEventListener("keydown", (e) => __async(this, null, function* () {
if (e.key === "Enter" && !nameInputState.lock) {
e.preventDefault();
if (!stem) {
errorEl.innerText = 'Error: "New name" could not be empty';
errorEl.style.display = "block";
return;
}
doRename();
this.close();
}
}));
const errorEl = contentEl.createDiv({
cls: "error",
attr: {
style: "display: none;"
}
});
new import_obsidian2.Setting(contentEl).addButton((button) => {
button.setButtonText("Rename").onClick(() => {
doRename();
this.close();
});
}).addButton((button) => {
button.setButtonText("Cancel").onClick(() => {
this.close();
});
});
}
onClose() {
const { contentEl } = this;
contentEl.empty();
this.onCloseExtra();
}
};
var imageNamePatternDesc = `
The pattern indicates how the new name should be generated.
Available variables:
- {{fileName}}: name of the active file, without ".md" extension.
- {{imageNameKey}}: this variable is read from the markdown file's frontmatter, from the same key "imageNameKey".
- {{fileHash}}: SHA-256 hash of the image file.
- {{DATE:$FORMAT}}: use "$FORMAT" to format the current date, "$FORMAT" must be a Moment.js format string, e.g. {{DATE:YYYY-MM-DD}}.
Here are some examples from pattern to image names (repeat in sequence), variables: fileName = "My note", imageNameKey = "foo":
- {{fileName}}: My note, My note-1, My note-2
- {{imageNameKey}}: foo, foo-1, foo-2
- {{fileHash}}: <sha256>, <sha256>-1, <sha256>-2
- {{imageNameKey}}-{{DATE:YYYYMMDD}}: foo-20220408, foo-20220408-1, foo-20220408-2
`;
var SettingTab = class extends import_obsidian2.PluginSettingTab {
constructor(app, plugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new import_obsidian2.Setting(containerEl).setName("Image name pattern").setDesc(imageNamePatternDesc).setClass("long-description-setting-item").addText((text) => text.setPlaceholder("{{imageNameKey}}").setValue(this.plugin.settings.imageNamePattern).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.imageNamePattern = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Duplicate number at start (or end)").setDesc(`If enabled, duplicate number will be added at the start as prefix for the image name, otherwise it will be added at the end as suffix for the image name.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAtStart).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.dupNumberAtStart = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Duplicate number delimiter").setDesc(`The delimiter to generate the number prefix/suffix for duplicated names. For example, if the value is "-", the suffix will be like "-1", "-2", "-3", and the prefix will be like "1-", "2-", "3-". Only characters that are valid in file names are allowed.`).addText((text) => text.setValue(this.plugin.settings.dupNumberDelimiter).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.dupNumberDelimiter = sanitizer.delimiter(value);
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Always add duplicate number").setDesc(`If enabled, duplicate number will always be added to the image name. Otherwise, it will only be added when the name is duplicated.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.dupNumberAlways).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.dupNumberAlways = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Auto rename").setDesc(`By default, the rename modal will always be shown to confirm before renaming, if this option is set, the image will be auto renamed after pasting.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.autoRename).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.autoRename = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Handle all attachments").setDesc(`By default, the plugin only handles images that starts with "Pasted image " in name,
which is the prefix Obsidian uses to create images from pasted content.
If this option is set, the plugin will handle all attachments that are created in the vault.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.handleAllAttachments).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.handleAllAttachments = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Exclude extension pattern").setDesc(`This option is only useful when "Handle all attachments" is enabled.
Write a Regex pattern to exclude certain extensions from being handled. Only the first line will be used.`).setClass("single-line-textarea").addTextArea((text) => text.setPlaceholder("docx?|xlsx?|pptx?|zip|rar").setValue(this.plugin.settings.excludeExtensionPattern).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.excludeExtensionPattern = value;
yield this.plugin.saveSettings();
})
));
new import_obsidian2.Setting(containerEl).setName("Disable rename notice").setDesc(`Turn off this option if you don't want to see the notice when renaming images.
Note that Obsidian may display a notice when a link has changed, this option cannot disable that.`).addToggle((toggle) => toggle.setValue(this.plugin.settings.disableRenameNotice).onChange(
(value) => __async(this, null, function* () {
this.plugin.settings.disableRenameNotice = value;
yield this.plugin.saveSettings();
})
));
}
};
/* nosourcemap */
@@ -1,10 +0,0 @@
{
"id": "obsidian-paste-image-rename",
"name": "Paste image rename (Edited)",
"version": "1.6.1",
"minAppVersion": "0.12.0",
"description": "Rename pasted images and all the other attchments added to the vault",
"author": "Reorx",
"authorUrl": "https://github.com/reorx",
"isDesktopOnly": false
}
@@ -1,79 +0,0 @@
/* src/styles.css */
:root {
--shadow-color: 0deg 0% 0%;
--shadow-elevation-medium:
0.5px 0.5px 0.7px hsl(var(--shadow-color) / 0.14),
1.1px 1.1px 1.5px -0.9px hsl(var(--shadow-color) / 0.12),
2.4px 2.5px 3.3px -1.8px hsl(var(--shadow-color) / 0.1),
5.3px 5.6px 7.3px -2.7px hsl(var(--shadow-color) / 0.09),
11px 11.4px 15.1px -3.6px hsl(var(--shadow-color) / 0.07);
}
.image-rename-modal .modal {
width: 65%;
min-width: 600px;
}
.image-rename-modal .modal-content {
padding: 10px 5px;
}
.image-rename-modal .image-container {
display: flex;
justify-content: center;
}
.image-rename-modal .info {
padding: 10px 0;
color: var(--text-muted);
user-select: text;
}
.image-rename-modal .info li > span:nth-of-type(1) {
display: inline-block;
width: 6em;
margin-right: .5em;
}
.image-rename-modal .info li > span:nth-of-type(1):after {
content: ":";
float: right;
}
.image-rename-modal .image-container img {
display: block;
max-height: 300px;
box-shadow: var(--shadow-elevation-medium);
}
.image-rename-modal .setting-item-control input {
min-width: 300px;
}
.image-rename-modal .error {
border: 1px solid rgb(201, 90, 90);
color: rgb(134, 22, 22);
padding: 10px;
}
.image-rename-modal table {
font-size: .9em;
line-height: 1.8;
margin-bottom: 1.5em;
user-select: text;
}
.image-rename-modal table td {
padding-right: 1em;
}
.image-rename-modal table thead td {
font-weight: 700;
}
.image-rename-modal table tbody td .file-path {
font-size: .8em;
color: var(--text-faint);
line-height: 1;
}
.long-description-setting-item {
flex-wrap: wrap;
}
.long-description-setting-item .setting-item-description {
white-space: pre-wrap;
line-height: 1.3em;
}
.long-description-setting-item .setting-item-control {
padding-top: 10px;
}
.long-description-setting-item .setting-item-control input {
min-width: 300px;
width: 50%;
}
-214
View File
@@ -1,214 +0,0 @@
{
"main": {
"id": "16a573e2ddb25847",
"type": "split",
"children": [
{
"id": "ead2c6543d1842b7",
"type": "tabs",
"children": [
{
"id": "e32607a73f296aa4",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "src/content/posts/codex-warp.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "codex-warp"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "b3b818b3595a168f",
"type": "split",
"children": [
{
"id": "f871c6ee02fda945",
"type": "tabs",
"children": [
{
"id": "6719a883e9f34850",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "文件列表"
}
},
{
"id": "8f2d88709f3efdd0",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "搜索"
}
},
{
"id": "e4e31602ca6ef73f",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "书签"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "0e5ea46bbde4f663",
"type": "split",
"children": [
{
"id": "9a2e329e85417358",
"type": "tabs",
"children": [
{
"id": "cf180cd264a5895e",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "src/content/posts/codex-warp.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "codex-warp 的反向链接列表"
}
},
{
"id": "20df57f8cd76fbac",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "src/content/posts/codex-warp.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "codex-warp 的出链列表"
}
},
{
"id": "fc6075104c1b1a76",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "标签"
}
},
{
"id": "f9fd6fcf70c8f5c5",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "添加笔记属性"
}
},
{
"id": "41cb4f8448efc28f",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "src/content/posts/codex-warp.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "codex-warp 的大纲"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:打开快速切换": false,
"graph:查看关系图谱": false,
"canvas:新建白板": false,
"daily-notes:打开/创建今天的日记": false,
"templates:插入模板": false,
"command-palette:打开命令面板": false,
"bases:新建数据库": false
}
},
"active": "e32607a73f296aa4",
"lastOpenFiles": [
"public/pic/f1d08603a003c00e8664b4cb80b20358e9b9638bcedce5a307c91bb853b10eb7.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-4.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-3.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-2.png",
"public/pic/122.png",
"public/pic/codex-warp-2.png",
"public/pic/codex-warp.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc-1.png",
"public/pic/18489ae97bb1f883adb8a3b531fd820aa7e57140553a19291c8bf8d14be97afc.png",
"public/pic",
"src/content/assets/images",
"src/content/assets",
"dist/pagefind/index/zh-cn_6473869.pf_index",
"dist/pagefind/index/zh-cn_52f3f92.pf_index",
"dist/pagefind/fragment/zh-cn_f061267.pf_fragment",
"dist/pagefind/fragment/zh-cn_de70424.pf_fragment",
"dist/pagefind/fragment/zh-cn_ddd5448.pf_fragment",
"dist/pagefind/fragment/zh-cn_dd6dfac.pf_fragment",
"dist/pagefind/fragment/zh-cn_c5d7eb4.pf_fragment",
"src/content/posts/codex-warp.md",
"src/content/posts/new-domain.md",
"dist/pic/Pasted image 20260420023402.png.md",
"public/pic/Pasted image 20260420023402.png.md"
]
}
+1 -6
View File
@@ -24,10 +24,5 @@
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"markdown.validate.enabled": false,
"Codegeex.RepoIndex": true
}
}
-101
View File
@@ -1,101 +0,0 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
基于 [Fuwari](https://github.com/saicaca/fuwari) 的静态博客,使用 Astro 6.1 + TailwindCSS 3.4 + Svelte 5。
- **包管理器**: pnpm 9`preinstall` hook 强制只允许 pnpm
- **语言**: TypeScript (strict)
- **代码规范**: Biome(同时负责 format 和 lint),无 ESLint/Prettier
- **部署**: Cloudflare Pages(见 `wrangler.toml`
## 常用命令
| 命令 | 用途 |
|---|---|
| `pnpm dev` | 启动本地开发服务器 (`localhost:4321`) |
| `pnpm build` | 构建生产版本到 `./dist/` 并运行 Pagefind 索引 |
| `pnpm preview` | 本地预览构建产物 |
| `pnpm check` | 运行 Astro 类型检查 |
| `pnpm type-check` | 运行 TypeScript 类型检查 (`tsc --noEmit`) |
| `pnpm format` | Biome 格式化 `./src` |
| `pnpm lint` | Biome 检查和修复 `./src` |
| `pnpm new-post <filename>` | 在 `src/content/posts/` 创建新文章 Markdown |
| `pnpm astro` | 直接调用 Astro CLI |
## 项目结构
```
src/
├── config.ts # 站点主配置(标题、导航、个人资料、许可证等)
├── content.config.ts # Astro Content Collections 定义(posts + spec
├── content/
│ ├── posts/ # 博客文章(.md / .mdx),通过文件系统集合加载
│ └── spec/ # 特殊页面集合
├── components/
│ ├── widget/ # 侧边栏组件(Profile, TOC, Tags, Categories 等)
│ ├── control/ # 交互控件(BackToTop, Pagination, ButtonLink 等)
│ ├── misc/ # 杂项(ImageWrapper, License, Markdown
│ └── *.astro/*.svelte # 页面级组件(Navbar, Footer, Search, PostPage 等)
├── pages/
│ ├── [...page].astro # 首页分页路由
│ ├── posts/[...slug].astro # 文章详情页
│ ├── archive.astro # 归档页
│ ├── about.astro # 关于页
│ ├── friends.astro # 友链页
│ ├── rss.xml.ts # RSS 生成
│ └── robots.txt.ts # robots.txt
├── layouts/
│ ├── Layout.astro # 根布局(SEO、主题、Umami 分析、PhotoSwipe
│ └── MainGridLayout.astro # 主网格布局(导航栏、Banner、侧边栏、TOC)
├── plugins/ # Remark/Rehype 插件 + Expressive Code 插件
├── i18n/ # 多语言翻译(en, zh_CN, ja, ko 等)
├── utils/ # 工具函数(content-utils, date-utils, url-utils, setting-utils
├── types/ # TypeScript 类型定义
├── constants/ # 常量(分页大小、主题模式、Banner 高度等)
└── styles/ # CSS 文件(main.css, markdown.css, scrollbar.css 等)
```
## 关键架构信息
### 内容管理
- 文章以 Markdown/MDX 格式存储在 `src/content/posts/`
- 使用 Astro Content Collections 加载,schema 在 `content.config.ts` 中定义
- 文章 frontmatter 包含: `title`, `published`, `description`, `image`, `tags`, `category`, `draft`, `lang`, `showcover`, `customcover`
### 路由
- `[...page].astro` — 首页分页
- `posts/[...slug].astro` — 文章详情页,生成 JSON-LD 结构化数据
- `archive.astro` — 归档,tagscategories
- 自定义页面: `about.astro`, `friends.astro`
### 路径别名(tsconfig 配置)
- `@/*``src/*``@components/*``src/components/*``@assets/*``src/assets/*`
- `@utils/*``src/utils/*``@i18n/*``src/i18n/*`
- `@layouts/*``src/layouts/*``@constants/*``src/constants/*`
### 页面过渡
- 使用 Swup 实现 SPA 风格页面过渡
- 支持自定义滚动条 (`OverlayScrollbars`),图片点击放大 (`PhotoSwipe`)
### Markdown 扩展
- GitHub Admonitions (note, tip, important, caution, warning)
- GitHub 仓库卡片,数学公式 (KaTeX)
- 扩展代码块 (Expressive Code) 支持折叠、行号、复制按钮、语言徽章
- 自动标题锚点链接
### 外部服务
- **分析**: Umami(自建)+ Google Analytics
- **评论**: Giscus(基于 GitHub Discussions
- **搜索**: Pagefind(构建时生成搜索索引)
- **统计**: 通过 Umami API 获取文章浏览量
## 注意事项
- `src/config.ts` 是站点配置中心
- 构建会先运行 `astro build`,然后执行 `pagefind --site dist` 生成搜索索引
- Biome 不处理 CSS 文件(在 `biome.json` 中排除)
- Svelte 组件使用 Svelte 5 语法
- 主题色基于 CSS 变量 `--hue` 动态计算
-102
View File
@@ -1,102 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
基于 [Fuwari](https://github.com/saicaca/fuwari) 的静态博客,使用 Astro 6.1 + TailwindCSS 3.4 + Svelte 5。
- **包管理器**: pnpm 9`preinstall` hook 强制只允许 pnpm
- **语言**: TypeScript (strict)
- **代码规范**: Biome(同时负责 format 和 lint),无 ESLint/Prettier
- **部署**: Cloudflare Pages(见 `wrangler.toml`
## 常用命令
| 命令 | 用途 |
|---|---|
| `pnpm dev` | 启动本地开发服务器 (`localhost:4321`) |
| `pnpm build` | 构建生产版本到 `./dist/` 并运行 Pagefind 索引 |
| `pnpm preview` | 本地预览构建产物 |
| `pnpm check` | 运行 Astro 类型检查 |
| `pnpm type-check` | 运行 TypeScript 类型检查 (`tsc --noEmit`) |
| `pnpm format` | Biome 格式化 `./src` |
| `pnpm lint` | Biome 检查和修复 `./src` |
| `pnpm new-post <filename>` | 在 `src/content/posts/` 创建新文章 Markdown |
| `pnpm astro` | 直接调用 Astro CLI |
## 项目结构
```
src/
├── config.ts # 站点主配置(标题、导航、个人资料、许可证等)
├── content.config.ts # Astro Content Collections 定义(posts + spec
├── content/
│ ├── posts/ # 博客文章(.md / .mdx),通过文件系统集合加载
│ └── spec/ # 特殊页面集合
├── components/
│ ├── widget/ # 侧边栏组件(Profile, TOC, Tags, Categories 等)
│ ├── control/ # 交互控件(BackToTop, Pagination, ButtonLink 等)
│ ├── misc/ # 杂项(ImageWrapper, License, Markdown
│ └── *.astro/*.svelte # 页面级组件(Navbar, Footer, Search, PostPage 等)
├── pages/
│ ├── [...page].astro # 首页分页路由
│ ├── posts/[...slug].astro # 文章详情页
│ ├── archive.astro # 归档页
│ ├── about.astro # 关于页
│ ├── friends.astro # 友链页
│ ├── rss.xml.ts # RSS 生成
│ └── robots.txt.ts # robots.txt
├── layouts/
│ ├── Layout.astro # 根布局(SEO、主题、Umami 分析、PhotoSwipe
│ └── MainGridLayout.astro # 主网格布局(导航栏、Banner、侧边栏、TOC)
├── plugins/ # Remark/Rehype 插件 + Expressive Code 插件
├── i18n/ # 多语言翻译(en, zh_CN, ja, ko 等)
├── utils/ # 工具函数(content-utils, date-utils, url-utils, setting-utils
├── types/ # TypeScript 类型定义
├── constants/ # 常量(分页大小、主题模式、Banner 高度等)
└── styles/ # CSS 文件(main.css, markdown.css, scrollbar.css 等)
```
## 关键架构信息
### 内容管理
- 文章以 Markdown/MDX 格式存储在 `src/content/posts/`
- 使用 Astro Content Collections 加载,schema 在 `content.config.ts` 中定义
- 文章 frontmatter 包含: `title`, `published`, `description`, `image`, `tags`, `category`, `draft`, `lang`, `showcover`, `customcover`
### 路由
- `[...page].astro` — 首页分页
- `posts/[...slug].astro` — 文章详情页,生成 JSON-LD 结构化数据
- `archive.astro` — 归档,tagscategories
- 自定义页面: `about.astro`, `friends.astro`
### 路径别名(tsconfig 配置)
- `@/*``src/*``@components/*``src/components/*``@assets/*``src/assets/*`
- `@utils/*``src/utils/*``@i18n/*``src/i18n/*`
- `@layouts/*``src/layouts/*``@constants/*``src/constants/*`
### 页面过渡
- 使用 Swup 实现 SPA 风格页面过渡
- 支持自定义滚动条 (`OverlayScrollbars`),图片点击放大 (`PhotoSwipe`)
### Markdown 扩展
- GitHub Admonitions (note, tip, important, caution, warning)
- GitHub 仓库卡片,数学公式 (KaTeX)
- 扩展代码块 (Expressive Code) 支持折叠、行号、复制按钮、语言徽章
- 自动标题锚点链接
### 外部服务
- **分析**: Umami(自建)+ Google Analytics
- **评论**: Giscus(基于 GitHub Discussions
- **搜索**: Pagefind(构建时生成搜索索引)
- **统计**: 通过 Umami API 获取文章浏览量
## 注意事项
- `src/config.ts` 是站点配置中心
- 构建会先运行 `astro build`,然后执行 `pagefind --site dist` 生成搜索索引
- Biome 不处理 CSS 文件(在 `biome.json` 中排除)
- Svelte 组件使用 Svelte 5 语法
- 主题色基于 CSS 变量 `--hue` 动态计算
- 不要执行构建,除非特别要求
+3 -12
View File
@@ -21,14 +21,12 @@ import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.m
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs";
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js";
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js";
import { remarkPublicImagePaths } from "./src/plugins/remark-public-image-paths.mjs";
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs";
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button.js";
import rehypeExternalLinks from 'rehype-external-links';
// https://astro.build/config
export default defineConfig({
site: "https://blog.adclosenn.top",
site: "https://adclosenn.top",
base: "/",
trailingSlash: "always",
integrations: [
@@ -42,7 +40,7 @@ export default defineConfig({
// when the Tailwind class `transition-all` is used
containers: ["main", "#toc"],
smoothScrolling: true,
cache: process.env.NODE_ENV !== "development",
cache: true,
preload: true,
accessibility: true,
updateHead: true,
@@ -78,7 +76,7 @@ export default defineConfig({
borderRadius: "0.75rem",
borderColor: "none",
codeFontSize: "0.875rem",
codeFontFamily: "'Cascadia Mono', 'JetBrains Mono'",
codeFontFamily: "'JetBrains Mono Variable', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
codeLineHeight: "1.5rem",
frames: {
editorBackground: "var(--codeblock-bg)",
@@ -109,7 +107,6 @@ export default defineConfig({
remarkMath,
remarkReadingTime,
remarkExcerpt,
remarkPublicImagePaths,
remarkGithubAdmonitionsToDirectives,
remarkDirective,
remarkSectionize,
@@ -131,12 +128,6 @@ export default defineConfig({
},
},
],
[
rehypeExternalLinks,
{
target: '_blank',
},
],
[
rehypeAutolinkHeadings,
{
+29 -31
View File
@@ -6,50 +6,48 @@
"dev": "astro dev",
"start": "astro dev",
"check": "astro check",
"build": "astro build --verbose && node scripts/rewrite-built-image-links.js && pagefind --site dist",
"build": "astro build && pagefind --site dist",
"preview": "astro preview",
"astro": "astro",
"type-check": "tsc --noEmit --isolatedDeclarations",
"new-post": "node scripts/new-post.js",
"summary": "node scripts/summary.js",
"summary:all": "node scripts/summary.js --all",
"summary:force": "node scripts/summary.js --all --force",
"format": "biome format --write ./src",
"lint": "biome check --write ./src",
"preinstall": "npx only-allow pnpm"
},
"dependencies": {
"@astrojs/check": "^0.9.8",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/svelte": "8.0.5",
"@astrojs/check": "^0.9.4",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.2",
"@astrojs/svelte": "7.1.0",
"@astrojs/tailwind": "^6.0.2",
"@expressive-code/core": "^0.41.7",
"@expressive-code/plugin-collapsible-sections": "^0.41.7",
"@expressive-code/plugin-line-numbers": "^0.41.7",
"@expressive-code/core": "^0.41.3",
"@expressive-code/plugin-collapsible-sections": "^0.41.3",
"@expressive-code/plugin-line-numbers": "^0.41.3",
"@fontsource-variable/jetbrains-mono": "^5.2.6",
"@fontsource/roboto": "^5.2.6",
"@iconify-json/fa6-brands": "^1.2.6",
"@iconify-json/fa6-regular": "^1.2.4",
"@iconify-json/fa6-solid": "^1.2.4",
"@iconify-json/ic": "^1.2.4",
"@iconify-json/material-symbols": "^1.2.67",
"@iconify-json/ic": "^1.2.2",
"@iconify-json/material-symbols": "^1.2.30",
"@iconify/svelte": "^4.2.0",
"@swup/astro": "^1.8.0",
"@tailwindcss/typography": "^0.5.19",
"astro": "6.1.8",
"astro-expressive-code": "^0.41.7",
"@swup/astro": "^1.7.0",
"@tailwindcss/typography": "^0.5.16",
"astro": "5.12.8",
"astro-expressive-code": "^0.41.3",
"astro-icon": "^1.1.5",
"hastscript": "^9.0.1",
"katex": "^0.16.45",
"markdown-it": "^14.1.1",
"katex": "^0.16.22",
"markdown-it": "^14.1.0",
"mdast-util-to-string": "^4.0.0",
"node-html-parser": "^7.1.0",
"overlayscrollbars": "^2.15.1",
"pagefind": "^1.5.2",
"node-html-parser": "^7.0.1",
"overlayscrollbars": "^2.11.4",
"pagefind": "^1.3.0",
"photoswipe": "^5.4.4",
"reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0",
"rehype-components": "^0.3.0",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1",
"rehype-slug": "^6.0.0",
"remark-directive": "^3.0.1",
@@ -57,22 +55,22 @@
"remark-github-admonitions-to-directives": "^1.0.5",
"remark-math": "^6.0.0",
"remark-sectionize": "^2.1.0",
"sanitize-html": "^2.17.3",
"sharp": "^0.34.5",
"sanitize-html": "^2.17.0",
"sharp": "^0.34.3",
"stylus": "^0.64.0",
"svelte": "^5.55.4",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"unist-util-visit": "^5.1.0"
"svelte": "^5.37.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.7",
"@astrojs/ts-plugin": "^1.10.4",
"@biomejs/biome": "2.1.3",
"@rollup/plugin-yaml": "^4.1.2",
"@types/hast": "^3.0.4",
"@types/markdown-it": "^14.1.2",
"@types/mdast": "^4.0.4",
"@types/sanitize-html": "^2.16.1",
"@types/sanitize-html": "^2.16.0",
"postcss-import": "^16.1.1",
"postcss-nesting": "^13.0.2"
},
+3353 -2876
View File
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>404 未找到 - Ad_closeNN 的小站</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="5;url=/">
<meta id="theme-color-meta" name="theme-color" content="#48823b">
<link rel="icon" href="/assets/avatar.jpg">
<style>
body {
height: 100%;
width: 100%;
background: #fefefe center bottom fixed;
-webkit-background-size: cover;
-moz-background-size: cover;
-o-background-size: cover;
background-size: cover;
color: #2E2F30;
margin: 0;
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿, Times New Roman, sans-serif;
}
.dialog {
float: left;
text-align: left;
width: 50%;
margin: 2% auto 0;
padding-left: 10%;
}
h1 {
font-size: 5em;
color: #393939;
line-height: 1em;
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿, Times New Roman, sans-serif;
}
h2 {
font-size: 2em;
color: #393939;
line-height: .5em;
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿, Times New Roman, sans-serif;
}
span {
font-size: 1.4em;
color: #393939;
font-family: MiSans VF, MiSans, Inter, HarmonyOS Sans SC, 鸿, Times New Roman, sans-serif;
}
.link {
color: grey
}
</style>
</head>
<body>
<div>
<div class="dialog">
<h1>Woops!</h1>
<span>我们找不到您要访问的页面</span>
<span id="fullpath" class="link"></span>
<br>
<br>
<h2>将在 5 秒后跳转回首页</h2>
</div>
</div>
<script>
let fullpath = window.location.href
document.getElementById("fullpath").innerText = fullpath
</script>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

-2
View File
@@ -1,2 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16 8.016A8.522 8.522 0 008.016 16h-.032A8.521 8.521 0 000 8.016v-.032A8.521 8.521 0 007.984 0h.032A8.522 8.522 0 0016 7.984v.032z" fill="url(#prefix__paint0_radial_980_20147)"/><defs><radialGradient id="prefix__paint0_radial_980_20147" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(16.1326 5.4553 -43.70045 129.2322 1.588 6.503)"><stop offset=".067" stop-color="#9168C0"/><stop offset=".343" stop-color="#5684D1"/><stop offset=".672" stop-color="#1BA1E3"/></radialGradient></defs></svg>

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 703 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

-70
View File
@@ -1,70 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
const DIST_DIR = path.resolve("dist");
const REMOTE_RAW_ORIGIN = "https://cnb.cool/CLN-Grated/blog-fuwari/-/git/raw/main/public";
const TEXT_FILE_EXTENSIONS = new Set([".html", ".xml", ".json", ".js", ".css", ".txt"]);
const PIC_PATH_PATTERN = /(?:https:\/\/blog\.adclosenn\.top)?\/pic\/([^"'\s)<>]+)/g;
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return walk(fullPath);
}
return [fullPath];
}),
);
return files.flat();
}
async function rewriteFile(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (!TEXT_FILE_EXTENSIONS.has(ext)) {
return 0;
}
const original = await fs.readFile(filePath, "utf8");
let replacements = 0;
const rewritten = original.replace(PIC_PATH_PATTERN, (_match, rest) => {
replacements += 1;
return `${REMOTE_RAW_ORIGIN}/pic/${rest}`;
});
if (rewritten === original) {
return 0;
}
await fs.writeFile(filePath, rewritten, "utf8");
return replacements;
}
async function main() {
try {
await fs.access(DIST_DIR);
} catch {
console.error(`[rewrite-built-image-links] dist directory not found: ${DIST_DIR}`);
process.exit(1);
}
const files = await walk(DIST_DIR);
let touchedFiles = 0;
let totalReplacements = 0;
for (const filePath of files) {
const replacements = await rewriteFile(filePath);
if (replacements > 0) {
touchedFiles += 1;
totalReplacements += replacements;
}
}
console.log(
`[rewrite-built-image-links] Replaced ${totalReplacements} link(s) in ${touchedFiles} file(s).`,
);
}
await main();
-459
View File
@@ -1,459 +0,0 @@
/* This is a script to generate AI summary for a post */
import fs from "fs"
import path from "path"
import https from "https"
import readline from "readline"
const targetDir = "./src/content/posts/"
const summaryModel = "deepseek-v4-flash-free"
const batchDelayMs = 1500
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
function parseAiSummaryValue(value) {
const trimmed = value.trim()
if (!trimmed) return null
if (trimmed.startsWith('"')) {
try {
return JSON.parse(trimmed)
} catch {
return trimmed
}
}
return trimmed
}
function extractAiSummary(frontmatter) {
const summaryMatch = frontmatter.match(/^aiSummary:[ \t]*(.*)$/m)
if (!summaryMatch) return null
const inlineValue = summaryMatch[1].trim()
if (inlineValue !== ">" && inlineValue !== "|") {
return parseAiSummaryValue(inlineValue)
}
const afterSummary = frontmatter.slice(summaryMatch.index + summaryMatch[0].length)
const lines = afterSummary.split(/\r?\n/)
const blockLines = []
for (const line of lines) {
if (!line.startsWith(" ") && line.trim()) break
blockLines.push(line.replace(/^ {1,2}/, ""))
}
return blockLines.join("\n").trim() || null
}
function formatFrontmatterField(key, value) {
return `${key}: ${JSON.stringify(value.replace(/\r?\n/g, " "))}`
}
function upsertFrontmatterField(frontmatter, key, value) {
const fieldPattern = new RegExp(`^${key}:[ \\t]*(?:.*(?:\\r?\\n[ \\t].*)*)`, "m")
const formattedField = formatFrontmatterField(key, value)
return fieldPattern.test(frontmatter)
? frontmatter.replace(fieldPattern, formattedField)
: `${frontmatter.trimEnd()}\n${formattedField}`
}
function stripAdmonitionMarkers(text) {
return text
.replace(/^:::(note|tip|important|caution|warning)(?:\[[^\]]*\])?\s*$/gim, "")
.replace(/^:::\s*$/gm, "")
.replace(/^>\s*\[!(note|tip|important|caution|warning)\]\s*$/gim, "")
.replace(/^\s*\[!(note|tip|important|caution|warning)\]\s*/gim, "")
}
function cleanGeneratedSummary(summary) {
return stripAdmonitionMarkers(summary)
.replace(/^(note|tip|important|caution|warning|警告|注意|提示)[:\s-]+/i, "")
.replace(/\s+/g, " ")
.trim()
}
function getPostFiles() {
const files = fs.readdirSync(targetDir)
return files.filter(f => f.endsWith(".md") || f.endsWith(".mdx"))
}
function getCurrentAiSummary(fileName) {
const fullPath = path.join(targetDir, fileName)
const content = fs.readFileSync(fullPath, "utf-8")
const match = content.match(frontmatterRegex)
if (match) {
const frontmatter = match[1]
const summary = extractAiSummary(frontmatter)
if (summary) {
return summary
}
}
return null
}
function selectFile(files) {
return new Promise((resolve) => {
const rl = createInterface()
const summaries = new Map(files.map(file => [file, getCurrentAiSummary(file)]))
const wasRaw = process.stdin.isRaw
let selectedIndex = 0
let closed = false
readline.emitKeypressEvents(process.stdin, rl)
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
const cleanup = () => {
if (closed) return
closed = true
process.stdin.removeListener("keypress", onKey)
if (process.stdin.isTTY) {
process.stdin.setRawMode(Boolean(wasRaw))
}
process.stdout.write("\x1b[?25h")
rl.close()
}
const draw = () => {
const rows = process.stdout.rows || 24
const listHeight = Math.max(5, rows - 8)
const start = Math.min(
Math.max(0, selectedIndex - Math.floor(listHeight / 2)),
Math.max(0, files.length - listHeight),
)
const visibleFiles = files.slice(start, start + listHeight)
let output = "\x1b[?25l\x1b[2J\x1b[H"
output += "选择文章(↑/↓ 或 j/k 移动,Enter 确认,q/Esc 取消)\n\n"
visibleFiles.forEach((file, offset) => {
const index = start + offset
const currentSummary = summaries.get(file)
const prefix = index === selectedIndex ? "" : " "
const hasSummary = currentSummary ? " 已有摘要" : ""
output += `${prefix} ${file}${hasSummary}\n`
})
const currentFile = files[selectedIndex]
const currentSummary = summaries.get(currentFile)
output += `\n${selectedIndex + 1}/${files.length} ${currentFile}\n`
if (currentSummary) {
output += `当前摘要:${currentSummary}\n`
}
process.stdout.write(output)
}
const cancel = () => {
cleanup()
process.stdout.write("已取消\n")
process.exit(0)
}
const onKey = (_str, key = {}) => {
if (key.ctrl && key.name === "c") {
cancel()
}
if (key.name === "up" || key.name === "k") {
selectedIndex = Math.max(0, selectedIndex - 1)
draw()
return
}
if (key.name === "down" || key.name === "j") {
selectedIndex = Math.min(files.length - 1, selectedIndex + 1)
draw()
return
}
if (key.name === "return") {
const selectedFile = files[selectedIndex]
cleanup()
resolve(selectedFile)
return
}
if (key.name === "escape" || key.name === "q") {
cancel()
}
}
process.stdin.on("keypress", onKey)
draw()
})
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function getRetryAfterMs(value) {
if (!value) return null
const seconds = Number(value)
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000)
}
const timestamp = Date.parse(value)
if (Number.isFinite(timestamp)) {
return Math.max(0, timestamp - Date.now())
}
return null
}
function getRetryDelayMs(error, attempt) {
const retryAfterMs = getRetryAfterMs(error.retryAfter)
if (retryAfterMs !== null) {
return Math.min(retryAfterMs, 120000)
}
const exponentialDelay = 3000 * 2 ** (attempt - 1)
const jitter = Math.floor(Math.random() * 1000)
return Math.min(exponentialDelay + jitter, 120000)
}
function shouldRetry(error) {
return !error.statusCode || error.statusCode === 429 || error.statusCode >= 500
}
function requestSummary(options, requestBody) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = ""
res.on("data", (chunk) => {
data += chunk
})
res.on("end", () => {
if (res.statusCode !== 200) {
const error = new Error(`HTTP ${res.statusCode}`)
error.statusCode = res.statusCode
error.retryAfter = res.headers["retry-after"]
error.responseBody = data.slice(0, 500)
reject(error)
return
}
try {
const response = JSON.parse(data)
const output = response.output
if (!output || !Array.isArray(output)) {
reject(new Error("Failed to generate summary"))
return
}
const messageOutput = output.find(o => o.type === "message")
if (!messageOutput || !messageOutput.content) {
reject(new Error("No message output found"))
return
}
const textBlock = messageOutput.content.find((block) => block.type === "output_text")
if (!textBlock) {
reject(new Error("No output_text block found"))
return
}
resolve(textBlock.text.trim())
} catch (error) {
reject(error)
}
})
})
req.on("error", (error) => {
reject(error)
})
req.write(requestBody)
req.end()
})
}
async function generateSummary(fileName) {
const fullPath = path.join(targetDir, fileName)
const fileContent = fs.readFileSync(fullPath, "utf-8")
const frontmatterMatch = fileContent.match(frontmatterRegex)
const bodyOriginal = frontmatterMatch
? fileContent.slice(frontmatterMatch[0].length).trimStart()
: fileContent;
const bodyForAI = stripAdmonitionMarkers(bodyOriginal)
const eol = fileContent.includes("\r\n") ? "\r\n" : "\n"
console.log("\n生成 AI 摘要中...\n")
const apiKey = "public"
const apiUrl = "opencode.ai/zen"
const prompt = `请为以下博客文章生成一个不超过100字的中文摘要。
输出要求:
- 只输出摘要正文,不要解释,不要加标题。
- 摘要必须以“本文介绍了”开头。
- 使用一句话概括文章主题、关键内容和用途。
- 不要输出 Markdown 语法、admonition 标记或提示框类型,例如 :::warning、:::caution、[!warning]、警告、注意。
文章内容:
${bodyForAI}`
const requestBody = JSON.stringify({
model: summaryModel,
input: [
{
role: "user",
content: [{ type: "input_text", text: prompt }]
}
],
max_output_tokens: 500,
stream: false,
reasoning: { effort: "minimal" }
})
const url = new URL(`https://${apiUrl}/v1/responses`)
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`,
"anthropic-version": "2023-06-01"
}
}
const maxAttempts = 6
let summary = ""
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
summary = cleanGeneratedSummary(await requestSummary(options, requestBody))
break
} catch (error) {
if (!shouldRetry(error) || attempt === maxAttempts) {
if (error.responseBody) {
console.error("Error response:", error.responseBody)
}
throw error
}
const delayMs = getRetryDelayMs(error, attempt)
console.warn(`请求失败:${error.message}${Math.ceil(delayMs / 1000)} 秒后重试(${attempt}/${maxAttempts - 1}`)
await sleep(delayMs)
}
}
let newContent
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1]
const newFrontmatter = upsertFrontmatterField(
upsertFrontmatterField(frontmatter, "aiSummary", summary),
"aiSummaryModel",
summaryModel,
)
newContent = `---${eol}${newFrontmatter}${eol}---${eol}${bodyOriginal}`
} else {
newContent = `---${eol}${formatFrontmatterField("aiSummary", summary)}${eol}${formatFrontmatterField("aiSummaryModel", summaryModel)}${eol}---${eol}${bodyOriginal}`
}
fs.writeFileSync(fullPath, newContent)
return summary
}
function createInterface() {
return readline.createInterface({
input: process.stdin,
output: process.stdout
})
}
async function generateMissingSummaries(files, { force = false } = {}) {
const pendingFiles = force ? files : files.filter(file => !getCurrentAiSummary(file))
if (pendingFiles.length === 0) {
console.log("所有文章都已有 AI 摘要")
return
}
if (force) {
console.log(`将强制为 ${pendingFiles.length} 篇文章重新生成 AI 摘要。\n`)
} else {
console.log(`将为 ${pendingFiles.length} 篇文章生成 AI 摘要,跳过 ${files.length - pendingFiles.length} 篇已有摘要的文章。\n`)
}
const failedFiles = []
for (const [index, file] of pendingFiles.entries()) {
console.log(`[${index + 1}/${pendingFiles.length}] ${file}`)
try {
const summary = await generateSummary(file)
console.log(`完成:${summary}\n`)
if (index < pendingFiles.length - 1) {
await sleep(batchDelayMs)
}
} catch (err) {
failedFiles.push({ file, message: err.message })
console.error(`失败:${file} - ${err.message}\n`)
}
}
if (failedFiles.length > 0) {
console.error("以下文章生成失败:")
failedFiles.forEach(({ file, message }) => {
console.error(`- ${file}: ${message}`)
})
process.exitCode = 1
}
}
async function main() {
const files = getPostFiles()
const args = process.argv.slice(2)
const force = args.includes("--force")
const all = args.includes("--all")
const positionalArgs = args.filter(arg => !arg.startsWith("--"))
if (files.length === 0) {
console.log("没有找到任何文章文件")
process.exit(1)
}
if (all) {
await generateMissingSummaries(files, { force })
} else if (positionalArgs.length > 0) {
const fileName = positionalArgs[0]
let targetFile = fileName
const fileExtensionRegex = /\.md(x)?$/i
if (!fileExtensionRegex.test(targetFile)) {
targetFile += ".md"
}
if (!files.includes(targetFile)) {
console.error(`Error: File ${targetFile} does not exist`)
process.exit(1)
}
try {
const summary = await generateSummary(targetFile)
console.log(`AI 摘要已生成: \n${summary}`)
} catch (err) {
console.error(`生成失败: ${err.message}`)
process.exit(1)
}
} else {
const selectedFile = await selectFile(files)
console.log(`已选择: ${selectedFile}\n`)
try {
const summary = await generateSummary(selectedFile)
console.log(`AI 摘要已生成: ${summary}`)
} catch (err) {
console.error(`生成失败: ${err.message}`)
process.exit(1)
}
}
}
main().catch((err) => {
console.error("Error:", err.message)
process.exit(1)
})
+3 -9
View File
@@ -19,9 +19,8 @@ interface Post {
data: {
title: string;
tags: string[];
category?: string | null;
category?: string;
published: Date;
pinned?: boolean;
};
}
@@ -132,14 +131,9 @@ onMount(async () => {
<div
class="w-[70%] md:max-w-[65%] md:w-[65%] text-left font-bold
group-hover:translate-x-1 transition-all group-hover:text-[var(--primary)]
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden flex items-center gap-2"
text-75 pr-8 whitespace-nowrap overflow-ellipsis overflow-hidden"
>
{#if post.data.pinned}
<span class="inline-flex shrink-0 items-center gap-1 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-2 py-0.5 text-[11px] font-semibold text-[var(--primary)] leading-none">
置顶
</span>
{/if}
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{post.data.title}</span>
{post.data.title}
</div>
<!-- tag list -->
+12 -3
View File
@@ -1,7 +1,16 @@
---
import { siteConfig } from "../config";
---
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}>
</div>
// 周日判断
const isSunday = true;
// 这tm重置色
---
{isSunday &&
<div id="config-carrier" data-hue={290}></div>
}
{!isSunday &&
<div id="config-carrier" data-hue={siteConfig.themeColor.hue}></div>
}
+1 -15
View File
@@ -11,11 +11,9 @@ const currentYear = new Date().getFullYear();
<!--<div class="transition bg-[oklch(92%_0.01_var(&#45;&#45;hue))] dark:bg-black rounded-2xl py-8 mt-4 mb-8 flex flex-col items-center justify-center px-6">-->
<div class="transition border-dashed border-[oklch(85%_0.01_var(--hue))] dark:border-white/15 rounded-2xl mb-12 flex flex-col items-center justify-center px-6">
<div class="transition text-50 text-sm text-center">
<a href="https://icp.redcha.cn/beian/ICP-2025080144.html" title="茶ICP备2025080144号" target="_blank">茶ICP备2025080144号</a>
<br>
<a href="https://icp.gov.moe/?keyword=20256087" target="_blank">萌ICP备20256087号</a>
<br>
&copy; <span id="copyright-year">2025-present</span> <span><a class="transition link text-[var(--primary)] font-medium" href="https://github.com/Ad-closeNN" target="blank">{profileConfig.name}</a></span>. 博客代码 <a href="https://github.com/Ad-closeNN/blog-fuwari" class="transition link text-[var(--primary)] font-medium" target="_blank">已开源</a>
&copy; <span id="copyright-year">{currentYear}</span> <span><a class="transition link text-[var(--primary)] font-medium" href="https://github.com/Ad-closeNN" target="blank">{profileConfig.name}</a></span>. All Rights Reserved.
<br>
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('rss.xml')}>RSS</a> /
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href={url('sitemap-index.xml')}>Sitemap</a><br>
@@ -23,17 +21,5 @@ const currentYear = new Date().getFullYear();
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://astro.build">Astro</a> 和
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://github.com/saicaca/fuwari">Fuwari</a>
强力驱动
<br>
<a class="transition link text-[var(--primary)] font-medium" target="_blank" href="https://www.cloudflare.com">Cloudflare</a>
构建并部署至全球 Cloudflare CDN 节点
<br>
</div>
<img
src="https://www.cloudflare.com/img/logo-cloudflare-dark.svg"
alt="Cloudflare"
draggable="false"
ondragstart="return false;"
style="height: 30px; user-select: none; -webkit-user-drag: none;"
>
</div>
+89
View File
@@ -0,0 +1,89 @@
<script lang="ts">
import { AUTO_MODE, DARK_MODE, LIGHT_MODE } from "@constants/constants.ts";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import {
applyThemeToDocument,
getStoredTheme,
setTheme,
} from "@utils/setting-utils.ts";
import { onMount } from "svelte";
import type { LIGHT_DARK_MODE } from "@/types/config.ts";
const seq: LIGHT_DARK_MODE[] = [LIGHT_MODE, DARK_MODE];
let mode: LIGHT_DARK_MODE = LIGHT_MODE;
onMount(() => {
mode = getStoredTheme();
const darkModePreference = window.matchMedia("(prefers-color-scheme: dark)");
const changeThemeWhenSchemeChanged: Parameters<
typeof darkModePreference.addEventListener<"change">
>[1] = (_e) => {
applyThemeToDocument(mode);
};
darkModePreference.addEventListener("change", changeThemeWhenSchemeChanged);
return () => {
darkModePreference.removeEventListener(
"change",
changeThemeWhenSchemeChanged,
);
};
});
function switchScheme(newMode: LIGHT_DARK_MODE) {
mode = newMode;
setTheme(newMode);
}
function toggleScheme() {
let i = 0;
for (; i < seq.length; i++) {
if (seq[i] === mode) {
break;
}
}
switchScheme(seq[(i + 1) % seq.length]);
}
function showPanel() {
const panel = document.querySelector("#light-dark-panel");
panel.classList.remove("float-panel-closed");
}
function hidePanel() {
const panel = document.querySelector("#light-dark-panel");
panel.classList.add("float-panel-closed");
}
</script>
<!-- z-50 make the panel higher than other float panels -->
<div class="relative z-50" role="menu" tabindex="-1" onmouseleave={hidePanel}>
<button aria-label="Light/Dark Mode" role="menuitem" class="relative btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="scheme-switch" onclick={toggleScheme} onmouseenter={showPanel}>
<div class="absolute" class:opacity-0={mode !== LIGHT_MODE}>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
<div class="absolute" class:opacity-0={mode !== DARK_MODE}>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem]"></Icon>
</div>
</button>
<div id="light-dark-panel" class="hidden lg:block absolute transition float-panel-closed top-11 -right-2 pt-5" >
<div class="card-base float-panel p-2">
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
class:current-theme-btn={mode === LIGHT_MODE}
onclick={() => switchScheme(LIGHT_MODE)}
>
<Icon icon="material-symbols:wb-sunny-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.lightMode)}
</button>
<button class="flex transition whitespace-nowrap items-center !justify-start w-full btn-plain scale-animation rounded-lg h-9 px-3 font-medium active:scale-95 mb-0.5"
class:current-theme-btn={mode === DARK_MODE}
onclick={() => switchScheme(DARK_MODE)}
>
<Icon icon="material-symbols:dark-mode-outline-rounded" class="text-[1.25rem] mr-3"></Icon>
{i18n(I18nKey.darkMode)}
</button>
</div>
</div>
</div>
+40 -19
View File
@@ -4,9 +4,10 @@ import { navBarConfig, siteConfig } from "../config";
import { LinkPresets } from "../constants/link-presets";
import { LinkPreset, type NavBarLink } from "../types/config";
import { url } from "../utils/url-utils";
import LightDarkSwitch from "./LightDarkSwitch.svelte";
import Search from "./Search.svelte";
import DisplaySettings from "./widget/DisplaySettings.svelte";
import NavMenuPanel from "./widget/NavMenuPanel.astro";
import ThemeSettingsBlock from "./widget/ThemeSettingsBlock.svelte";
const className = Astro.props.class;
@@ -23,11 +24,10 @@ let links: NavBarLink[] = navBarConfig.links.map(
<div class="absolute h-8 left-0 right-0 -top-8 bg-[var(--card-bg)] transition"></div> <!-- used for onload animation -->
<div class:list={[
className,
"navbar-blur card-base relative isolate !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4 !bg-white/45 dark:!bg-slate-900/35 backdrop-blur-xl backdrop-saturate-150 border-x border-b border-white/35 dark:border-white/10 shadow-[0_8px_24px_rgba(15,23,42,0.08)] dark:shadow-[0_8px_24px_rgba(2,6,23,0.22)]"]}>
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/55 dark:bg-white/12"></div>
"card-base !overflow-visible max-w-[var(--page-width)] h-[4.5rem] !rounded-t-none mx-auto flex items-center justify-between px-4"]}>
<a href={url('/')} class="btn-plain scale-animation rounded-lg h-[3.25rem] px-5 font-bold active:scale-95">
<div class="flex flex-row text-[var(--primary)] items-center text-md">
<Icon is:inline name="material-symbols:home-outline-rounded" class="text-[1.75rem] mb-1 mr-2" />
<Icon name="material-symbols:home-outline-rounded" class="text-[1.75rem] mb-1 mr-2" />
{siteConfig.title}
</div>
</a>
@@ -38,7 +38,7 @@ let links: NavBarLink[] = navBarConfig.links.map(
>
<div class="flex items-center">
{l.name}
{l.external && <Icon is:inline name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
{l.external && <Icon name="fa6-solid:arrow-up-right-from-square" class="text-[0.875rem] transition -translate-y-[1px] ml-1 text-black/[0.2] dark:text-white/[0.2]"></Icon>}
</div>
</a>;
})}
@@ -46,17 +46,50 @@ let links: NavBarLink[] = navBarConfig.links.map(
<div class="flex">
<!--<SearchPanel client:load>-->
<Search client:only="svelte"></Search>
<ThemeSettingsBlock showThemeColor={!siteConfig.themeColor.fixed} client:only="svelte"></ThemeSettingsBlock>
{!siteConfig.themeColor.fixed && (
<button aria-label="Display Settings" class="btn-plain scale-animation rounded-lg h-11 w-11 active:scale-90" id="display-settings-switch">
<Icon name="material-symbols:palette-outline" class="text-[1.25rem]"></Icon>
</button>
)}
<LightDarkSwitch client:only="svelte"></LightDarkSwitch>
<button aria-label="Menu" name="Nav Menu" class="btn-plain scale-animation rounded-lg w-11 h-11 active:scale-90 md:!hidden" id="nav-menu-switch">
<Icon is:inline name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
<Icon name="material-symbols:menu-rounded" class="text-[1.25rem]"></Icon>
</button>
</div>
<NavMenuPanel links={links}></NavMenuPanel>
<DisplaySettings client:only="svelte"></DisplaySettings>
</div>
</div>
<script>
function switchTheme() {
if (localStorage.theme === 'dark') {
document.documentElement.classList.remove('dark');
localStorage.theme = 'light';
} else {
document.documentElement.classList.add('dark');
localStorage.theme = 'dark';
}
}
function loadButtonScript() {
let switchBtn = document.getElementById("scheme-switch");
if (switchBtn) {
switchBtn.onclick = function () {
switchTheme()
};
}
let settingBtn = document.getElementById("display-settings-switch");
if (settingBtn) {
settingBtn.onclick = function () {
let settingPanel = document.getElementById("display-setting");
if (settingPanel) {
settingPanel.classList.toggle("float-panel-closed");
}
};
}
let menuBtn = document.getElementById("nav-menu-switch");
if (menuBtn) {
menuBtn.onclick = function () {
@@ -71,18 +104,6 @@ function loadButtonScript() {
loadButtonScript();
</script>
<style>
.navbar-blur {
background-color: color-mix(in oklab, var(--card-bg) 58%, transparent);
backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%);
}
:global(.dark) .navbar-blur {
background-color: color-mix(in oklab, var(--card-bg) 44%, transparent);
}
</style>
{import.meta.env.PROD && <script is:inline define:vars={{scriptUrl: url('/pagefind/pagefind.js')}}>
async function loadPagefind() {
try {
+50 -52
View File
@@ -1,6 +1,5 @@
---
import type { CollectionEntry } from "astro:content";
import { render } from "astro:content";
import path from "node:path";
import { Icon } from "astro-icon/components";
import I18nKey from "../i18n/i18nKey";
@@ -8,6 +7,7 @@ import { i18n } from "../i18n/translation";
import { getDir } from "../utils/url-utils";
import ImageWrapper from "./misc/ImageWrapper.astro";
import PostMetadata from "./PostMeta.astro";
import { umamiConfig } from "../config";
interface Props {
class?: string;
@@ -21,7 +21,7 @@ interface Props {
image: string;
description: string;
draft: boolean;
style?: string;
style: string;
}
const {
entry,
@@ -33,19 +33,17 @@ const {
category,
image,
description,
style = "",
style,
} = Astro.props;
const className = Astro.props.class;
const hasCover = image !== undefined && image !== null && image !== "";
const coverWidth = "28%";
const isPinned = entry.data.pinned;
const isOutdated = entry.data.outdated;
const { remarkPluginFrontmatter } = await render(entry);
const { remarkPluginFrontmatter } = await entry.render();
---
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative border border-black/20 dark:border-white/20 hover:scale-[1.02] hover:shadow-xl transition-all duration-[300ms]", className]} style={style}>
<div class:list={["card-base flex flex-col-reverse md:flex-col w-full rounded-[var(--radius-large)] overflow-hidden relative", className]} style={style}>
<div class:list={["pl-6 md:pl-9 pr-6 md:pr-2 pt-6 md:pt-7 pb-6 relative", {"w-full md:w-[calc(100%_-_52px_-_12px)]": !hasCover, "w-full md:w-[calc(100%_-_var(--coverWidth)_-_12px)]": hasCover}]}>
<a href={url}
class="transition group w-full block font-bold mb-3 text-3xl text-90
@@ -54,21 +52,9 @@ const { remarkPluginFrontmatter } = await render(entry);
before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[35px] before:left-[18px] before:hidden md:before:block
">
<span class="flex flex-wrap items-center gap-x-3 gap-y-2">
{isPinned && (
<span class="inline-flex items-center gap-1 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-2.5 py-1 text-xs font-semibold text-[var(--primary)] leading-none">
<Icon is:inline name="material-symbols:keep-rounded" class="text-lg"></Icon>
置顶
</span>
)}
<span class="min-w-0">
{title}
<span class="inline-flex items-center align-middle whitespace-nowrap">
<Icon is:inline class="inline text-[2rem] text-[var(--primary)] translate-y-0.5 md:hidden" name="material-symbols:chevron-right-rounded" ></Icon>
<Icon is:inline class="text-[var(--primary)] text-[2rem] transition hidden md:inline translate-y-0.5 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
</span>
</span>
</span>
{title}
<Icon class="inline text-[2rem] text-[var(--primary)] md:hidden translate-y-0.5 absolute" name="material-symbols:chevron-right-rounded" ></Icon>
<Icon class="text-[var(--primary)] text-[2rem] transition hidden md:inline absolute translate-y-0.5 opacity-0 group-hover:opacity-100 -translate-x-1 group-hover:translate-x-0" name="material-symbols:chevron-right-rounded"></Icon>
</a>
<!-- metadata -->
@@ -81,18 +67,13 @@ const { remarkPluginFrontmatter } = await render(entry);
<!-- word count, read time and page views https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/PostCard.astro -->
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition flex-wrap items-center">
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
<div>{remarkPluginFrontmatter.words} {" " + i18n(I18nKey.wordsCount)}</div>
<div>|</div>
<div>{remarkPluginFrontmatter.minutes} {" " + i18n(I18nKey.minutesCount)}</div>
<div>|</div>
<div class="flex items-center gap-2">
<span class="text-50 text-sm font-medium" id={`page-views-${entry.id}`}>加载中...</span>
{isOutdated && (
<span class="inline-flex items-center rounded-full border border-red-500/20 bg-red-500/8 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-300">
已失效
</span>
)}
<div>
<span class="text-50 text-sm font-medium" id={`page-views-${entry.slug}`}>加载中...</span>
</div>
</div>
@@ -105,12 +86,12 @@ const { remarkPluginFrontmatter } = await render(entry);
]} >
<div class="absolute pointer-events-none z-10 w-full h-full group-hover:bg-black/30 group-active:bg-black/50 transition"></div>
<div class="absolute pointer-events-none z-20 w-full h-full flex items-center justify-center ">
<Icon is:inline name="material-symbols:chevron-right-rounded"
<Icon name="material-symbols:chevron-right-rounded"
class="transition opacity-0 group-hover:opacity-100 scale-50 group-hover:scale-100 text-white text-5xl">
</Icon>
</div>
<ImageWrapper src={image} basePath={path.join("content/posts/", getDir(entry.id))} alt="Cover Image of the Post"
class="w-full h-full" loader>
class="w-full h-full">
</ImageWrapper>
</a>}
@@ -119,7 +100,7 @@ const { remarkPluginFrontmatter } = await render(entry);
absolute right-3 top-3 bottom-3 rounded-xl bg-[var(--enter-btn-bg)]
hover:bg-[var(--enter-btn-bg-hover)] active:bg-[var(--enter-btn-bg-active)] active:scale-95
">
<Icon is:inline name="material-symbols:chevron-right-rounded"
<Icon name="material-symbols:chevron-right-rounded"
class="transition text-[var(--primary)] text-4xl mx-auto">
</Icon>
</a>
@@ -130,39 +111,56 @@ const { remarkPluginFrontmatter } = await render(entry);
<!-- https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/PostCard.astro -->
<script define:vars={{ slug: entry.id }}>
<script define:vars={{ entry, umamiConfig }}>
// 获取文章浏览量统计
async function fetchPostCardViews(slug) {
const displayElement = document.getElementById(`page-views-${slug}`);
if (!displayElement || displayElement.dataset.umamiState === 'loading' || displayElement.dataset.umamiState === 'loaded') {
if (!umamiConfig.enable) {
return;
}
displayElement.dataset.umamiState = 'loading';
try {
const umamiStore = window['__blogUmami'];
const statsData = await umamiStore?.getStats(`post:${slug}`, (websiteId) => {
const currentTimestamp = Date.now();
return `https://umami.adclosenn.top/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent('Asia/Shanghai')}&url=%2Fposts%2F${slug}%2F&compare=false`;
});
if (!statsData) {
throw new Error('统计功能未启用');
// 第一步:获取网站ID和token
const shareResponse = await fetch(`${umamiConfig.baseUrl}/api/share/${umamiConfig.shareId}`);
if (!shareResponse.ok) {
throw new Error('获取分享信息失败');
}
const shareData = await shareResponse.json();
const { websiteId, token } = shareData;
// 第二步:获取统计数据
const currentTimestamp = Date.now();
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
const statsResponse = await fetch(statsUrl, {
headers: {
'x-umami-share-token': token
}
});
if (!statsResponse.ok) {
throw new Error('获取统计数据失败');
}
const statsData = await statsResponse.json();
const pageViews = statsData.pageviews?.value || 0;
displayElement.textContent = `浏览量 ${pageViews}`;
displayElement.dataset.umamiState = 'loaded';
// const visits = statsData.visits?.value || 0;
const displayElement = document.getElementById(`page-views-${slug}`);
if (displayElement) {
displayElement.textContent = `浏览量 ${pageViews}`;
}
} catch (error) {
console.error('Error fetching page views for', slug, ':', error);
displayElement.textContent = '统计不可用';
displayElement.dataset.umamiState = 'error';
const displayElement = document.getElementById(`page-views-${slug}`);
if (displayElement) {
displayElement.textContent = '统计不可用';
}
}
}
// 页面加载完成后获取统计数据
function initPostCardStats() {
const slug = entry.slug;
if (slug) {
fetchPostCardViews(slug);
}
+44 -29
View File
@@ -4,7 +4,8 @@ import { Icon } from "astro-icon/components";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
import { getCategoryUrl, getDir, getTagUrl, url } from "../utils/url-utils";
import { getCategoryUrl, getTagUrl, getDir, url } from "../utils/url-utils";
import { umamiConfig } from "../config";
interface Props {
class: string;
@@ -14,7 +15,7 @@ interface Props {
category: string | null;
hideTagsForMobile?: boolean;
hideUpdateDate?: boolean;
slug?: string;
slug?: string;
}
const {
published,
@@ -23,7 +24,7 @@ const {
category,
hideTagsForMobile = false,
hideUpdateDate = false,
slug,
slug,
} = Astro.props;
const className = Astro.props.class;
---
@@ -33,7 +34,7 @@ const className = Astro.props.class;
<div class="flex items-center">
<div class="meta-icon"
>
<Icon is:inline name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
<Icon name="material-symbols:calendar-today-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(published)}</span>
</div>
@@ -43,7 +44,7 @@ const className = Astro.props.class;
<div class="flex items-center">
<div class="meta-icon"
>
<Icon is:inline name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
<Icon name="material-symbols:edit-calendar-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium">{formatDateToYYYYMMDD(updated)}</span>
</div>
@@ -54,7 +55,7 @@ const className = Astro.props.class;
<div class="flex items-center">
<div class="meta-icon"
>
<Icon is:inline name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
<Icon name="material-symbols:book-2-outline-rounded" class="text-xl"></Icon>
</div>
<div class="flex flex-row flex-nowrap items-center">
<a href={getCategoryUrl(category)} aria-label={`View all posts in the ${category} category`}
@@ -69,7 +70,7 @@ const className = Astro.props.class;
<div class:list={["items-center", {"flex": !hideTagsForMobile, "hidden md:flex": hideTagsForMobile}]}>
<div class="meta-icon"
>
<Icon is:inline name="material-symbols:tag-rounded" class="text-xl"></Icon>
<Icon name="material-symbols:tag-rounded" class="text-xl"></Icon>
</div>
<div class="flex flex-row flex-nowrap items-center">
{(tags && tags.length > 0) && tags.map((tag, i) => (
@@ -87,7 +88,7 @@ const className = Astro.props.class;
{slug && (
<div class="flex items-center">
<div class="meta-icon">
<Icon is:inline name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium" id="page-views-display">加载中...</span>
</div>
@@ -97,36 +98,50 @@ const className = Astro.props.class;
<!-- https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/PostMeta.astro -->
{slug && (
<script define:vars={{ slug }}>
<script define:vars={{ slug, umamiConfig }}>
// 获取访问量统计
async function fetchPageViews() {
const displayElement = document.getElementById('page-views-display');
if (!displayElement || displayElement.dataset.umamiState === 'loading' || displayElement.dataset.umamiState === 'loaded') {
if (!umamiConfig.enable) {
return;
}
displayElement.dataset.umamiState = 'loading';
try {
const umamiStore = window['__blogUmami'];
const statsData = await umamiStore?.getStats(`post:${slug}`, (websiteId) => {
const currentTimestamp = Date.now();
return `https://umami.adclosenn.top/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent('Asia/Shanghai')}&url=%2Fposts%2F${slug}%2F&compare=false`;
});
if (!statsData) {
throw new Error('统计功能未启用');
// 第一步:获取网站ID和token
const shareResponse = await fetch(`${umamiConfig.baseUrl}/api/share/${umamiConfig.shareId}`);
if (!shareResponse.ok) {
throw new Error('获取分享信息失败');
}
const shareData = await shareResponse.json();
const { websiteId, token } = shareData;
// 第二步:获取统计数据
const currentTimestamp = Date.now();
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
const statsResponse = await fetch(statsUrl, {
headers: {
'x-umami-share-token': token
}
});
if (!statsResponse.ok) {
throw new Error('获取统计数据失败');
}
const statsData = await statsResponse.json();
const pageViews = statsData.pageviews?.value || 0;
const visits = statsData.visits?.value || 0;
displayElement.textContent = `浏览量 ${pageViews} · 访问数 ${visits}`;
displayElement.dataset.umamiState = 'loaded';
const displayElement = document.getElementById('page-views-display');
if (displayElement) {
displayElement.textContent = `浏览量 ${pageViews} · 访问数 ${visits}`;
}
} catch (error) {
console.error('Error fetching page views:', error);
displayElement.textContent = '统计不可用';
displayElement.dataset.umamiState = 'error';
const displayElement = document.getElementById('page-views-display');
if (displayElement) {
displayElement.textContent = '统计不可用';
}
}
}
@@ -137,4 +152,4 @@ const className = Astro.props.class;
fetchPageViews();
}
</script>
)}
)}
+4 -2
View File
@@ -8,7 +8,7 @@ const { page } = Astro.props;
let delay = 0;
const interval = 50;
---
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-8 overflow-visible">
<div class="transition flex flex-col rounded-[var(--radius-large)] bg-[var(--card-bg)] py-1 md:py-0 md:bg-transparent md:gap-4 mb-4">
{page.data.map((entry: CollectionEntry<"posts">) => (
<PostCard
entry={entry}
@@ -17,10 +17,12 @@ const interval = 50;
category={entry.data.category}
published={entry.data.published}
updated={entry.data.updated}
url={getPostUrlBySlug(entry.id)}
url={getPostUrlBySlug(entry.slug)}
image={entry.data.image}
description={entry.data.description}
draft={entry.data.draft}
class:list="onload-animation"
style={`animation-delay: calc(var(--content-delay) + ${delay++ * interval}ms);`}
></PostCard>
))}
</div>
+1 -1
View File
@@ -146,7 +146,7 @@ $: if (initialized && keywordMobile) {
<Icon icon="material-symbols:search" class="absolute text-[1.25rem] pointer-events-none ml-3 transition my-auto text-black/30 dark:text-white/30"></Icon>
<input placeholder="{i18n(I18nKey.search)}" bind:value={keywordDesktop} on:focus={() => search(keywordDesktop, true)}
class="transition-all pl-10 text-sm bg-transparent outline-0
h-full w-40 active:w-40 focus:w-40 text-black/50 dark:text-white/50"
h-full w-40 active:w-60 focus:w-60 text-black/50 dark:text-white/50"
>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { Icon } from "astro-icon/components";
<div class="back-to-top-wrapper hidden lg:block">
<div id="back-to-top-btn" class="back-to-top-btn hide flex items-center rounded-2xl overflow-hidden transition" onclick="backToTop()">
<button aria-label="Back to Top" class="btn-card h-[3.75rem] w-[3.75rem]">
<Icon is:inline name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
<Icon name="material-symbols:keyboard-arrow-up-rounded" class="mx-auto"></Icon>
</button>
</div>
</div>
+3 -3
View File
@@ -57,12 +57,12 @@ const getPageUrl = (p: number) => {
{"disabled": page.url.prev == undefined}
]}
>
<Icon is:inline name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
<Icon name="material-symbols:chevron-left-rounded" class="text-[1.75rem]"></Icon>
</a>
<div class="bg-[var(--card-bg)] flex flex-row rounded-lg items-center text-neutral-700 dark:text-neutral-300 font-bold">
{pages.map((p) => {
if (p == HIDDEN)
return <Icon is:inline name="material-symbols:more-horiz" class="mx-1"/>;
return <Icon name="material-symbols:more-horiz" class="mx-1"/>;
if (p == page.currentPage)
return <div class="h-11 w-11 rounded-lg bg-[var(--primary)] flex items-center justify-center
font-bold text-white dark:text-black/70"
@@ -79,6 +79,6 @@ const getPageUrl = (p: number) => {
{"disabled": page.url.next == undefined}
]}
>
<Icon is:inline name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
<Icon name="material-symbols:chevron-right-rounded" class="text-[1.75rem]"></Icon>
</a>
</div>
+4 -24
View File
@@ -8,22 +8,12 @@ interface Props {
alt?: string;
position?: string;
basePath?: string;
loader?: boolean;
blur?: boolean;
}
import { Image } from "astro:assets";
import { url } from "../../utils/url-utils";
const {
id,
src,
alt,
position = "center",
basePath = "/",
loader = false,
blur = false,
} = Astro.props;
const { id, src, alt, position = "center", basePath = "/" } = Astro.props;
const className = Astro.props.class;
const isLocal = !(
@@ -56,19 +46,9 @@ if (isLocal) {
const imageClass = "w-full h-full object-cover";
const imageStyle = `object-position: ${position}`;
const normalizedPublicSrc =
src === "/public"
? "/"
: src.startsWith("/public/")
? src.slice("/public".length)
: src;
const originalSrc =
isLocal && img ? img.src : isPublic ? url(normalizedPublicSrc) : src;
const originalWidth = isLocal && img ? img.width : undefined;
const originalHeight = isLocal && img ? img.height : undefined;
---
<div id={id} class:list={[className, "overflow-hidden relative", loader && "image-loading-shell is-loading", blur && "image-blur-shell is-loading"]}>
<div id={id} class:list={[className, 'overflow-hidden relative']}>
<div class="transition absolute inset-0 dark:bg-black/10 bg-opacity-50 pointer-events-none"></div>
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle} data-pswp-src={originalSrc} data-pswp-width={originalWidth} data-pswp-height={originalHeight} data-original-src={originalSrc}/>}
{!isLocal && <img src={originalSrc} alt={alt || ""} class={imageClass} style={imageStyle} data-pswp-src={originalSrc} data-original-src={originalSrc}/>}
{isLocal && img && <Image src={img} alt={alt || ""} class={imageClass} style={imageStyle}/>}
{!isLocal && <img src={isPublic ? url(src) : src} alt={alt || ""} class={imageClass} style={imageStyle}/>}
</div>
+1 -1
View File
@@ -39,5 +39,5 @@ const postUrl = decodeURIComponent(Astro.url.toString());
<a href={licenseConf.url} target="_blank" class="link text-[var(--primary)] line-clamp-2">{licenseConf.name}</a>
</div>
</div>
<Icon is:inline name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
<Icon name="fa6-brands:creative-commons" class="transition text-[15rem] absolute pointer-events-none right-6 top-1/2 -translate-y-1/2 text-black/5 dark:text-white/5"></Icon>
</div>
+3 -2
View File
@@ -1,12 +1,13 @@
---
import "@fontsource-variable/jetbrains-mono";
import "@fontsource-variable/jetbrains-mono/wght-italic.css";
interface Props {
class: string;
}
const className = Astro.props.class;
---
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`} style="overflow-wrap: break-word; word-wrap: break-word; word-break: break-word;">
<div data-pagefind-body class={`prose dark:prose-invert prose-base !max-w-none custom-md ${className}`}>
<!--<div class="prose dark:prose-invert max-w-none custom-md">-->
<!--<div class="max-w-none custom-md">-->
<slot/>
@@ -0,0 +1,93 @@
<script lang="ts">
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import Icon from "@iconify/svelte";
import { getDefaultHue, getHue, setHue } from "@utils/setting-utils";
let hue = getHue();
const defaultHue = getDefaultHue();
function resetHue() {
hue = getDefaultHue();
}
$: if (hue || hue === 0) {
setHue(hue);
}
</script>
<div id="display-setting" class="float-panel float-panel-closed absolute transition-all w-80 right-4 px-4 py-4">
<div class="flex flex-row gap-2 mb-3 items-center justify-between">
<div class="flex gap-2 font-bold text-lg text-neutral-900 dark:text-neutral-100 transition relative ml-3
before:w-1 before:h-4 before:rounded-md before:bg-[var(--primary)]
before:absolute before:-left-3 before:top-[0.33rem]"
>
{i18n(I18nKey.themeColor)}
<button aria-label="Reset to Default" class="btn-regular w-7 h-7 rounded-md active:scale-90"
class:opacity-0={hue === defaultHue} class:pointer-events-none={hue === defaultHue} on:click={resetHue}>
<div class="text-[var(--btn-content)]">
<Icon icon="fa6-solid:arrow-rotate-left" class="text-[0.875rem]"></Icon>
</div>
</button>
</div>
<div class="flex gap-1">
<div id="hueValue" class="transition bg-[var(--btn-regular-bg)] w-10 h-7 rounded-md flex justify-center
font-bold text-sm items-center text-[var(--btn-content)]">
{hue}
</div>
</div>
</div>
<div class="w-full h-6 px-1 bg-[oklch(0.80_0.10_0)] dark:bg-[oklch(0.70_0.10_0)] rounded select-none">
<input aria-label={i18n(I18nKey.themeColor)} type="range" min="0" max="360" bind:value={hue}
class="slider" id="colorSlider" step="5" style="width: 100%">
</div>
</div>
<style lang="stylus">
#display-setting
input[type="range"]
-webkit-appearance none
height 1.5rem
background-image var(--color-selection-bar)
transition background-image 0.15s ease-in-out
/* Input Thumb */
&::-webkit-slider-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-moz-range-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
border-width 0
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
&::-ms-thumb
-webkit-appearance none
height 1rem
width 0.5rem
border-radius 0.125rem
background rgba(255, 255, 255, 0.7)
box-shadow none
&:hover
background rgba(255, 255, 255, 0.8)
&:active
background rgba(255, 255, 255, 0.6)
</style>
@@ -1,291 +0,0 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { cubicOut } from "svelte/easing";
import { fade, fly } from "svelte/transition";
import { feedbackConfig } from "@/config";
let dismissed = $state(true);
let feedbackDone = $state(false);
let isOpen = $state(false);
let feedbackState = $state<"idle" | "submitting" | "animating" | "error">(
"idle",
);
let lastChoice = $state<"yes" | "no" | null>(null);
let currentUrl = $state("");
let modalRef: HTMLDivElement | undefined = $state();
let showFeedback = $derived(feedbackConfig.enable && !feedbackDone);
let canSubmit = $derived(feedbackState === "idle");
let isError = $derived(feedbackState === "error");
onMount(() => {
if (!localStorage.getItem("dns-warning-dismissed")) {
dismissed = false;
}
if (localStorage.getItem("dns-feedback-submitted")) {
feedbackDone = true;
}
});
$effect(() => {
if (isOpen) {
document.documentElement.style.overflow = "hidden";
const firstBtn = modalRef?.querySelector<HTMLButtonElement>("button");
firstBtn?.focus();
} else {
document.documentElement.style.overflow = "";
}
return () => {
document.documentElement.style.overflow = "";
};
});
function dismissBar() {
dismissed = true;
localStorage.setItem("dns-warning-dismissed", "1");
}
function openModal() {
currentUrl = window.location.href;
isOpen = true;
}
function closeModal() {
isOpen = false;
if (feedbackState === "error") {
feedbackState = "idle";
lastChoice = null;
}
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeModal();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && feedbackState !== "submitting") closeModal();
}
async function submitFeedback(choice: "yes" | "no") {
if (!canSubmit) return;
feedbackState = "submitting";
lastChoice = choice;
if (feedbackConfig.debug) {
feedbackState = "animating";
setTimeout(() => {
feedbackDone = true;
localStorage.setItem("dns-feedback-submitted", "1");
}, 1500);
return;
}
try {
const resp = await fetch(feedbackConfig.apiEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: currentUrl,
choice,
timestamp: new Date().toISOString(),
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
feedbackState = "animating";
setTimeout(() => {
feedbackDone = true;
localStorage.setItem("dns-feedback-submitted", "1");
}, 1500);
} catch (err) {
console.error("[DNS Feedback]", err);
feedbackState = "error";
setTimeout(() => {
feedbackState = "idle";
lastChoice = null;
}, 3000);
}
}
</script>
{#if !dismissed}
<div id="dns-warning-banner" class="sticky top-0 z-[100] w-full bg-gradient-to-r from-amber-50/95 via-amber-50/90 to-amber-50/95 dark:from-amber-950/80 dark:via-amber-950/75 dark:to-amber-950/80 backdrop-blur-md border-b border-amber-200/60 dark:border-amber-700/40 text-amber-900 dark:text-amber-100 px-4 py-3 text-sm text-center">
<div class="select-none flex items-center justify-center gap-2 max-w-[var(--page-width)] mx-auto flex-wrap">
<Icon icon="material-symbols:info-outline-rounded" class="pointer-events-none shrink-0 text-lg" aria-hidden="true" />
<span class="text-balance">本站近期遭到反诈部门 DNS 污染,可能无法正常访问。如有需要可开启代理或使用国外 DNS 服务器。</span>
{#if showFeedback}
<button
onclick={openModal}
class="shrink-0 btn-plain scale-animation rounded-lg h-7 px-2.5 flex items-center gap-1 text-xs font-medium text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 active:scale-90"
aria-label="反馈"
>
<Icon icon="material-symbols:feedback-outline-rounded" class="text-sm" aria-hidden="true" />
<span>反馈</span>
</button>
{/if}
<!-- close button -->
<button
onclick={dismissBar}
class="select-auto shrink-0 btn-plain scale-animation rounded-lg w-7 h-7 flex items-center justify-center text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 active:scale-90"
aria-label="关闭通知"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg>
</button>
</div>
</div>
<!-- Modal -->
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="presentation"
class="fixed inset-0 z-[200] bg-black/30 dark:bg-black/50 backdrop-blur-sm"
transition:fade={{ duration: 100 }}
onclick={handleOverlayClick}
aria-hidden="true"
></div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
role="dialog"
aria-modal="true"
aria-label="站点反馈"
class="fixed inset-0 z-[210] flex items-center justify-center p-4"
onkeydown={handleKeydown}
onclick={handleOverlayClick}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={modalRef}
class="w-full max-w-sm rounded-[var(--radius-large)] border border-black/5 dark:border-white/5
bg-[var(--card-bg)] shadow-xl dark:shadow-none"
transition:fly={{ duration: 150, y: 6, easing: cubicOut }}
>
<!-- Header -->
<div class="flex items-center justify-between px-5 pt-5 pb-3 border-b border-[var(--line-color)]">
<h3 class="text-base font-semibold text-90">
DNS 污染反馈
</h3>
<button
onclick={closeModal}
class="-mr-1 btn-plain scale-animation w-8 h-8 rounded-lg flex items-center justify-center"
aria-label="关闭"
>
<Icon icon="material-symbols:close-rounded" class="text-xl" aria-hidden="true" />
</button>
</div>
<!-- Body -->
<div class="px-5 py-6">
{#if feedbackState === "animating"}
<div class="flex flex-col items-center gap-3 py-4">
<Icon
icon="material-symbols:check-circle-rounded"
class="text-4xl text-green-500 dark:text-green-400 success-icon"
aria-hidden="true"
/>
<p class="text-sm text-50 text-center">
感谢你的反馈!
</p>
</div>
{:else}
<p class="text-sm text-50 text-center mb-5">
本站是否能够在你所在地区被正常访问
</p>
{#if isError}
<p class="text-xs text-red-500 dark:text-red-400 text-center -mt-3 mb-4">提交失败,请稍后重试</p>
{/if}
<div class="flex gap-3 justify-center">
<button
onclick={() => submitFeedback("yes")}
disabled={!canSubmit}
class="feedback-option"
class:feedback-option--active={lastChoice === "yes" && feedbackState !== "idle"}
class:feedback-option--disabled={!canSubmit}
aria-label="可以访问"
>
<Icon icon="material-symbols:thumb-up-outline-rounded" class="text-lg" aria-hidden="true" />
<span></span>
</button>
<button
onclick={() => submitFeedback("no")}
disabled={!canSubmit}
class="feedback-option"
class:feedback-option--active={lastChoice === "no" && feedbackState !== "idle"}
class:feedback-option--disabled={!canSubmit}
aria-label="无法访问"
>
<Icon icon="material-symbols:thumb-down-outline-rounded" class="text-lg" aria-hidden="true" />
<span></span>
</button>
</div>
{/if}
</div>
</div>
</div>
{/if}
{/if}
<style>
.success-icon {
animation: success-pop 350ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes success-pop {
0% {
opacity: 0;
transform: scale(0.5);
}
60% {
transform: scale(1.15);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.feedback-option {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
border-radius: 0.75rem;
border: 1px solid transparent;
background: var(--btn-regular-bg);
color: var(--btn-content);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
user-select: none;
}
.feedback-option:hover:not(:disabled) {
background: var(--btn-regular-bg-hover);
border-color: var(--primary);
color: var(--primary);
}
.feedback-option:active:not(:disabled) {
background: var(--btn-regular-bg-active);
transform: scale(0.97);
}
.feedback-option:disabled {
cursor: default;
}
.feedback-option:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
.feedback-option--disabled {
opacity: 0.5;
}
.feedback-option--active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
</style>
+2 -2
View File
@@ -19,11 +19,11 @@ const links = Astro.props.links;
<div class="transition text-black/75 dark:text-white/75 font-bold group-hover:text-[var(--primary)] group-active:text-[var(--primary)]">
{link.name}
</div>
{!link.external && <Icon is:inline name="material-symbols:chevron-right-rounded"
{!link.external && <Icon name="material-symbols:chevron-right-rounded"
class="transition text-[1.25rem] text-[var(--primary)]"
>
</Icon>}
{link.external && <Icon is:inline name="fa6-solid:arrow-up-right-from-square"
{link.external && <Icon name="fa6-solid:arrow-up-right-from-square"
class="transition text-[0.75rem] text-black/25 dark:text-white/25 -translate-x-1"
>
</Icon>}
+51 -123
View File
@@ -1,11 +1,12 @@
---
import { Icon } from "astro-icon/components";
import { profileConfig } from "../../config";
import { profileConfig, umamiConfig } from "../../config";
import { url } from "../../utils/url-utils";
import ImageWrapper from "../misc/ImageWrapper.astro";
import TotalWords from "./TotalWords.astro";
const config = profileConfig;
const isSunday = new Date().getDay() === 0;
---
<div class="card-base p-3">
<div class="text-center text-sm text-neutral-500 dark:text-neutral-400 border-neutral-100 dark:border-neutral-700">一言 / hitokoto</div>
@@ -19,7 +20,7 @@ const config = profileConfig;
max-w-[12rem] lg:max-w-none overflow-hidden rounded-xl active:scale-95">
<div class="absolute transition pointer-events-none group-hover:bg-black/30 group-active:bg-black/50
w-full h-full z-50 flex items-center justify-center">
<Icon is:inline name="fa6-regular:address-card"
<Icon name="fa6-regular:address-card"
class="transition opacity-0 scale-90 group-hover:scale-100 group-hover:opacity-100 text-white text-5xl">
</Icon>
</div>
@@ -28,46 +29,32 @@ const config = profileConfig;
<div class="px-2">
<div class="font-bold text-xl text-center mb-1 dark:text-neutral-50 transition">{config.name}</div>
<div class="h-1 w-5 bg-[var(--primary)] mx-auto rounded-full mb-2 transition"></div>
<!-- bio: 名言部分 -->
<div class="text-center text-neutral-400 mb-2.5 transition">{config.bio}</div>
<div class="flex gap-2 justify-center mb-1">
{config.links.length > 1 && config.links.map(item =>
<a rel="me" aria-label={item.name} href={item.url} target="_blank" class="btn-regular rounded-lg h-10 w-10 active:scale-90">
<Icon is:inline name={item.icon} class="text-[1.5rem]"></Icon>
<Icon name={item.icon} class="text-[1.5rem]"></Icon>
</a>
)}
{config.links.length == 1 && <a rel="me" aria-label={config.links[0].name} href={config.links[0].url} target="_blank"
class="btn-regular rounded-lg h-10 gap-2 px-3 font-bold active:scale-95">
<Icon is:inline name={config.links[0].icon} class="text-[1.5rem]"></Icon>
<Icon name={config.links[0].icon} class="text-[1.5rem]"></Icon>
{config.links[0].name}
</a>}
</div>
<TotalWords />
<!-- 全站访问量统计 https://github.com/afoim/fuwari/blob/81f22decb17ff7ee1dd480c10773f7ba8f4df296/src/components/widget/Profile.astro -->
<div class="text-center text-sm text-neutral-500 dark:text-neutral-400 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-center gap-1">
<Icon is:inline name="material-symbols:visibility-outline" class="text-base"></Icon>
<Icon name="material-symbols:visibility-outline" class="text-base"></Icon>
<span id="site-stats">加载中...</span>
</div>
</div>
<!-- 最新 Commit 提交信息 -->
<div class="text-center text-sm text-neutral-500 dark:text-neutral-400 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-center gap-1">
<Icon is:inline name="ic:baseline-commit" class="text-base"></Icon>
<span><a id="github-commit-link" href="#">
<span id="github-commit">加载中...</span></a></span>
</div>
</div>
<!-- 星期日 -->
<iframe frameborder="no" border="0" marginwidth="0" marginheight="0" width=250 height=86 src="//music.163.com/outchain/player?type=2&id=2155423467&auto=0&height=66"></iframe>
</div>
</div>
</script>
<script>
// 傻逼脑子 Merge 的时候怎么把这个删了
@@ -77,123 +64,64 @@ const config = profileConfig;
fetch("https://v1.hitokoto.cn")
.then(response => response.json())
.then(data => {
const hitokotoElement = document.getElementById("hitokoto");
if (hitokotoElement) {
hitokotoElement.innerText = data.hitokoto;
}
// API 返回的字段是 data.text
document.getElementById("hitokoto").innerText = data.hitokoto;
})
.catch(error => {
console.error("获取一言失败:", error);
const hitokotoElement = document.getElementById("hitokoto");
if (hitokotoElement) {
hitokotoElement.innerText = "再热情的心也经不起冷漠,再爱你的人也经不起冷落。";
}
//document.getElementById("hitokoto").innerText = "获取失败,请稍后再试。";
//失败后用内置的,成功了用别人的。不过,这真的可能用失败吗?
document.getElementById("hitokoto").innerText = "再热情的心也经不起冷漠,再爱你的人也经不起冷落。";
});
</script>
<script>
type SiteStatsData = {
pageviews?: {
value?: number;
};
};
type BlogUmamiStore = {
getStats: (key: string, createUrl: (websiteId: string) => string) => Promise<SiteStatsData | undefined>;
};
declare global {
interface Window {
__blogUmami?: BlogUmamiStore;
}
}
<script define:vars={{ umamiConfig }}>
// 获取全站访问量统计
async function loadSiteStats() {
const statsElement = document.getElementById('site-stats');
if (!statsElement || statsElement.dataset.umamiState === 'loading' || statsElement.dataset.umamiState === 'loaded') {
if (!umamiConfig.enable) {
return;
}
statsElement.dataset.umamiState = 'loading';
try {
const umamiStore = window.__blogUmami;
const statsData = await umamiStore?.getStats('site:all', (websiteId: string) => {
const currentTimestamp = Date.now();
return `https://umami.adclosenn.top/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent('Asia/Shanghai')}&compare=false`;
});
if (!statsData) {
throw new Error('统计功能未启用');
// 第一步:获取网站ID和token
const shareResponse = await fetch(`${umamiConfig.baseUrl}/api/share/${umamiConfig.shareId}`);
if (!shareResponse.ok) {
throw new Error('获取分享信息失败');
}
const shareData = await shareResponse.json();
const { websiteId, token } = shareData;
// 第二步:获取全站统计数据(不指定url参数获取全站数据)
const currentTimestamp = Date.now();
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&compare=false`;
const statsResponse = await fetch(statsUrl, {
headers: {
'x-umami-share-token': token
}
});
if (!statsResponse.ok) {
throw new Error('获取统计数据失败');
}
const statsData = await statsResponse.json();
const pageviews = statsData.pageviews?.value || 0;
statsElement.textContent = `浏览量 ${pageviews}`;
statsElement.dataset.umamiState = 'loaded';
// const visitors = statsData.visits?.value || 0;
const statsElement = document.getElementById('site-stats');
if (statsElement) {
statsElement.textContent = `浏览量 ${pageviews}`;
}
} catch (error) {
console.error('获取全站统计失败:', error);
statsElement.textContent = '统计不可用';
statsElement.dataset.umamiState = 'error';
const statsElement = document.getElementById('site-stats');
if (statsElement) {
statsElement.textContent = '统计不可用';
}
}
}
// 页面加载完成后获取统计数据
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadSiteStats, { once: true });
} else {
loadSiteStats();
}
</script>
<!-- 获取 Commit 信息 via API -->
<script>
type GithubCommit = {
sha: string;
html_url: string;
commit: {
message: string;
committer: {
date: string;
};
};
};
async function loadCommitStats() {
try {
const statsElement = document.getElementById('github-commit');
const link = document.getElementById('github-commit-link');
const githubResponse = await fetch(`https://api.github.com/repos/Ad-closeNN/blog-fuwari/commits?per_page=1`);
if (!githubResponse.ok) {
throw new Error('获取信息失败');
}
const data = (await githubResponse.json()) as GithubCommit[];
const latestCommit = data[0];
if (!latestCommit) {
throw new Error('未获取到提交信息');
}
if (statsElement) {
statsElement.textContent = `当前提交:${latestCommit.sha.slice(0, 7)}`;
}
if (link instanceof HTMLAnchorElement) {
const infoUrl = "/info/";
link.href = infoUrl;
link.title = `(${latestCommit.commit.committer.date}) ${latestCommit.commit.message}`;
}
} catch (error) {
console.error('获取 Commit 信息失败:', error);
const statsElement = document.getElementById('github-commit');
if (statsElement) {
statsElement.textContent = '提交信息不可用';
}
}
}
// 页面加载完成后获取 Commit 数据
document.addEventListener('DOMContentLoaded', loadCommitStats);
</script>
document.addEventListener('DOMContentLoaded', loadSiteStats);
</script>

Some files were not shown because too many files have changed in this diff Show More