diff --git a/.gitignore b/.gitignore index 9a021c9..7c2cda3 100644 --- a/.gitignore +++ b/.gitignore @@ -113,6 +113,5 @@ dmypy.json env dist/* test/* -debug.log -webdrive.log +logs/* *.bat \ No newline at end of file diff --git a/README.md b/README.md index d60ea91..6935f0a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Some services like Super, HostingPotion, HostNotion and Fruition try to work aro - **Not free** - Super, HostingPotion and HostNotion all take a monthly fee since they manage all the "hacky bits" for you; Fruition is open-source but any domain with a decent amount of daily visit will soon clash against CloudFlare's free tier limitations, and force you to upgrade to the 5$ or more plan (plus you need to setup Cloudflare yourself) - **Slow-ish** - As the page is still hosted on Notion, it comes bundled with all their analytics, editing / collaboration javascript, vendors css, and more bloat which causes the page to load at speeds that are not exactly appropriate for a simple blog / website. Running [this](https://www.notion.so/The-perfect-It-s-Always-Sunny-in-Philadelphia-episode-d08aaec2b24946408e8be0e9f2ae857e) example page on Google's [PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights/) scores a measly **24 - 66** on mobile / desktop. - **Ugly URLs** - While the services above enable the use of custom domains, the URLs for individual pages are stuck with the long, ugly, original Notion URL (apart from Fruition - they got custom URLs figured out, altough you will always see the original URL flashing for an instant when the page is loaded). +- **Notion Free Account Limitations** - Recently Notion introduced a change to its pricing model where public pages can't be set to be indexed by search engines on a free account (but they also removed the blocks count limitations, which is a good trade-off if you ask me) Loconotion approaches this a bit differently. It lets Notion render the page, then scrapes it and saves a static version of the page to disk. This offers the following benefits: - Strips out all the unnecessary bloat, like Notion's analytics, vendors scripts / styles, and javascript left in to enable collaboration. @@ -28,11 +29,16 @@ Bear in mind that as we are effectively parsing a static version of the page, th Everything else should be fine. Loconotion rebuilds the logic for toggle boxes and embeds so they still work; plus it defines some additional CSS rules to enable mobile responsiveness across the whole site (in some cases looking even better than Notion's defaults - wasn't exactly thought for mobile). +### But Notion already had an html export function? +It does, but I wasn't really happy with the styling - the pages looked a bit uglier than what they look like on a live Notion page. Plus, it doesn't support all the cool customization features outlined above! + ## Installation & Requirements `pip install -r requirements.txt` This script uses [ChromeDriver](chromedriver.chromium.org) to automate the Google Chrome browser - therefore Google Chrome needs to be installed in order to work. +The script comes bundled with the default windows chromedriver executable. On Max / Linux, download the right distribution for you from https://chromedriver.chromium.org/downloads and place the executable in this folder. Alternatively, use the `--chromedriver` argument to specify its path at runtime + ## Simple Usage `python loconotion.py https://www.notion.so/The-perfect-It-s-Always-Sunny-in-Philadelphia-episode-d08aaec2b24946408e8be0e9f2ae857e` @@ -44,15 +50,24 @@ You can fully configure Loconotion to your needs by passing a [.toml](https://gi Here's what a full configuration would look like, alongside with explanations for each parameter. ```toml +## Loconotion Site Configuration File ## +# full .toml configuration example file to showcase all of Loconotion's available settings +# check out https://github.com/toml-lang/toml for more info on the toml format + +# name of the folder that the site will be generated in name = "Notion Test Site" # the notion.so page to being parsing from. This page will become the index.html # of the generated site, and loconotation will parse all sub-pages present on the page. page = "https://www.notion.so/A-Notion-Page-03c403f4fdc94cc1b315b9469a8950ef" -# this site table defines override settings for the whole site +## Global Site Settings ## +# this [site] table defines override settings for the whole site # later on we will see how to define settings for a single page [site] - ## custom meta tags ## + ## Custom Meta Tags ## + # defined as an array of tables (double square brackets) + # each key in the table maps to an atttribute in the tag + # the following adds the tag [[site.meta]] name = "title" content = "Loconotion Test Site" @@ -60,10 +75,10 @@ page = "https://www.notion.so/A-Notion-Page-03c403f4fdc94cc1b315b9469a8950ef" name = "description" content = "A static site generated from a Notion.so page using Loconotion" - ## custom site fonts ## - # you can specify the name of a google font to use on the site, use the font embed name - # (if in doubt select a style on fonts.google.com and navigate to the "embed" tag to check the name under CSS rules) - # keys controls the font of the following elements: + ## Custom Fonts ## + # you can specify the name of a google font to use on the site - use the font embed name + # if in doubt select a style on fonts.google.com and navigate to the "embed" tag to check the name under CSS rules + # the table keys controls the font of the following elements: # site: changes the font for the whole page (apart from code blocks) but the following settings override it # navbar: site breadcrumbs on the top-left of the page # title: page title (under the icon) @@ -73,19 +88,19 @@ page = "https://www.notion.so/A-Notion-Page-03c403f4fdc94cc1b315b9469a8950ef" # body: non-heading text on the page # code: text inside code blocks [site.fonts] - site = 'Roboto' + site = 'Lato' navbar = '' title = 'Montserrat' h1 = 'Montserrat' h2 = 'Montserrat' - h3 = '' + h3 = 'Montserrat' body = '' code = '' - ## custom element injection ## - # 'head' or 'body' to set where the element will be injected - # the next dotted key represents the tag to inject, with the table values being the the tag attributes - # e.g. the following injects in the
+ ## Custom Element Injection ## + # defined as an array of tables [[site.inject]], followed by 'head' or 'body' to set where the injection point, + # followed by name of the tag to inject. Each key in the table maps to an atttribute in the tag + # the following injects in the [[site.inject.head.link]] rel="icon" sizes="16x16" @@ -97,33 +112,37 @@ page = "https://www.notion.so/A-Notion-Page-03c403f4fdc94cc1b315b9469a8950ef" type="text/javascript" src="/example/custom-script.js" -## individual page settings ## -# while the [site] table applies the settings to all parse pages, -# it's possible to override a single page's setting by defining -# a table named after the page url or part of it. -# -# e.g the following settings will only apply to this parsed page: -# https://www.notion.so/d2fa06f244e64f66880bb0491f58223d -[d2fa06f244e64f66880bb0491f58223d] - ## custom slugs ## - # inside page settings, you can change the url that page will map to with the 'slug' key - # e.g. page "/d2fa06f244e64f66880bb0491f58223d" will now map to "/list" - slug = "list" +## Individual Page Settings ## +# the [pages] table defines override settings for individual pages, by defining a sub-table named after the page url +# (or part of the url, but careful into not use a string that appears in multiple page urls) +[pages] + # the following settings will only apply to this page: https://www.notion.so/d2fa06f244e64f66880bb0491f58223d + [pages.d2fa06f244e64f66880bb0491f58223d] + ## custom slugs ## + # inside page settings, you can change the url that page will map to with the 'slug' key + # e.g. page "/d2fa06f244e64f66880bb0491f58223d" will now map to "/list" + slug = "list" - [[d2fa06f244e64f66880bb0491f58223d.meta]] - # change the description meta tag for this page only - name = "description" - content = "A fullscreen list database page, now with a pretty slug" + # change the description meta tag for this page only + [[pages.d2fa06f244e64f66880bb0491f58223d.meta]] + name = "description" + content = "A fullscreen list database page, now with a pretty slug" - [d2fa06f244e64f66880bb0491f58223d.fonts] - # change the title font for this page only - title = 'Nunito' + # change the title font for this page only + [pages.d2fa06f244e64f66880bb0491f58223d.fonts] + title = 'Nunito' + + # for smaller sets of settings you can use inline notation + # 2483a3a5c3fd445980c1adc8e550b552.slug = "gallery" + # 2604ce45890645c79f67d92833083fee.slug = "table" + # a28dba2e7a67448da52f2cd2c641407b.slug = "board" ``` -On top of this, the script can take a few extra arguments: +On top of this, the script can take this optional arguments: ``` --clean Delete all previously cached files for the site before generating it -v, --verbose Shows way more exciting facts in the output + --single-page Don't parse sub-pages ``` ## Roadmap / Features wishlist diff --git a/example/example_site.toml b/example/example_site.toml new file mode 100644 index 0000000..394c9bf --- /dev/null +++ b/example/example_site.toml @@ -0,0 +1,90 @@ +## Loconotion Site Configuration File ## +# full .toml configuration example file to showcase all of Loconotion's available settings +# check out https://github.com/toml-lang/toml for more info on the toml format + +# name of the folder that the site will be generated in +name = "Notion Test Site" +# the notion.so page to being parsing from. This page will become the index.html +# of the generated site, and loconotation will parse all sub-pages present on the page. +page = "https://www.notion.so/Loconotion-Example-Page-03c403f4fdc94cc1b315b9469a8950ef" + +## Global Site Settings ## +# this [site] table defines override settings for the whole site +# later on we will see how to define settings for a single page +[site] + ## Custom Meta Tags ## + # defined as an array of tables (double square brackets) + # each key in the table maps to an atttribute in the tag + # the following adds the tag + [[site.meta]] + name = "title" + content = "Loconotion Test Site" + [[site.meta]] + name = "description" + content = "A static site generated from a Notion.so page using Loconotion" + + ## Custom Fonts ## + # you can specify the name of a google font to use on the site - use the font embed name + # if in doubt select a style on fonts.google.com and navigate to the "embed" tag to check the name under CSS rules + # the table keys controls the font of the following elements: + # site: changes the font for the whole page (apart from code blocks) but the following settings override it + # navbar: site breadcrumbs on the top-left of the page + # title: page title (under the icon) + # h1: heading blocks, and inline databases' titles + # h2: sub-heading blocks + # h3: sub-sub-heading blocks + # body: non-heading text on the page + # code: text inside code blocks + [site.fonts] + site = 'Lato' + navbar = '' + title = 'Montserrat' + h1 = 'Montserrat' + h2 = 'Montserrat' + h3 = 'Montserrat' + body = '' + code = '' + + ## Custom Element Injection ## + # defined as an array of tables [[site.inject]], followed by 'head' or 'body' to set where the injection point, + # followed by name of the tag to inject. Each key in the table maps to an atttribute in the tag + # the following injects in the + [[site.inject.head.link]] + rel="icon" + sizes="16x16" + type="image/png" + href="/example/favicon-16x16.png" + + # the following injects in the + # note that all href / src files are copied to the root of the site folder regardless of their original path + [[site.inject.body.script]] + type="text/javascript" + src="/example/custom-script.js" + +## Individual Page Settings ## +# the [pages] table defines override settings for individual pages, by defining a sub-table named after the page url +# (or part of the url, but careful into not use a string that appears in multiple page urls) +[pages] + # the following settings will only apply to this page: https://www.notion.so/d2fa06f244e64f66880bb0491f58223d + [pages.d2fa06f244e64f66880bb0491f58223d] + ## custom slugs ## + # inside page settings, you can change the url that page will map to with the 'slug' key + # e.g. page "/d2fa06f244e64f66880bb0491f58223d" will now map to "/games-list" + slug = "games-list" + + # change the description meta tag for this page only + [[pages.d2fa06f244e64f66880bb0491f58223d.meta]] + name = "description" + content = "A fullscreen list database page, now with a pretty slug" + + # change the title font for this page only + [pages.d2fa06f244e64f66880bb0491f58223d.fonts] + title = 'Nunito' + + # set up pretty slugs for the other database pages + [pages.54dab6011e604430a21dc477cb8e4e3a] + slug = "film-gallery" + [pages.2604ce45890645c79f67d92833083fee] + slug = "books-table" + [pages.ae0a85c527824a3a855b7f4d31f4e0fc] + slug = "random-board" \ No newline at end of file diff --git a/loconotion.js b/loconotion.js index c37581e..22695cc 100644 --- a/loconotion.js +++ b/loconotion.js @@ -64,3 +64,18 @@ for (let i = 0; i < collectionSearchBoxes.length; i++) { const collectionSearchBox = collectionSearchBoxes.item(i).parentElement; collectionSearchBox.style.display = "none"; } + +const anchorLinks = document.querySelectorAll("a.loconotion-anchor-link"); +for (let i = 0; i < anchorLinks.length; i++) { + const anchorLink = anchorLinks.item(i); + const id = anchorLink.getAttribute("href").replace("#", ""); + const targetBlockId = + id.slice(0, 8) + "-" + id.slice(8, 12) + "-" + id.slice(12, 16) + "-" + id.slice(16, 20) + "-" + id.slice(20); + anchorLink.addEventListener("click", (e) => { + e.preventDefault(); + document.querySelector(targetBlockId).scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }); +} diff --git a/loconotion.py b/loconotion.py index 45d6d18..b791009 100644 --- a/loconotion.py +++ b/loconotion.py @@ -1,4 +1,5 @@ import os +import platform import sys import shutil import time @@ -12,21 +13,25 @@ import hashlib import argparse from pathlib import Path -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.common.exceptions import TimeoutException, NoSuchElementException -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from bs4 import BeautifulSoup - -import requests -import toml -import cssutils -cssutils.log.setLevel(logging.CRITICAL) # removes warning logs from cssutils - log = logging.getLogger(__name__) +try: + from selenium import webdriver + from selenium.webdriver.chrome.options import Options + from selenium.common.exceptions import TimeoutException, NoSuchElementException + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + from bs4 import BeautifulSoup + + import requests + import toml + import cssutils + cssutils.log.setLevel(logging.CRITICAL) # removes warning logs from cssutils +except ModuleNotFoundError as error: + log.critical(f"ModuleNotFoundError: {error}. have your installed the requirements?") + sys.exit() + class notion_page_loaded(object): """An expectation for checking that a notion page has loaded. """ @@ -167,29 +172,34 @@ class Parser(): if not matching_file: # if url has a network scheme, download the file if "http" in urllib.parse.urlparse(url).scheme: - # Disabling proxy speeds up requests time - # https://stackoverflow.com/questions/45783655/first-https-request-takes-much-more-time-than-the-rest - # https://stackoverflow.com/questions/28521535/requests-how-to-disable-bypass-proxy - session = requests.Session() - session.trust_env = False - log.info(f"Downloading '{url}'") - response = session.get(url) + try: + # Disabling proxy speeds up requests time + # https://stackoverflow.com/questions/45783655/first-https-request-takes-much-more-time-than-the-rest + # https://stackoverflow.com/questions/28521535/requests-how-to-disable-bypass-proxy + session = requests.Session() + session.trust_env = False + log.info(f"Downloading '{url}'") + response = session.get(url) - # if the filename does not have an extension at this point, - # try to infer it from the url, and if not possible, - # from the content-type header mimetype - if (not destination.suffix): - file_extension = Path(urllib.parse.urlparse(url).path).suffix - if (not file_extension): - content_type = response.headers.get('content-type') - file_extension = mimetypes.guess_extension(content_type) - destination = destination.with_suffix(file_extension) + # if the filename does not have an extension at this point, + # try to infer it from the url, and if not possible, + # from the content-type header mimetype + if (not destination.suffix): + file_extension = Path(urllib.parse.urlparse(url).path).suffix + if (not file_extension): + content_type = response.headers.get('content-type') + if (content_type): + file_extension = mimetypes.guess_extension(content_types) + destination = destination.with_suffix(file_extension) - Path(destination).parent.mkdir(parents=True, exist_ok=True) - with open(destination, "wb") as f: - f.write(response.content) - - return destination.relative_to(self.dist_folder) + Path(destination).parent.mkdir(parents=True, exist_ok=True) + with open(destination, "wb") as f: + f.write(response.content) + + return destination.relative_to(self.dist_folder) + except Exception as error: + log.error(f"Error downloading file '{url}': {error}") + return url # if not, check if it's a local file, and copy it to the dist folder else: if Path(url).is_file(): @@ -202,10 +212,24 @@ class Parser(): cached_file = Path(matching_file[0]).relative_to(self.dist_folder) log.debug(f"'{url}' was already downloaded") return cached_file - # if all fails, return the original url - return url def init_chromedriver(self): + exec_extension = ".exe" if platform.system() == "Windows" else "" + chromedriver_path = Path.cwd() / self.args.get("chromedriver") + + # add the .exe extension on Windows if omitted + if (not chromedriver_path.suffix): + chromedriver_path = chromedriver_path.with_suffix(exec_extension) + + # check the chromedriver executable exists + if (not chromedriver_path.is_file()): + log.critical(f"Chromedriver not found at {chromedriver_path}." + + " Download the correct distribution at https://chromedriver.chromium.org/downloads") + sys.exit() + + logs_path = (Path.cwd() / "logs" / "webdrive.log") + logs_path.parent.mkdir(parents=True, exist_ok=True) + log.info("Initialising chrome driver") chrome_options = Options() chrome_options.add_argument("--headless") @@ -213,11 +237,11 @@ class Parser(): chrome_options.add_argument("--log-level=3"); chrome_options.add_argument("--silent"); chrome_options.add_argument("--disable-logging") - # removes the 'DevTools listening' log message + # removes the 'DevTools listening' log message chrome_options.add_experimental_option('excludeSwitches', ['enable-logging']) return webdriver.Chrome( - executable_path=str(Path.cwd() / "bin" / "chromedriver.exe"), - service_log_path=str(Path.cwd() / "webdrive.log"), + executable_path=str(chromedriver_path), + service_log_path=str(logs_path), options=chrome_options) def parse_page(self, url, processed_pages = {}, index = None): @@ -429,8 +453,18 @@ class Parser(): for a in soup.findAll('a'): if a['href'].startswith('/'): sub_page_href = 'https://www.notion.so' + a['href'] + # if the link is an anchor link, check if the page hasn't already been parsed + if ("#" in sub_page_href): + sub_page_href_tokens = sub_page_href.split("#") + sub_page_href = sub_page_href_tokens[0] + a['href'] = "#" + sub_page_href_tokens[-1] + a['class'] = a.get('class', []) + ['loconotion-anchor-link'] + if (sub_page_href in processed_pages.keys() or sub_page_href in sub_pages): + log.debug(f"Original page for anchor link {sub_page_href} already parsed / pending parsing, skipping") + continue + else: + a['href'] = self.get_page_slug(sub_page_href) if sub_page_href != index else "index.html" sub_pages.append(sub_page_href) - a['href'] = self.get_page_slug(sub_page_href) if sub_page_href != index else "index.html" log.debug(f"Found link to page {a['href']}") # exports the parsed page @@ -446,7 +480,7 @@ class Parser(): # parse sub-pages if (sub_pages and not self.args.get("single_page", False)): - if (processed_pages): log.debug(f"Pages processed so far: {processed_pages}") + if (processed_pages): log.debug(f"Pages processed so far: {len(processed_pages)}") for sub_page in sub_pages: if not sub_page in processed_pages.keys(): self.parse_page(sub_page, processed_pages = processed_pages, index = index) @@ -465,44 +499,48 @@ if __name__ == '__main__': # set up argument parser parser = argparse.ArgumentParser(description='Generate static websites from Notion.so pages') parser.add_argument('target', help='The config file containing the site properties, or the url of the Notion.so page to generate the site from') + parser.add_argument('--chromedriver', default='bin/chromedriver', help='Path to the chromedriver executable') + parser.add_argument("--single-page", action="store_true", default=False, help="Only parse the first page, then stop") parser.add_argument('--clean', action='store_true', default=False, help='Delete all previously cached files for the site before generating it') parser.add_argument("-v", "--verbose", action="store_true", help="Shows way more exciting facts in the output") - parser.add_argument("--single-page", action="store_true", help="Don't parse sub-pages") args = parser.parse_args() # set up some pretty logs - import colorama, copy - - LOG_COLORS = { - logging.DEBUG: colorama.Fore.GREEN, - logging.INFO: colorama.Fore.BLUE, - logging.WARNING: colorama.Fore.YELLOW, - logging.ERROR: colorama.Fore.RED, - logging.CRITICAL: colorama.Back.RED - } - - class ColorFormatter(logging.Formatter): - def format(self, record, *args, **kwargs): - # if the corresponding logger has children, they may receive modified - # record, so we want to keep it intact - new_record = copy.copy(record) - if new_record.levelno in LOG_COLORS: - new_record.levelname = "{color_begin}{level}{color_end}".format( - level=new_record.levelname, - color_begin=LOG_COLORS[new_record.levelno], - color_end=colorama.Style.RESET_ALL, - ) - return super(ColorFormatter, self).format(new_record, *args, **kwargs) - - log_screen_handler = logging.StreamHandler(stream=sys.stdout) - log_screen_handler.setFormatter(ColorFormatter(fmt='%(asctime)s %(levelname)-8s %(message)s', - datefmt="{color_begin}[%H:%M:%S]{color_end}".format( - color_begin=colorama.Style.DIM, - color_end=colorama.Style.RESET_ALL - ))) log = logging.getLogger(__name__) log.setLevel(logging.INFO if not args.verbose else logging.DEBUG) + log_screen_handler = logging.StreamHandler(stream=sys.stdout) log.addHandler(log_screen_handler) + try: + import colorama, copy + + LOG_COLORS = { + logging.DEBUG: colorama.Fore.GREEN, + logging.INFO: colorama.Fore.BLUE, + logging.WARNING: colorama.Fore.YELLOW, + logging.ERROR: colorama.Fore.RED, + logging.CRITICAL: colorama.Back.RED + } + + class ColorFormatter(logging.Formatter): + def format(self, record, *args, **kwargs): + # if the corresponding logger has children, they may receive modified + # record, so we want to keep it intact + new_record = copy.copy(record) + if new_record.levelno in LOG_COLORS: + new_record.levelname = "{color_begin}{level}{color_end}".format( + level=new_record.levelname, + color_begin=LOG_COLORS[new_record.levelno], + color_end=colorama.Style.RESET_ALL, + ) + return super(ColorFormatter, self).format(new_record, *args, **kwargs) + + log_screen_handler.setFormatter(ColorFormatter(fmt='%(asctime)s %(levelname)-8s %(message)s', + datefmt="{color_begin}[%H:%M:%S]{color_end}".format( + color_begin=colorama.Style.DIM, + color_end=colorama.Style.RESET_ALL + ))) + except ModuleNotFoundError as identifier: + pass # parse the provided arguments try: diff --git a/test_site.toml b/test_site.toml deleted file mode 100644 index f69c7ca..0000000 --- a/test_site.toml +++ /dev/null @@ -1,76 +0,0 @@ -name = "Notion Test Site" -# the notion.so page to being parsing from. This page will become the index.html -# of the generated site, and loconotation will parse all sub-pages present on the page. -page = "https://www.notion.so/A-Notion-Page-03c403f4fdc94cc1b315b9469a8950ef" - -# this site table defines override settings for the whole site -# later on we will see how to define settings for a single page -[site] - ## custom meta tags ## - [[site.meta]] - name = "title" - content = "Loconotion Test Site" - [[site.meta]] - name = "description" - content = "A static site generated from a Notion.so page using Loconotion" - - ## custom site fonts ## - # you can specify the name of a google font to use on the site, use the font embed name - # (if in doubt select a style on fonts.google.com and navigate to the "embed" tag to check the name under - # CSS rules) - # keys controls the font of the following elements: - # site: changes the font for the whole page (apart from code blocks) but the following settings override it - # navbar: site breadcrumbs on the top-left of the page - # title: page title (under the icon) - # h1: heading blocks, and inline databases' titles - # h2: sub-heading blocks - # h3: sub-sub-heading blocks - # body: non-heading text on the page - # code: text inside code blocks - [site.fonts] - site = 'Roboto' - navbar = '' - title = 'Montserrat' - h1 = 'Montserrat' - h2 = 'Montserrat' - h3 = '' - body = '' - code = '' - - ## custom element injection ## - # 'head' or 'body' to set where the element will be injected - # the next dotted key represents the tag to inject, with the table values being the the tag attributes - # e.g. the following injects in the - [[site.inject.head.link]] - rel="icon" - sizes="16x16" - type="image/png" - href="/example/favicon-16x16.png" - - # the following injects in the - [[site.inject.body.script]] - type="text/javascript" - src="/example/custom-script.js" - -## individual page settings ## -# while the [site] table applies the settings to all parse pages, -# it's possible to override a single page's setting by defining -# a table named after the page url or part of it. -# -# e.g the following settings will only apply to this parsed page: -# https://www.notion.so/d2fa06f244e64f66880bb0491f58223d -[pages] - [pages.d2fa06f244e64f66880bb0491f58223d] - ## custom slugs ## - # inside page settings, you can change the url that page will map to with the 'slug' key - # e.g. page "/d2fa06f244e64f66880bb0491f58223d" will now map to "/list" - slug = "list" - - [[pages.d2fa06f244e64f66880bb0491f58223d.meta]] - # change the description meta tag for this page only - name = "description" - content = "A fullscreen list database page, now with a pretty slug" - - [pages.d2fa06f244e64f66880bb0491f58223d.fonts] - # change the title font for this page only - title = 'Nunito' \ No newline at end of file