skip to content

Stream

  • Webtrotion 2.4.x: tab blocks and heading 4

    Webtrotion 2.4.x has landed with support for tab blocks and Heading 4.

    I think one of the biggest reasons why I'm still maintaining this whole setup instead of using one of the prevalent Notion-to-website converters out there is: no one else is going to introduce and add components at the rate that I need them, and Notion has been shipping fast lately. I think this gives me a good idea of what is available, what can be done versus what can’t be done.

    See, here are the headings in order, depicted by non-Taylor Swift songs.

    Manchild

    So Easy to Fall in Love

    bad idea right?

    And see, heading 4 is real now

    Yes, Notion now supports full tiny-heading mode. Something to note here is that in Webtrotion, we set the page title as H1 and then move down every corresponding heading, so the H1 inside the page becomes H2, H2 becomes H3, and so on. So this H4 on the web page actually renders as H5.

    And so are tab blocks

    Say, if you have a Notion database where you keep adding the same URL 17 times (same link, different casing, extra tracking params, whatever), this script [a] will: pull all pages from the database, normalize the URL (so duplicates actually match), keep the oldest page, archive the duplicates.

    And yes I’m showing both versions in tabs [b]. And the whole point with tabs is: I can keep the site clean without a ton of toggles and such.

    The image shows how it looks on the notion pages.
    The image shows how it looks on the notion pages.
    #!/usr/bin/env python3
    """dedupe_notion_urls.py
    
    Archives duplicate rows in a Notion database based on a URL property.
    
    Keeps the oldest page (by created_time) for each normalized URL.
    
    Setup:
      export NOTION_TOKEN="secret_..."
      python dedupe_notion_urls.py --db <DATABASE_ID> --property "External URL" --dry-run
    
    Then remove --dry-run to actually archive duplicates.
    
    Requires:
      pip install requests
    """
    
    from __future__ import annotations
    
    import argparse
    import os
    import re
    import sys
    from urllib.parse import urlparse, urlunparse
    
    import requests
    
    NOTION_VERSION = "2022-06-28"
    
    
    def normalize_url(u: str) -> str:
        u = (u or "").strip()
        if not u:
            return ""
    
        # make sure it parses (allow people to paste without scheme)
        if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", u):
            u = "https://" + u
    
        p = urlparse(u)
    
        # normalize host + strip tracking + strip fragments
        scheme = p.scheme.lower() or "https"
        netloc = p.netloc.lower()
        path = re.sub(r"/+$", "", p.path)  # trim trailing slashes
    
        # drop query entirely (simple, aggressive dedupe)
        # (you can keep whitelisted params if you want)
        query = ""
        fragment = ""
    
        return urlunparse((scheme, netloc, path, "", query, fragment))
    
    
    def notion_headers(token: str) -> dict:
        return {
            "Authorization": f"Bearer {token}",
            "Notion-Version": NOTION_VERSION,
            "Content-Type": "application/json",
        }
    
    
    def query_database(token: str, database_id: str, start_cursor: str | None = None) -> dict:
        url = f"https://api.notion.com/v1/databases/{database_id}/query"
        payload = {"page_size": 100}
        if start_cursor:
            payload["start_cursor"] = start_cursor
        r = requests.post(url, headers=notion_headers(token), json=payload, timeout=60)
        r.raise_for_status()
        return r.json()
    
    
    def archive_page(token: str, page_id: str) -> None:
        url = f"https://api.notion.com/v1/pages/{page_id}"
        payload = {"archived": True}
        r = requests.patch(url, headers=notion_headers(token), json=payload, timeout=60)
        r.raise_for_status()
    
    
    def get_url_prop(page: dict, prop_name: str) -> str:
        prop = page.get("properties", {}).get(prop_name)
        if not prop:
            return ""
        if prop.get("type") != "url":
            return ""
        return prop.get("url") or ""
    
    
    def main() -> None:
        p = argparse.ArgumentParser()
        p.add_argument("--db", required=True, help="Database ID")
        p.add_argument("--property", default="External URL", help="URL property name")
        p.add_argument("--dry-run", action="store_true")
        args = p.parse_args()
    
        token = os.environ.get("NOTION_TOKEN")
        if not token:
            print("Missing NOTION_TOKEN env var", file=sys.stderr)
            sys.exit(1)
    
        pages = []
        cursor = None
        while True:
            data = query_database(token, args.db, cursor)
            pages.extend(data.get("results", []))
            if not data.get("has_more"):
                break
            cursor = data.get("next_cursor")
    
        # group by normalized URL
        groups: dict[str, list[dict]] = {}
        for pg in pages:
            raw = get_url_prop(pg, args.property)
            norm = normalize_url(raw)
            if not norm:
                continue
            groups.setdefault(norm, []).append(pg)
    
        duplicates = []
        for norm, items in groups.items():
            if len(items) <= 1:
                continue
            items.sort(key=lambda x: x.get("created_time", ""))
            keep = items[0]
            trash = items[1:]
            for t in trash:
                duplicates.append((norm, keep["id"], t["id"]))
    
        print(f"Found {len(duplicates)} duplicate pages to archive")
    
        for norm, keep_id, dupe_id in duplicates:
            print(f"- {norm}\n    keep: {keep_id}\n    archive: {dupe_id}")
            if not args.dry_run:
                archive_page(token, dupe_id)
    
        if args.dry_run:
            print("(dry-run) No pages were archived")
    
    
    if __name__ == "__main__":
        main()



    Footnotes

    1. [a]
      This code is written by AI and not human verified. It’s just an example. Don’t run it.
    2. [b]
      Unfortunately the tabs below will not show the icons associated with the tabs.

  • Ext Webtrotion 2.x Config Generator

    A UI interface made by Gemini 3 which can generate the constants config that you can directly copy paste into your forked github repo rather than you needing to fiddle with JSON5 yourself

  • Test Which Fonts You’d Like

    Now that Webtrotion supports direct font names, has simplified font configuration, you might want to test how different fonts would look on your website. To do this, open the dev console [a] on any Webtrotion powered website, like this one, and paste this snippet into the console tab after changing the sans and mono font names to the ones you want to try [b].

    (function() {
      const sans = 'Inter';      // change to any Google Sans font
      const mono = 'Geist Mono';  // change to any Google Monospace font
    
      // Create Google Fonts URL with all weights + italics
      const googleFontsURL = `https://fonts.googleapis.com/css2?family=${sans.replace(/ /g,'+')
      }:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=${mono.replace(/ /g,'+')
      }:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap`;
    
      // Inject link tag for Google Fonts
      const link = document.createElement('link');
      link.href = googleFontsURL;
      link.rel = 'stylesheet';
      document.head.appendChild(link);
    
      // Override CSS variables globally
      const style = document.createElement('style');
      style.textContent = `
        :root {
          --font-sans: '${sans}', system-ui, sans-serif !important;
          --font-mono: '${mono}', monospace !important;
          --default-font-family: '${sans}', system-ui, sans-serif !important;
          --default-mono-font-family: '${mono}', monospace !important;
        }
      `;
      document.head.appendChild(style);
    
      // Apply sans font to body immediately
      document.body.style.fontFamily = `var(--font-sans)`;
    })();

    Footnotes

    1. [a]
      The Chrome developer console shortcut is Ctrl + Shift + J for Windows/Linux and Cmd + Opt + J for Mac.
    2. [b]
      Note that font names are case-sensitive, so using geist mono won't work since the font name is Geist Mono.

  • Three-Way Theme Toggle in Webtrotion

    I added a 3-way Theme toggle enhancing the existing 2-way toggle, allowing switching between system, light, and dark modes, which I'm quite happy about. This was implemented using GitHub Copilot with Claude 3.5s Sonnet.

    Most of the time was spent finding a suitable icon (tried searching for light dark and sun moon with a single path that would work with my system. I found one on Iconify but had to modify it. Something to note, while I attempted to use LLMs for the modification, it wasn't successful, so I used SVG Path Editor instead. As shown in the image below, you can now switch between three theme modes rather than just two.

    Image uploaded to Notion

    You can look at the GitHub gist below to see the conversation with GitHub Copilot.

    Read in the embed:


  • Startup Options on MacOS (Lid and Power Connections)

    My partner always hated how opening the screen on the laptop used to lead to turning on the laptop. Of course he switched off from macOS but I would like to collect these tips and trips together at some place just to make sure that I have them at hand and don't go searching over the internet. And so this is written by MacRumors linked below and I'm copy pasting the relevant stuff here.

    #!/bin/bash
    
    # This script modifies the Mac's startup behavior using the nvram command.
    # Run this script in Terminal with the appropriate option.
    
    echo "Choose an option to modify startup behavior:"
    echo "1) Prevent startup when opening the lid or connecting to power"
    echo "2) Prevent startup only when opening the lid"
    echo "3) Prevent startup only when connecting to power"
    echo "4) Undo changes (restore default behavior)"
    read -p "Enter your choice (1-4): " choice
    
    case $choice in
        1)
            sudo nvram BootPreference=%00
            echo "Startup is now prevented when opening the lid or connecting to power."
            ;;
        2)
            sudo nvram BootPreference=%01
            echo "Startup is now prevented only when opening the lid."
            ;;
        3)
            sudo nvram BootPreference=%02
            echo "Startup is now prevented only when connecting to power."
            ;;
        4)
            sudo nvram -d BootPreference
            echo "Startup behavior restored to default."
            ;;
        *)
            echo "Invalid choice. No changes made."
            ;;
    esac
    
    # The user will be prompted for their administrator password when running the script.
    # Terminal does not display the password while typing.

  • I Wish We Had Tear-Off Hotbars

    One of the major reasons I avoid screenshots/videos in my posts generally is because of how cumbersome it is to take them. And I wish I could kinda "tear off" commands from different apps and place it onto a temporary menu bar.

    For example, when I was writing this post All The Menu Bar Weather Apps for MacOS — I really wanted to convert all screenshots to text. Why, you might ask? Because the workflow requires so many clicks in order.

    So, I usually have my clop optimize on clipboard on. But given this required stitching of different images — I didn’t want to optimize each image and have it progressively get worse. So, this is what it looked like —

    Pause auto-optimizations in Clop, then show desktop in Supercharge. Use Shottr to capture any window (note that this doesn't create a file). Copy the image in Shottr, then capture additional windows (like settings or preferences). Open clipboard history in Raycast (which doesn’t have a paste stack) multiple times to Paste all images into the last Shottr image, turn optimization back on in Clop, and copy the final image from Shottr.

    Oh, now I want to generate image captions, here is what that looked like —

    First, paste the image into Notion, then open ChatGPT and paste the image into ChatGPT to generate a caption. Copy the generated caption into Notion, replace line breaks using Shift+Enter, and finally cut and paste the text into the image caption field that is opened by a click on the caption button.

    I wish there was a way to create a workflow hotbar on the go, one where I could put all the actions I need to take across various apps into one place, and then discard the hotbar. Sure, I can assign keyboard shortcuts to all these actions — but I possibly never need to use these actions again, and if I need to, I am not going to remember the shortcuts I assigned to them. I don’t even know if tear-off hotbars are possible in MacOS, but I wish they were.


  • Safari has an Inbuilt Link Preview

    Remember how I was excited about the preview options in Arc, and then when I stopped using Arc, I shifted to Chrome and found an extension called MaxFocus to give me the same preview options (Read more here: Link Previews in Chrome using MaxFocus)? Well, Safari has an inbuilt option for link previews (and I learnt about it here) — you can use force touch on your touchpad or use ⌃+⌘+D while hovering over the link (similar to bringing up the dictionary) to bring up the preview popup. That is awesome! Honestly, there are so many shortcuts with ^ and that I am still learning them (see other shortcuts you might like here).


  • Bluesky comments now work on Webtrotion

    Two days ago, I added this comment to Making Webtrotion:

    Image uploaded to Notion

    After Emily’s post, there was a flurry of efforts to use bluesky as a commenting system. I am actually surprised more people didn’t use Twitter as a commenting system when their API was free for all. That led to Cory creating a package, which was react based. So, I tried finding if people had previously added bluesky comments to astro, and of course they had: see graysky, snorre (also has the option to reply from the comment section but needs bluesky app password, I’ll revisit this after OAuth), and Jade’s (statically builds comments, and doesn’t update in real-time). Then I found Lou’s post here and was relieved to know that you can do this without react. I ended up using the version by Matt here (the person whose code I am using for Bluesky post embeds now work on Webtrotion as well). The good part about Matt’s version is that it has an option to search for did:{xx}plc:{{yy} format, instead of expecting people to find the exact at format URI. And lastly, I used this post from Jack to auto-search a post (and the idea of using a echo-feed-emoji). There is a react version of this (modified from Cory’s version) here by Nico.

    Update Nov 27, 2024, 04:20 AM: I saw this cool trick by Garrick here to filter out hidden, blocked or not found replies and added that to the script.

    All this together, and webtrotion now has its bluesky commenting system. I am not going to add an explicit URL here to show that auto-search works, while I added an explicit URL to the Bluesky post embeds post.

    Image uploaded to Notion
    Image uploaded to Notion
    How to use Bluesky Comments in Webtrotion

    Remember, these are currently only read-only comments, interaction needs to happen on Bluesky (there is a handy-dandy link added on top of comments). Once Bluesky has OAuth, I’ll try to make it so that people can also comment through your webpage.

    Step 1: If you are just now duplicating the template here, you should be fine. If you have already been using Webtrotion, add a property called Bluesky Post Link to the database with type as URL. Make sure that the property name matches.

    Step 2: In your constants-config.json file, you will see this:

    	"bluesky-comments": {
    		"show-comments-from-bluesky": false,
    		"auto-search-for-match": {
    			"turn-on-auto-search": false,
    			"author": "",
    			"echo-feed-emoji": ""
    		}
    	}
    • show-comments-from-bluesky decides whether you want to show a bluesky comment section at all or not. It is false by default, but if you turn it to true, bluesky comment rendering script will be executed.
    • Now, whichever link you paste into Bluesky Post Link for a post, that thread will be used to render the comments on your post.
    • If we used this system as is, given Webtrotion builds every 8 hours (configurable), we would need to wait for it to compile to post, then make a post on Bluesky, then copy that URL and paste it into the Bluesky Post Link field, and wait for it to build again. auto-search-for-match allows you to automatically search your profile for posts that mention the link (only parent posts, not in replies).
      • turn-on-auto-search decides whether you want to turn auto-search on or off.
      • author is your bluesky handle. You can specify this as handle (do not include @ sign) or as the did: protocol value. If you do not specify a handle here, auto-search will not be executed.
      • echo-feed-emoji: Only searches posts that mention the echo-feed character. If you set it to empty, it will search all your parent posts.

    The variables look like this:

    const post_slug = new URL(
      getPostLink(post.Slug),
      import.meta.env.SITE
    ).toString();
    const bluesky_url = post.BlueSkyPostLink || "";
    const BLUESKY_COMM = {
      "show-comments-from-bluesky": true,
      "auto-search-for-match": {
        "turn-on-auto-search": false,
        "author": "",
        "echo-feed-emoji": "",
      },
    };

    Here is the latest version of the script:


  • Bluesky post embeds now work on Webtrotion

    Initially, I hadn't even considered that Bluesky posts wouldn’t embed correctly on Webtrotion. I'd spent lots of time working on tweet embeds to make them look more aligned with Notion's style. Interestingly, Bluesky embeds don't even work in Notion right now—they just show up as cut-off web pages.

    Then I saw this post (which I'll embed below) that lets you use Bluesky comments as post comments and shows threads in those comments. That sounded awesome! While implementing that feature will take some time, I tried to mention the post and discovered that embeds actually work. So now Bluesky post embeds work on Webtrotion—yay!

    They still don't have dark mode support, and I need to adjust the line spacing and font sizing, but I wanted to share this now since, you know, just in case the platform takes off.


    Anyhow, the post in question:

    Emily Liu

    By popular request, here's how to add Bluesky replies as your blog's comment section! This requires some technical know-how for now, but I'm hoping that we see some no-code solutions for this pop up soon, like Ghost or Wordpress plugins. emilyliu.me/blog/comments

    November 25, 2024 at 5:49 AM UTC

    How it looks like on the web

    Image uploaded to Notion

    And, how it looks like on Notion

    Image uploaded to Notion


    How it previously looked like on Webtrotion

    Image uploaded to Notion

    And now, with the update

    Image uploaded to Notion

  • I tried Stackie

    Stackie is a prompt your own database to obtain prompted structured extraction app, and I am here for it

    As of Nov 5, 2024, Stackie has multiple Twitter posts that could be interpreted as endorsements of Trump. I want to clarify that my testing of this app occurred before I was aware of that position, and it doesn't reflect the values I hold dear.


    Stackie (FAQ) is a new app that was suggested to me on Twitter feed from HeyDola (another app that I use for scheduling from images and text, because it works with whatsapp and is free). And it looked fun! It is very similar in idea to what I have implemented in Use Notion’s Property Description As Text → DB add-itor (the example there being a money tracker) and comes halfway through what I mentioned in The Components of a PKMS. But this is a self contained app, has way better UX than hosting your own script, is clean and somehow really clicked for me, because it comes really close to what I wanted (want?) to make in Trying to Build a Micro Journalling App.

    To be honest, Notion's AI properties and Notion’s AI add option will get you there pretty often. It is probably too much for you would want if all you are looking for is tracking. There have been other apps that do something similar — hints being the one I can recall off the top of my head, but they all integrate with external apps or are meant for power users or developers (for example, AI add to Supabase database).

    When you open the app it starts with a baseline of inbox database. It comes with its own templates, and ideally you should be prompted to select at least one during onboarding to get a feel of how it works. The templates are prompted databases, where each field can either be a date/time, number, boolean or text. The templated database and properties are all customizable which is a huge win!

    The entry box when you have created all your “stacks” let's you type in anything and chooses which stack it is most likely to belong to — another affordance I really appreciate. It works with photos too, both understanding the text in the photo (so you can capture a snippet of an event you attended if you are tracking all events you attend in a month), and understands the objects in the photo — so you can click a photo of a cheeseburger and it will understand that it should go to the calorie tracking stack and figuring out the breakdown of nutrients for that log. And it works with voice, so you can speak and it will transcribe and process that information. It seems to use internal dictate option, so doesn't seem to be as good as whisper (proper nouns are hard for example) — but I might be wrong about their processing mechanism.

    It can process into multiple databases and add multiple entries at once! It seems to only be additive at the moment though, you cannot edit entries through the universal text box (you can go to the entry and edit it though). There is no export option, but that disappointingly seems to be the norm for iOS and beta apps. You currently cannot do anything with the data you record like you can do in Notion (add it up, set limits etc), so it might not be satisfying to use as a habit tracker and hard to get a view of data you might want, but it is a great starting point. It is what Collections DB could look like, with integrated AI. The app is iOS only, so wouldn’t be something I use, but definitely something worth looking at.

    Some images from the app
    This screen displays all my current stacks: Inbox, Calorie Record, and Mood Tracker. Inbox is a special pre-existing stack where you can create notes without assigning them to a specific stack. Later, you can combine these notes into a new stack if desired. Calorie Record has multiple properties, such as food input, unit, meal time, and more. The Mood Tracker simply contains two values: the recorded time and the general mood.
    This screen displays all my current stacks: Inbox, Calorie Record, and Mood Tracker. Inbox is a special pre-existing stack where you can create notes without assigning them to a specific stack. Later, you can combine these notes into a new stack if desired. Calorie Record has multiple properties, such as food input, unit, meal time, and more. The Mood Tracker simply contains two values: the recorded time and the general mood.
    You can create a stack through prompting, which is similar to building a database but using natural language instead. This approach allows you to specify properties, and conversions into structured formats. It's an excellent method for creating a database without using the detailed user interfaces found in tools like Collections or Notion.
    You can create a stack through prompting, which is similar to building a database but using natural language instead. This approach allows you to specify properties, and conversions into structured formats. It's an excellent method for creating a database without using the detailed user interfaces found in tools like Collections or Notion.
    The best part is the option to create stacks from templates. These templates come with pre-prompted fields and extraction prompts, ranging from AI-generated content for all fields to logging and note-keeping with multiple date properties.
    The best part is the option to create stacks from templates. These templates come with pre-prompted fields and extraction prompts, ranging from AI-generated content for all fields to logging and note-keeping with multiple date properties.
    So, for example, if you select the calories record, you will see the stack name, the prompt stack indicating which properties should be there, and each property by itself. You can have any number of properties, and you can edit and rearrange them as needed.
    So, for example, if you select the calories record, you will see the stack name, the prompt stack indicating which properties should be there, and each property by itself. You can have any number of properties, and you can edit and rearrange them as needed.
    Here, I click into a property in the calorie record named "calories." If it's not provided in the input text, the prompt estimates its value. This property is a number, which I can delete, edit, or change its type.
    Here, I click into a property in the calorie record named "calories." If it's not provided in the input text, the prompt estimates its value. This property is a number, which I can delete, edit, or change its type.
    Here's what that stack looks like. I've added some random records, not actual data from real life, but this is how it appears. One thing to note is that you can use phrases like "yesterday," "today," or "two days ago," and it will adjust the date and time accordingly. It stores both the specified date and time in the field as well as the creation time, similar to how it would work in a tool like Notion.
    Here's what that stack looks like. I've added some random records, not actual data from real life, but this is how it appears. One thing to note is that you can use phrases like "yesterday," "today," or "two days ago," and it will adjust the date and time accordingly. It stores both the specified date and time in the field as well as the creation time, similar to how it would work in a tool like Notion.
    I can also prompt or add text that contains multiple instances I want to record in a stack. For example, I'm mentioning that I ate cabbage last night and Maggi yesterday morning. My hope is to record these two instances separately as different food items.
    I can also prompt or add text that contains multiple instances I want to record in a stack. For example, I'm mentioning that I ate cabbage last night and Maggi yesterday morning. My hope is to record these two instances separately as different food items.
    It does indeed. The app records two separate items at different times, estimating meal times for dinner and morning quite accurately. Overall, it captures the entire arrangement quite well.
    It does indeed. The app records two separate items at different times, estimating meal times for dinner and morning quite accurately. Overall, it captures the entire arrangement quite well.
    Here's my attempt to try out the Spanish words template. The idea here is that you aren't adding any information at all. You're just adding the word, and all of the other information is actually generated by AI. So it's not just structured extraction; it's generation and extraction combined into one, which is really cool.
    Here's my attempt to try out the Spanish words template. The idea here is that you aren't adding any information at all. You're just adding the word, and all of the other information is actually generated by AI. So it's not just structured extraction; it's generation and extraction combined into one, which is really cool.
    So, here I added the word "beuno" to the stack. I'm using the general input, so my hope is I would first determine that it needs to be added to the Spanish word stack and then add that information to the stack itself.
    So, here I added the word "beuno" to the stack. I'm using the general input, so my hope is I would first determine that it needs to be added to the Spanish word stack and then add that information to the stack itself.
    It accomplished that quite successfully, so now I have a Spanish word, created a sample sentence, explored multiple variations, chose the correct approach, and decided on the appropriate action.
    It accomplished that quite successfully, so now I have a Spanish word, created a sample sentence, explored multiple variations, chose the correct approach, and decided on the appropriate action.
    And this is what my main page looks like now, with a new stack added beyond the calorie and mood tracker.
    And this is what my main page looks like now, with a new stack added beyond the calorie and mood tracker.