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 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+  <component name="JavaScriptLibraryMappings">
+    <includedPredefinedLibrary name="Node.js Core" />
+  </component>
+</project>
\ 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