├── .gitignore ├── CNAME ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── util.ts ├── browserconfig.xml ├── site.webmanifest ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ └── publishArticels.yml ├── safari-pinned-tab.svg ├── articles.ts ├── main.ts ├── index.html ├── articles ├── 49f035c6-a518-40c1-acab-e5087f0c228f.html └── 749e77a7-4e3d-44a5-bbbc-6f65d894f77a.html └── cards.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .env -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | blog.silvan.codes -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/favicon.ico -------------------------------------------------------------------------------- /favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/favicon-16x16.png -------------------------------------------------------------------------------- /favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/favicon-32x32.png -------------------------------------------------------------------------------- /mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/mstile-144x144.png -------------------------------------------------------------------------------- /mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/mstile-150x150.png -------------------------------------------------------------------------------- /mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/mstile-310x150.png -------------------------------------------------------------------------------- /mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/mstile-310x310.png -------------------------------------------------------------------------------- /mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/mstile-70x70.png -------------------------------------------------------------------------------- /apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/apple-touch-icon.png -------------------------------------------------------------------------------- /android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/android-chrome-192x192.png -------------------------------------------------------------------------------- /android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SilvanCodes/supernotes_blogging/main/android-chrome-512x512.png -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | const readEnv = (name: string) => 2 | Deno.env.get(name) || 3 | (console.error(`Environment variable ${name} is not set`), Deno.exit(1)); 4 | 5 | export { readEnv }; 6 | -------------------------------------------------------------------------------- /browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Debian OS version: bullseye, buster 2 | ARG VARIANT=bullseye 3 | FROM --platform=linux/amd64 mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} 4 | 5 | ENV DENO_INSTALL=/deno 6 | RUN mkdir -p /deno \ 7 | && curl -fsSL https://deno.land/x/install/install.sh | sh \ 8 | && chown -R vscode /deno 9 | 10 | ENV PATH=${DENO_INSTALL}/bin:${PATH} \ 11 | DENO_DIR=${DENO_INSTALL}/.cache/deno 12 | 13 | # [Optional] Uncomment this section to install additional OS packages. 14 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 15 | # && apt-get -y install --no-install-recommends 16 | -------------------------------------------------------------------------------- /.github/workflows/publishArticels.yml: -------------------------------------------------------------------------------- 1 | name: Supernotes Article Publisher 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "37 4 * * *" # “At 04:37.”, see: https://crontab.guru/#37_4_*_*_* 7 | 8 | jobs: 9 | Publish-Articles: 10 | runs-on: ubuntu-latest 11 | env: 12 | SUPERNOTES_API_KEY: ${{ secrets.SUPERNOTES_API_KEY }} 13 | 14 | steps: 15 | - uses: denoland/setup-deno@v1 16 | with: 17 | deno-version: v1.10.3 18 | 19 | - uses: actions/checkout@v3 20 | 21 | - name: Regenerate index and articles 22 | run: deno run --allow-env --allow-net --allow-write main.ts 23 | 24 | - name: Commit generated files 25 | run: | 26 | git config --global user.name "SilvanCodes" 27 | git config --global user.email "hello@silvan.codes" 28 | git add -A 29 | git commit -m "Update generated files" || true 30 | git push 31 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.245.2/containers/deno 3 | { 4 | "name": "Deno", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick an Debian OS version: bullseye, buster 8 | "args": { 9 | "VARIANT": "bullseye" 10 | } 11 | }, 12 | 13 | // Configure tool-specific properties. 14 | "customizations": { 15 | // Configure properties specific to VS Code. 16 | "vscode": { 17 | // Set *default* container specific settings.json values on container create. 18 | "settings": { 19 | // Enables the project as a Deno project 20 | "deno.enable": true, 21 | // Enables Deno linting for the project 22 | "deno.lint": true, 23 | // Sets Deno as the default formatter for the project 24 | "editor.defaultFormatter": "denoland.vscode-deno" 25 | }, 26 | 27 | // Add the IDs of extensions you want installed when the container is created. 28 | "extensions": [ 29 | "denoland.vscode-deno" 30 | ] 31 | } 32 | }, 33 | 34 | "remoteUser": "vscode" 35 | } -------------------------------------------------------------------------------- /safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 32 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /articles.ts: -------------------------------------------------------------------------------- 1 | const articleSkeleton = (title: string, html: string) => ` 2 | 3 | 4 | 5 | 6 | ${title} 7 | ${headSkeleton()} 8 | 9 | 10 | ${style()} 11 | 12 | 13 | 14 |
15 | ${header()} 16 |
17 |
${html}
18 |
19 | ${footer()} 20 |
21 | 22 | 23 | `; 24 | 25 | const headSkeleton = () => ` 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | `; 64 | 65 | const header = () => ` 66 |
67 | 68 |

