Routing

In Gustwind, routes are defined using configuration. The model favors explicity over implicity and it supports nesting. Thanks to nesting, you can implement features like i18n and generate a blog or pages to the site root as in the example below.

Routes define how project layouts are combined with data sources and what kind of metadata is injected to them. In short, they are glue of each site.

The example below shows how the structure of this site has been defined:

routes.json

{
  "/": {
    "layout": "siteIndex",
    "meta": {
      "title": "Gustwind",
      "description": "Deno powered website creator"
    },
    "dataSources": [
      {
        "operation": "processMarkdown",
        "parameters": [{ "path": "./README.md" }, { "skipFirstLine": true }],
        "name": "readme"
      }
    ],
    "expand": {
      "matchBy": {
        "indexer": {
          "operation": "indexMarkdown",
          "parameters": ["./documentation"]
        },
        "dataSources": [{
          "operation": "processMarkdown",
          "parameters": [
            { "parseHeadmatter": true }
          ],
          "name": "document"
        }],
        "slug": "data.slug"
      },
      "layout": "documentationPage",
      "meta": {
        "title": {
          "utility": "get",
          "parameters": ["context", "document.data.title"]
        },
        "description": {
          "utility": "get",
          "parameters": ["context", "document.data.description"]
        }
      }
    }
  },
  "templating": {
    "layout": "readmeIndex",
    "meta": {
      "title": "HTMLisp",
      "description": "Templating in Gustwind by default is done using a combination of HTML and Lisp",
      "readmePath": "htmlisp/README.md"
    },
    "dataSources": [
      {
        "operation": "processMarkdown",
        "parameters": [
          { "path": "./htmlisp/README.md" },
          { "skipFirstLine": false }
        ],
        "name": "readme"
      }
    ],
    "scripts": [{ "name": "templatingPlayground" }]
  },
  "blog": {
    "layout": "blogIndex",
    "meta": {
      "title": "Blog",
      "description": "A blog about Gustwind"
    },
    "dataSources": [
      {
        "operation": "indexMarkdown",
        "parameters": ["./blogPosts"],
        "name": "blogPosts"
      }
    ],
    "expand": {
      "matchBy": {
        "indexer": {
          "operation": "indexMarkdown",
          "parameters": ["./blogPosts"]
        },
        "dataSources": [{
          "operation": "processMarkdown",
          "parameters": [
            { "parseHeadmatter": true }
          ],
          "name": "document"
        }],
        "slug": "data.slug"
      },
      "scripts": [{ "name": "hello" }],
      "layout": "documentationPage",
      "meta": {
        "title": {
          "utility": "get",
          "parameters": ["context", "document.data.title"]
        },
        "description": {
          "utility": "get",
          "parameters": ["context", "document.data.description"]
        }
      }
    },
    "routes": {
      "more": {
        "layout": "buttonsPage",
        "meta": {
          "title": "More Gustwind buttons",
          "description": "Another secret buttons page"
        },
        "scripts": [{ "name": "hello" }]
      }
    }
  },
  "buttons": {
    "layout": "buttonsPage",
    "meta": {
      "title": "Gustwind buttons",
      "description": "Secret buttons page"
    },
    "scripts": [{ "name": "hello" }],
    "routes": {
      "more": {
        "layout": "buttonsPage",
        "meta": {
          "title": "More Gustwind buttons",
          "description": "Another secret buttons page"
        },
        "scripts": [{ "name": "hello" }]
      }
    }
  },
  "atom.xml": {
    "layout": "rssPage",
    "meta": {
      "title": "Gustwind",
      "description": "Gustwind blog"
    },
    "dataSources": [
      {
        "operation": "indexMarkdown",
        "parameters": ["./blogPosts", { "parseHeadmatter": true }],
        "name": "blogPosts"
      }
    ]
  },
  "404.html": {
    "layout": "404",
    "meta": {
      "title": "Page not found",
      "description": "This page does not exist"
    }
  }
}

Data sources🔗

Each route can be connected to data sources through functions as defined below. The data sources are then visible at layouts and can be accessed through the templating context.

dataSources.ts

import {
  extract,
  test,
} from "https://deno.land/std@0.207.0/front_matter/yaml.ts";
import { parse } from "https://deno.land/std@0.207.0/yaml/parse.ts";
import getMarkdown from "./transforms/markdown.ts";
import { getMemo } from "../utilities/getMemo.ts";
import type { LoadApi } from "../types.ts";

type MarkdownWithFrontmatter = {
  data: {
    slug: string;
    title: string;
    date: Date;
    keywords: string[];
  };
  content: string;
};

async function init({ load }: { load: LoadApi }) {
  const markdown = getMarkdown(load);

  async function indexMarkdown(
    directory: string,
  ) {
    const files = await load.dir({
      path: directory,
      extension: ".md",
      type: "",
    });

    return Promise.all(
      files.map(async ({ path }) => ({ ...await parseHeadmatter(path), path })),
    );
  }

  async function processMarkdown(
    { path }: { path: string },
    o?: { parseHeadmatter: boolean; skipFirstLine: boolean },
  ) {
    if (o?.parseHeadmatter) {
      const headmatter = await parseHeadmatter(path);

      return { ...headmatter, ...(await parseMarkdown(headmatter.content)) };
    }

    // Markdown also parses toc but it's not needed for now
    return parseMarkdown(await load.textFile(path), o);
  }

  async function parseHeadmatter(
    path: string,
  ): Promise<MarkdownWithFrontmatter> {
    const file = await load.textFile(path);

    if (test(file)) {
      const { frontMatter, body: content } = extract(file);

      return {
        // TODO: It would be better to handle this with Zod or some other runtime checker
        data: parse(frontMatter) as MarkdownWithFrontmatter["data"],
        content,
      };
    }

    throw new Error(`path ${path} did not contain a headmatter`);
  }

  // Interestingly enough caching to fs doesn't result in a speedup
  // TODO: Investigate why not
  // const fs = await fsCache(path.join(Deno.cwd(), ".gustwind"));
  const memo = getMemo(new Map());
  function parseMarkdown(lines: string, o?: { skipFirstLine: boolean }) {
    const input = o?.skipFirstLine
      ? lines.split("\n").slice(1).join("\n")
      : lines;

    return memo(markdown, input);
  }

  return { indexMarkdown, processMarkdown };
}

export { init };

Data sources are asynchronous functions returning arrays of objects. Then, when bound, you can access the content. This would be a good spot to connect to a database, external API, or local data.