From a3fb0634f61928b790d59451025660a91cee7d15 Mon Sep 17 00:00:00 2001 From: MassiveBox Date: Sun, 20 Apr 2025 12:26:23 +0200 Subject: [PATCH] Add SiYuan Blogging support --- .idea/jsLibraryMappings.xml | 6 ++ README.md | 34 +++++++-- package-lock.json | 9 +-- package.json | 3 +- src/utils/joplin.mjs | 10 +-- src/utils/siyuan.mjs | 140 ++++++++++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 .idea/jsLibraryMappings.xml create mode 100644 src/utils/siyuan.mjs diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml new file mode 100644 index 0000000..d23208f --- /dev/null +++ b/.idea/jsLibraryMappings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index f650606..7511751 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,37 @@ npm run build ``` The website will be built to the `./dist` folder. +## Blogging with SiYuan + +You can use SiYuan to write and manage your blog posts, instead of just placing the Markdown files in the +`src/content/blog` folder. + +1. Install [SiYuan](https://b3log.org/siyuan/en/) +2. Start the application and keep it running in the background +3. Create a notebook for your blog posts, then right-click on it in the document tree, select `Settings`, then `Copy ID` +4. Set the environment variable `BLOG_NOTEBOOK_ID` to the notebook ID you copied earlier, like so: + `BLOG_NOTEBOOK_ID=your_notebook_id` +5. Run `npm run siyuan` + +The documents in your SiYuan notebook must include the frontmatter inside a code block with `yaml` as the language. +Every line before the frontmatter will be deleted. The notes' title is entirely irrelevant, only the frontmatter +determines the slug and title. + +You can embed attachments in your notes, and they will work normally in the blog. +You might need to restart the development server after running the script. + +In order, to have an ogImage (also known as article cover): +1. Insert the image in your note before the YAML frontmatter block, so that it gets added to the Assets folder +2. Copy the asset link (Right-click on the image > copy `image URL`) +3. You can now remove the image from the note, or you can keep it there. It will not be shown twice in the blog post as + long as it's before the frontmatter. + - Generally, it's best to keep the image referenced somewhere, so that it's not suggested as an "unused asset". + - A simple and reasonable way to do this is to set the image as the document's cover image. + - Hover on the cover image, click `Assets` (photograph icon), and select your cover image. + ## Blogging with Joplin -You can use Joplin to write and manage your blog posts, instead of just placing the Markdown files in the -`src/content/blog` folder. +If you prefer Joplin over SiYuan, that can also be used to generate the blog posts. 1. Install [Joplin](https://joplinapp.org/) 2. Start the Web Clipper Service: Tools > Options > Web Clipper > Start Web Clipper Service @@ -32,9 +59,6 @@ You can use Joplin to write and manage your blog posts, instead of just placing The script will look for a notebook named `Blog`. It will then download all notes from that notebook, alongside their attachments, and place them in the `src/content/blog` folder. -You can embed attachments in your notes, and they will work normally in the blog. -You might need to restart the development server after running the Joplin script. - In order, to have an ogImage (also known as article cover): 1. Add the image to the note, and change the alt text to `ogImage`. It will look like this: ``` diff --git a/package-lock.json b/package-lock.json index 542f094..1e02939 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "@astrojs/sitemap": "^3.0.5", "@astrojs/tailwind": "^5.1.0", "@divriots/jampack": "^0.23.2", - "@iconify-json/fa6-solid": "^1.1.21", + "@iconify-json/fa6-solid": "^1.2.3", "@tailwindcss/typography": "^0.5.10", "@types/github-slugger": "^1.3.0", "@types/react": "^18.2.48", @@ -1682,10 +1682,11 @@ } }, "node_modules/@iconify-json/fa6-solid": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@iconify-json/fa6-solid/-/fa6-solid-1.1.21.tgz", - "integrity": "sha512-+C6F3dFjNdqj8S83ggIXZFsX2CwecFErJKk9dVEZNd0XpNx2iNCNS+K/fl1HLFhYRhS4lb2Rgfi8yVV4MQU7yA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@iconify-json/fa6-solid/-/fa6-solid-1.2.3.tgz", + "integrity": "sha512-C5o8YJF+ekrS4wRb/6/0SE2KjRyJlCg++IOVC/fineiRinITivsmzFRNW1MQX2xfDZ1T7bxeKxLN6lcaTG3jGA==", "dev": true, + "license": "CC-BY-4.0", "dependencies": { "@iconify/types": "*" } diff --git a/package.json b/package.json index 101f668..b68aa03 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "cz": "cz", "prepare": "husky install", "lint": "eslint .", + "siyuan": "node src/utils/siyuan.mjs", "joplin": "node src/utils/joplin.mjs", "upload": "node src/utils/upload.mjs" }, @@ -42,7 +43,7 @@ "@astrojs/sitemap": "^3.0.5", "@astrojs/tailwind": "^5.1.0", "@divriots/jampack": "^0.23.2", - "@iconify-json/fa6-solid": "^1.1.21", + "@iconify-json/fa6-solid": "^1.2.3", "@tailwindcss/typography": "^0.5.10", "@types/github-slugger": "^1.3.0", "@types/react": "^18.2.48", diff --git a/src/utils/joplin.mjs b/src/utils/joplin.mjs index d157435..d125040 100644 --- a/src/utils/joplin.mjs +++ b/src/utils/joplin.mjs @@ -19,7 +19,7 @@ async function fetch(url, method = "GET") { } -async function saveImage(url, outputPath) { +export async function saveFile(url, outputPath) { return new Promise((resolve, reject) => { const request = http.get(url, (response) => { if (response.statusCode !== 200) { @@ -164,12 +164,12 @@ async function saveResources(resources, apiToken, host, slug) { const resID = resource[0]; const extension = resource[1]; const filePath = path.join(__dirname, '../content/blog', slug, resID + "." + extension); - await saveImage(`${host}/resources/${resID}/file?token=${apiToken}&fields=data`, filePath) + await saveFile(`${host}/resources/${resID}/file?token=${apiToken}&fields=data`, filePath) console.log(`Resource saved to ${filePath}`); } } -async function clearBlogFolder() { +export async function clearBlogFolder() { const blogFolderPath = path.join(__dirname, '../content/blog'); if (fs.existsSync(blogFolderPath)) { fs.rmSync(blogFolderPath, { recursive: true, force: true }); @@ -204,4 +204,6 @@ async function main() { } } -main().then(); \ No newline at end of file +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error); +} \ No newline at end of file diff --git a/src/utils/siyuan.mjs b/src/utils/siyuan.mjs new file mode 100644 index 0000000..b60222e --- /dev/null +++ b/src/utils/siyuan.mjs @@ -0,0 +1,140 @@ +import dotenv from 'dotenv'; +import { clearBlogFolder, saveFile } from "./joplin.mjs"; +import path from "path"; +import { fileURLToPath } from "url"; +import fs from "fs"; +import extract from 'extract-zip'; +dotenv.config(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const blogDir = path.join(__dirname,'../content/blog/'); +const zipPath = path.join(blogDir,'/siyuan.zip'); + +let apiBase = process.env.SIYUAN_API_BASE ? process.env.SIYUAN_API_BASE : await findSiYuanPort(); + +async function checkPort(base, port) { + const response = await fetch(base + port + '/api/notebook/lsNotebooks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if(response.status !== 200) { return false; } + const respJson = await response.json(); + if(respJson.code === 0) { + console.log(`Found SiYuan port: ${port}`); + return true; + } +} + +async function findSiYuanPort() { + let port; + const base = 'http://127.0.0.1:'; + if(await checkPort(base, 6806)) { // default port in most cases + return base + 6806; + } + console.log("Starting to look for SiYuan port (long scan)...") + for(port = 1024; port <= 65535; port++) { + try { + if(await checkPort(base, port)) { + return base + port; + } + }catch(e){} + } + console.log("Couldn't find SiYuan port, make sure it's running!"); + return null; +} + +async function getNotebookDownload(notebookID) { + const response = await fetch(apiBase + '/api/export/exportNotebookMd', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + 'notebook': notebookID + }) + }); + if(response.status !== 200) { + throw new Error(`Failed to download notebook. Status code: ${response.status}`); + } + const respJson = await response.json(); + return apiBase + respJson.data.zip; +} + +async function findMarkdownFiles(dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + return entries + .filter(entry => entry.isFile()) + .filter(entry => entry.name.endsWith('.md')) + .map(entry => path.join(dirPath, entry.name)); +} + +async function transformBlogPost(filePath) { + // Read the file content + const content = fs.readFileSync(filePath, 'utf-8'); + + // Split into lines and process + const lines = content.split('\n'); + let newContent = []; + let inFrontmatter = false; + let frontmatterFound = false; + + for (let line of lines) { + + // Handle frontmatter code fences + if (!frontmatterFound && !inFrontmatter && line.startsWith('\`\`\`yaml')) { + inFrontmatter = true; + frontmatterFound = true; + continue; // Remove the opening fence + } + + if(inFrontmatter && line.startsWith('ogImage: ')) { + line = line.replace('ogImage: assets/', 'ogImage: ./assets/'); + }else{ + line = line.replace('](assets/', '](./assets/'); // asset in markdown + } + + if (inFrontmatter && line.startsWith('\`\`\`')) { + inFrontmatter = false; + continue; // Remove the closing fence + } + + // Remove all lines before frontmatter + if (!frontmatterFound) { + continue; + } + + if(!inFrontmatter) { + line += ' ' // add spaces before newline + } + + newContent.push(line); + } + + // Join lines and write back to file + fs.writeFileSync(filePath, newContent.join('\n')); +} + + +async function main() { + + console.log('Preparing for SiYuan download...') + if(!process.env.SIYUAN_NOTEBOOK_ID) { + console.error('required env variable is not set'); + return; + } + + let downloadLink = await getNotebookDownload(process.env.SIYUAN_NOTEBOOK_ID); + await clearBlogFolder(); + fs.mkdirSync(blogDir, { recursive: true }); + await saveFile(downloadLink, zipPath); + + await extract(zipPath, { dir: blogDir }); + + for (const filePath of await findMarkdownFiles(blogDir)) { + console.log("Processing...", filePath); + void transformBlogPost(filePath); + } + +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch(console.error); +} \ No newline at end of file