SilvanBlogs

69 |
70 |
71 | `; 72 | 73 | const footer = () => ` 74 | 97 | `; 98 | 99 | const style = () => ` 100 | 133 | `; 134 | 135 | export { articleSkeleton }; 136 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { articleSkeleton } from "./articles.ts"; 2 | import { 3 | Card, 4 | getAllBlogCardsToPublish, 5 | getAllChildCards, 6 | getAllPublishedBlogCards, 7 | tagCardsAsPublished, 8 | } from "./cards.ts"; 9 | 10 | const buildBlogArticleFromCard = async ( 11 | { data: { id, name, html } }: Card, 12 | level = 1, 13 | ) => { 14 | const sectionizedHtml = html.replaceAll( 15 | " 18 |
24 | cardA.data.targeted_when < cardB.data.targeted_when ? -1 : 1 25 | ); 26 | 27 | // console.log(children); 28 | 29 | let childrenHtml = ""; 30 | 31 | for (const child of Object.values(children)) { 32 | const childHtml = await buildBlogArticleFromCard(child, level + 1); 33 | childrenHtml += childHtml; 34 | } 35 | 36 | return ` 37 |
38 | ${name} 39 | ${sectionizedHtml} 40 |
41 | ${childrenHtml.trim()}`; 42 | }; 43 | 44 | type ArticleMeta = { 45 | articleUrl: string; 46 | name: string; 47 | created_when: string; 48 | modified_when: string; 49 | }; 50 | 51 | const buildIndexHtml = async ( 52 | articleMeta: ArticleMeta[], 53 | ) => { 54 | let indexEntries = ""; 55 | 56 | for (const { name, created_when, modified_when, articleUrl } of articleMeta) { 57 | indexEntries += ` 58 |
59 | 60 |

${name}

61 |

Created at: ${new Date(created_when).toDateString()}

62 |

Updated at: ${new Date(modified_when).toDateString()}

63 |
64 |
65 | `; 66 | } 67 | 68 | const indexHtml = articleSkeleton("SilvanBlogs", indexEntries); 69 | 70 | await Deno.writeTextFile("index.html", indexHtml); 71 | }; 72 | 73 | const publishBlogArticles = async () => { 74 | console.log("Getting all cards to publish..."); 75 | 76 | const blogCardsToPublish = await getAllBlogCardsToPublish(); 77 | 78 | console.log( 79 | `Got ${Object.keys(blogCardsToPublish).length} cards to publish.`, 80 | ); 81 | 82 | const blogArticles = []; 83 | 84 | const blogArticleLinks = []; 85 | 86 | for (const blogCardToPublish of Object.values(blogCardsToPublish)) { 87 | const { id, name, created_when, modified_when } = blogCardToPublish.data; 88 | 89 | console.log(`Building article ${name} with id ${id}...`); 90 | 91 | const articleHtml = await buildBlogArticleFromCard(blogCardToPublish); 92 | 93 | const article = articleSkeleton(name, articleHtml).trim(); 94 | 95 | // console.log(article); 96 | 97 | blogArticles.push(article); 98 | 99 | const articleUrl = `./articles/${id}.html`; 100 | 101 | blogArticleLinks.push({ articleUrl, name, created_when, modified_when }); 102 | 103 | await Deno.writeTextFile(articleUrl, article); 104 | 105 | console.log(`Sucessfully build article ${name} with id ${id}.`); 106 | } 107 | 108 | const alreadyPublishedCards = await getAllPublishedBlogCards(); 109 | 110 | for (const alreadyPublishedCard of Object.values(alreadyPublishedCards)) { 111 | const { id, name, created_when, modified_when } = alreadyPublishedCard.data; 112 | const articleUrl = `./articles/${id}.html`; 113 | blogArticleLinks.push({ articleUrl, name, created_when, modified_when }); 114 | } 115 | 116 | const uniqueBlogArticleLinks = Object.values(blogArticleLinks.reduce( 117 | ( 118 | acc: Record, 119 | val, 120 | ) => { 121 | acc[val.articleUrl] = val; 122 | return acc; 123 | }, 124 | {}, 125 | )); 126 | 127 | uniqueBlogArticleLinks.sort(( 128 | cardA, 129 | cardB, 130 | ) => cardA.created_when > cardB.created_when ? -1 : 1); 131 | 132 | console.log(`Building index.html...`); 133 | 134 | await buildIndexHtml(uniqueBlogArticleLinks); 135 | 136 | console.log(`Sucessfully build index.html.`); 137 | 138 | console.log(`Tagging cards as published...`); 139 | 140 | await tagCardsAsPublished(blogCardsToPublish); 141 | 142 | console.log(`Sucessfully tagged cards as published.`); 143 | }; 144 | 145 | // DO SOMETHING 146 | 147 | console.log("Starting article generation..."); 148 | 149 | await publishBlogArticles(); 150 | 151 | // const cards = await getCardsByIds(["5fd5b354-5d88-4296-9b71-99dbee4122fb"]); 152 | 153 | // const [card, ..._rest] = Object.values(cards); 154 | 155 | // console.log(cards); 156 | 157 | // tagCardAsPublished(card); 158 | 159 | console.log("Successfully generated articles!"); 160 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SilvanBlogs 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 27 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 82 | 83 | 84 | 85 | 86 |
87 | 88 |
89 | 90 |

SilvanBlogs

91 |
92 |
93 | 94 |
95 | 120 |
121 | 122 | 145 | 146 |
147 | 148 | 149 | -------------------------------------------------------------------------------- /articles/49f035c6-a518-40c1-acab-e5087f0c228f.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Blogging from Supernotes 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 81 | 82 | 83 | 84 | 85 |
86 | 87 |
88 | 89 |

SilvanBlogs

90 |
91 |
92 | 93 |
94 |
95 |
96 |

Blogging from Supernotes

97 |

I want to create and edit blog posts simply from within Supernotes. And soon I can! (When you are reading this I definitely can.)

98 | 99 |
100 |

But why?

101 |

I think blogging is fun. I can experiment with the tone of my written voice and improve my technical writing.

102 | 103 |
104 |

But why like this?

105 |

I once setup a blog with Jekyll and GitHub Pages but the experience kinda sucked. I even wrote my very first blog post about that incident.

106 |

Having it in Supernotes reduces mental load and friction a ton. I can start writing blog posts where I note down everything. It’s easy to refine thoughts into posts and I could publish any thought should I feel like it.

107 | 108 |
109 |
110 |
111 | 112 | 135 | 136 |
137 | 138 | 139 | -------------------------------------------------------------------------------- /cards.ts: -------------------------------------------------------------------------------- 1 | import { readEnv } from "./util.ts"; 2 | 3 | const SUPERNOTES_CARDS_SELECT_URL = 4 | "https://api.supernotes.app/v1/cards/get/select"; 5 | 6 | const SUPERNOTES_CARDS_SPECIFY_URL = 7 | "https://api.supernotes.app/v1/cards/get/specify"; 8 | 9 | const SUPERNOTES_CARDS_UPDATE_URL = "https://api.supernotes.app/v1/cards/"; 10 | 11 | const BLOG_ARTICLES_CARD_ID = "ea01500d-d244-44d8-9e85-ead7ecc53315"; 12 | 13 | const TAG_TO_PUBLISH = "publish"; 14 | const TAG_ONCE_PUBLISHED = "published"; 15 | 16 | type Card = { 17 | data: { 18 | id: string; 19 | name: string; 20 | html: string; 21 | tags: string[]; 22 | targeted_when: string; 23 | created_when: string; 24 | modified_when: string; 25 | }; 26 | }; 27 | 28 | type CardCollection = { string: Card }; 29 | 30 | const getCardsByIds = async (ids: string[]): Promise => { 31 | const options = { 32 | method: "POST", 33 | headers: { 34 | "Accept": "application/json", 35 | "Api-Key": readEnv("SUPERNOTES_API_KEY"), 36 | "Content-Type": "application/json", 37 | }, 38 | body: JSON.stringify({ 39 | "specified": ids, 40 | }), 41 | }; 42 | 43 | const res = await fetch(SUPERNOTES_CARDS_SPECIFY_URL, options); 44 | // console.log(res); 45 | 46 | const json = await res.json(); 47 | // console.log(json); 48 | 49 | return json; 50 | }; 51 | 52 | const getAllBlogCardsToPublish = async (): Promise => { 53 | const options = { 54 | method: "POST", 55 | headers: { 56 | "Accept": "application/json", 57 | "Api-Key": readEnv("SUPERNOTES_API_KEY"), 58 | "Content-Type": "application/json", 59 | }, 60 | body: JSON.stringify({ 61 | parent_card_id: BLOG_ARTICLES_CARD_ID, 62 | filter_group: { 63 | operator: "and", 64 | filters: [ 65 | { 66 | type: "tag", 67 | operator: "contains", 68 | arg: TAG_TO_PUBLISH, 69 | }, 70 | ], 71 | }, 72 | }), 73 | }; 74 | 75 | const res = await fetch(SUPERNOTES_CARDS_SELECT_URL, options); 76 | // console.log(res); 77 | 78 | const json = await res.json(); 79 | // console.log(json); 80 | 81 | return json; 82 | }; 83 | 84 | const getAllPublishedBlogCards = async (): Promise => { 85 | const options = { 86 | method: "POST", 87 | headers: { 88 | "Accept": "application/json", 89 | "Api-Key": readEnv("SUPERNOTES_API_KEY"), 90 | "Content-Type": "application/json", 91 | }, 92 | body: JSON.stringify({ 93 | parent_card_id: BLOG_ARTICLES_CARD_ID, 94 | filter_group: { 95 | operator: "and", 96 | filters: [ 97 | { 98 | type: "tag", 99 | operator: "contains", 100 | arg: TAG_ONCE_PUBLISHED, 101 | }, 102 | ], 103 | }, 104 | }), 105 | }; 106 | 107 | const res = await fetch(SUPERNOTES_CARDS_SELECT_URL, options); 108 | // console.log(res); 109 | 110 | const json = await res.json(); 111 | // console.log(json); 112 | 113 | return json; 114 | }; 115 | 116 | const getAllChildCards = async (id: string): Promise => { 117 | const options = { 118 | method: "POST", 119 | headers: { 120 | "Accept": "application/json", 121 | "Api-Key": readEnv("SUPERNOTES_API_KEY"), 122 | "Content-Type": "application/json", 123 | }, 124 | body: JSON.stringify({ 125 | parent_card_id: id, 126 | }), 127 | }; 128 | 129 | const res = await fetch(SUPERNOTES_CARDS_SELECT_URL, options); 130 | // console.log(res); 131 | 132 | const json = await res.json(); 133 | // console.log(json); 134 | 135 | return json; 136 | }; 137 | 138 | const tagCardsAsPublished = async (cards: CardCollection) => { 139 | for (const card of Object.values(cards)) { 140 | await tagCardAsPublished(card); 141 | } 142 | }; 143 | 144 | const tagCardAsPublished = async ({ data: { id, tags } }: Card) => { 145 | tags = tags.filter((tag) => 146 | tag !== TAG_TO_PUBLISH && tag !== TAG_ONCE_PUBLISHED 147 | ); 148 | tags.push(TAG_ONCE_PUBLISHED); 149 | 150 | const options = { 151 | method: "PUT", 152 | headers: { 153 | "Accept": "application/json", 154 | "Api-Key": readEnv("SUPERNOTES_API_KEY"), 155 | "Content-Type": "application/json", 156 | }, 157 | body: JSON.stringify({ 158 | "card": { 159 | "tags": tags, 160 | }, 161 | }), 162 | }; 163 | 164 | const res = await fetch( 165 | `${SUPERNOTES_CARDS_UPDATE_URL}${id}/`, 166 | options, 167 | ); 168 | // console.log(res); 169 | 170 | const json = await res.json(); 171 | // console.log(json); 172 | 173 | return json; 174 | }; 175 | 176 | export type { Card }; 177 | 178 | export { 179 | getAllBlogCardsToPublish, 180 | getAllChildCards, 181 | getAllPublishedBlogCards, 182 | getCardsByIds, 183 | tagCardAsPublished, 184 | tagCardsAsPublished, 185 | }; 186 | -------------------------------------------------------------------------------- /articles/749e77a7-4e3d-44a5-bbbc-6f65d894f77a.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SuperBlog: The ingredients 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 26 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 81 | 82 | 83 | 84 | 85 |
86 | 87 |
88 | 89 |

SilvanBlogs

90 |
91 |
92 | 93 |
94 |
95 |
96 |

SuperBlog: The ingredients

97 |

So, as you may have read, the source of this article is my Supernotes. I decided to turn this experiment into a little series, where I discuss the technical implementation and explore its capabilities.

98 | 99 |
100 |

Where does it come from?

101 |

Supernotes offers an API that I have build upon, yet it is to be considered in an alpha/beta stage.
102 | The only official docs is swagger at https://api.supernotes.app/docs/swagger which by far does not explain itself in detail and there are no guarantees regarding stability.

103 |

I hopped into VSCode, generated a . devcontainer configuration for deno and prototyped the API calls with Typescript. The API and especially the types (I love types!) of the JSON responses turned out sufficiently complete, enough for me to figure out how to read and write my cards and their metadata as needed.

104 | 105 |
106 |

Where does it go?

107 |

We as individuals got pretty powerful platforms at our fingertips these days. GitHub is one of them. Having my card data available I blew some magic powder in the air and they turned into HTML. (The content is actually available as HTML right away and most of the code is just template strings to stitch that together. Also the index page is generated.)

108 |

GitHub Actions to automate static file generation and GitHub Pages for static file hosting are the last two major components.

109 | 110 |
111 |

Why are you telling me this?

112 |

I think I got a potentially clever idea that could benefit you as well. (Assuming you do or would want to use Supernotes.) And in any case, you and me, we might learn something. So listen:
113 | The input for all that blog generation seems reasonably small to me. So small, in fact, I believe I can turn this thing into a GitHub Action itself. Right now I approximate the smallest possible input for that potential action as an token to access the API and one tag to find relevant cards. From there it depends how customizable the output needs to be to be satisfactory as an individual blog.

114 |

Thank you for spending your time with me and stay tuned for more.

115 | 116 |
117 |
118 |
119 | 120 | 143 | 144 |
145 | 146 | 147 | --------------------------------------------------------------------------------