From 81347fc816ee1a2faac7e7546b22c3b4f3886110 Mon Sep 17 00:00:00 2001 From: Emil Sayahi <97276123+emmyoh@users.noreply.github.com> Date: Wed, 29 May 2024 18:31:29 -0400 Subject: [PATCH] feat: First developer diary entry TODO: - Parallelise building - `meta` context - Write documentation --- .github/workflows/rust.yml | 53 ++- .gitignore | 5 +- Cargo.toml | 1 + site/404.vox | 10 + site/assets/style/_base.scss | 70 +++ site/assets/style/_code.scss | 58 +++ site/assets/style/_layout.scss | 16 + site/assets/style/_masthead.scss | 23 + site/assets/style/_message.scss | 12 + site/assets/style/_pagination.scss | 52 +++ site/assets/style/_posts.scss | 67 +++ site/assets/style/_syntax.scss | 65 +++ site/assets/style/_toc.scss | 16 + site/assets/style/_type.scss | 115 +++++ site/assets/style/_variables.scss | 66 +++ site/assets/style/styles.scss | 42 ++ site/diary/dag_watching.vox | 112 +++++ site/global.toml | 6 + site/index.vox | 36 ++ site/layouts/default.vox | 24 + site/layouts/page.vox | 8 + site/layouts/post.vox | 9 + site/pages/diary_atom.vox | 5 + site/pages/diary_index.vox | 19 + site/prebuild.sh | 3 + site/snippets/atom.voxs | 22 + site/snippets/head.voxs | 16 + src/builds.rs | 42 +- src/main.rs | 685 ++++++++++++++++------------- src/markdown_block.rs | 47 +- src/page.rs | 40 ++ 31 files changed, 1392 insertions(+), 353 deletions(-) create mode 100644 site/404.vox create mode 100644 site/assets/style/_base.scss create mode 100644 site/assets/style/_code.scss create mode 100644 site/assets/style/_layout.scss create mode 100644 site/assets/style/_masthead.scss create mode 100644 site/assets/style/_message.scss create mode 100644 site/assets/style/_pagination.scss create mode 100644 site/assets/style/_posts.scss create mode 100644 site/assets/style/_syntax.scss create mode 100644 site/assets/style/_toc.scss create mode 100644 site/assets/style/_type.scss create mode 100644 site/assets/style/_variables.scss create mode 100644 site/assets/style/styles.scss create mode 100644 site/diary/dag_watching.vox create mode 100644 site/global.toml create mode 100644 site/index.vox create mode 100644 site/layouts/default.vox create mode 100644 site/layouts/page.vox create mode 100644 site/layouts/post.vox create mode 100644 site/pages/diary_atom.vox create mode 100644 site/pages/diary_index.vox create mode 100755 site/prebuild.sh create mode 100644 site/snippets/atom.voxs create mode 100644 site/snippets/head.voxs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5d5cd34..339cb81 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -4,22 +4,51 @@ on: branches: [ "master" ] env: CARGO_TERM_COLOR: always + GH_TOKEN: ${{ github.token }} jobs: - build_documentation: - name: Build documentation + build_site: + name: Build site runs-on: ubuntu-latest steps: - - name: Setup Rust toolchain - uses: actions-rs/toolchain@v1 + # - name: Setup Rust toolchain + # uses: actions-rs/toolchain@v1 + # with: + # toolchain: nightly + # target: x86_64-unknown-linux-gnu + # default: true + # profile: default + - name: Get dependency information + run: | + gh api /repos/emmyoh/vox/commits/master --jq '.sha' > vox_rev + curl https://crates.io/api/v1/crates/grass > grass_rev + - name: Restore Cargo cache + id: cache-cargo + uses: actions/cache@v1 with: - toolchain: nightly - target: x86_64-unknown-linux-gnu - default: true - profile: default + path: ~/.cargo + key: ${{ runner.os }}-cargo-${{ hashFiles('vox_rev', 'grass_rev') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ hashFiles('vox_rev', 'grass_rev') }} + - if: ${{ steps.cache-cargo.outputs.cache-hit != 'true' }} + name: Install Grass and Vox + run: | + rm vox_rev + rm grass_rev + rustup update nightly && rustup default nightly + time cargo install grass + time cargo install --git https://github.com/emmyoh/vox --features="cli" - name: Checkout codebase uses: actions/checkout@v4 - name: Generate documentation run: time cargo doc --no-deps -Zrustdoc-map --release --quiet + - name: Build site + run: | + mkdir -p site/output + cp -r target/doc/* site/output/ + cd site + ./prebuild.sh + vox build -d + cd ../ - name: Fix permissions run: | chmod -c -R +rX "target/doc/" | while read line; do @@ -28,10 +57,10 @@ jobs: - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: - path: "target/doc/" - deploy_documentation: - needs: build_documentation - name: Deploy documentation to GitHub Pages + path: "site/output/" + deploy_site: + needs: build_site + name: Deploy to GitHub Pages permissions: pages: write id-token: write diff --git a/.gitignore b/.gitignore index dc5f487..85f5c4f 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,7 @@ Cargo.lock **/*.rs.bk # MSVC Windows builds of rustc generate these, which store debugging information -*.pdb \ No newline at end of file +*.pdb + +## Vox +site/output/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index da2f48c..8101166 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ notify-debouncer-full = { version = "0.3.1", default-features = false, optional actix-files = { version = "0.6.5", optional = true } actix-web = { version = "4.6.0", optional = true } layout-rs = "0.1.2" +html-escape = "0.2.13" [features] default = [] diff --git a/site/404.vox b/site/404.vox new file mode 100644 index 0000000..7b3a438 --- /dev/null +++ b/site/404.vox @@ -0,0 +1,10 @@ +--- +layout = "default" +title = "Page not found" +permalink = "404.html" +--- + +
+

Page not found

+

Return to the home page.

+
\ No newline at end of file diff --git a/site/assets/style/_base.scss b/site/assets/style/_base.scss new file mode 100644 index 0000000..05e71db --- /dev/null +++ b/site/assets/style/_base.scss @@ -0,0 +1,70 @@ +// Body resets +// +// Update the foundational and global aspects of the page. + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--body-font); + font-size: var(--body-font-size); + line-height: var(--body-line-height); + color: var(--body-color); + background-color: var(--body-bg); + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +// No `:visited` state is required by default (browsers will use `a`) +a { + color: var(--link-color); + + // `:focus` is linked to `:hover` for basic accessibility + &:hover, + &:focus { + color: var(--link-hover-color); + } + + strong { + color: inherit; + } +} + +img { + display: block; + max-width: 100%; + margin-bottom: var(--spacer); + border-radius: var(--border-radius); +} + +table { + margin-bottom: 1rem; + width: 100%; + border: 0 solid var(--border-color); + border-collapse: collapse; +} + +td, +th { + padding: .25rem .5rem; + border-color: inherit; + border-style: solid; + border-width: 0; + border-bottom-width: 1px; +} + +th { + text-align: left; +} + +thead th { + border-bottom-color: currentColor; +} + +mark { + padding: .15rem; + background-color: var(--yellow-100); + border-radius: .125rem; +} diff --git a/site/assets/style/_code.scss b/site/assets/style/_code.scss new file mode 100644 index 0000000..edb007d --- /dev/null +++ b/site/assets/style/_code.scss @@ -0,0 +1,58 @@ +// Code +// +// Inline and block-level code snippets. Includes tweaks to syntax highlighted +// snippets from Pygments/Rouge and Gist embeds. + +code, +pre { + font-family: var(--code-font); +} + +code { + font-size: 85%; +} + +pre { + display: block; + margin-top: 0; + margin-bottom: var(--spacer-3); + overflow: auto; +} + +.highlight { + padding: var(--spacer); + margin-bottom: var(--spacer); + background-color: var(--code-bg); + border-radius: var(--border-radius); + + pre { + margin-bottom: 0; + } + + // Triple backticks (code fencing) doubles the .highlight elements + .highlight { + padding: 0; + } +} + +.rouge-table { + margin-bottom: 0; + font-size: 100%; + + &, + td, + th { + border: 0; + } + + .gutter { + vertical-align: top; + user-select: none; + opacity: .25; + } +} + +// Gist via GitHub Pages +.gist .markdown-body { + padding: 15px !important; +} diff --git a/site/assets/style/_layout.scss b/site/assets/style/_layout.scss new file mode 100644 index 0000000..8e74c0e --- /dev/null +++ b/site/assets/style/_layout.scss @@ -0,0 +1,16 @@ +// Layout +// +// Styles for managing the structural hierarchy of the site. + +.container { + max-width: 45rem; + padding-left: var(--spacer-2); + padding-right: var(--spacer-2); + margin-left: auto; + margin-right: auto; +} + +footer { + margin-top: var(--spacer-3); + margin-bottom: var(--spacer-3); +} diff --git a/site/assets/style/_masthead.scss b/site/assets/style/_masthead.scss new file mode 100644 index 0000000..096abb7 --- /dev/null +++ b/site/assets/style/_masthead.scss @@ -0,0 +1,23 @@ +// Masthead +// +// Super small header above the content for site name and short description. + +.masthead { + padding-top: var(--spacer); + padding-bottom: var(--spacer); + margin-bottom: var(--spacer-3); +} + +.masthead-title { + margin-bottom: 0; + + a { + color: inherit; + text-decoration: none; + } + + small { + font-weight: 400; + opacity: .5; + } +} diff --git a/site/assets/style/_message.scss b/site/assets/style/_message.scss new file mode 100644 index 0000000..ac1d93b --- /dev/null +++ b/site/assets/style/_message.scss @@ -0,0 +1,12 @@ +// Messages +// +// Show alert messages to users. You may add it to single elements like a `

`, +// or to a parent if there are multiple elements to show. + +.message { + padding: var(--spacer); + margin-bottom: var(--spacer); + color: var(--gray-900); + background-color: var(--yellow-100); + border-radius: var(--border-radius); +} diff --git a/site/assets/style/_pagination.scss b/site/assets/style/_pagination.scss new file mode 100644 index 0000000..6ef79b3 --- /dev/null +++ b/site/assets/style/_pagination.scss @@ -0,0 +1,52 @@ +// Pagination +// +// Super lightweight (HTML-wise) blog pagination. `span`s are provide for when +// there are no more previous or next posts to show. + +.pagination { + display: flex; + margin: 0 -1.5rem var(--spacer); + color: var(--gray-500); + text-align: center; +} + +// Pagination items can be `span`s or `a`s +.pagination-item { + display: block; + padding: var(--spacer); + text-decoration: none; + border: solid var(--border-color); + border-width: 1px 0; + + &:first-child { + margin-bottom: -1px; + } +} + +// Only provide a hover state for linked pagination items +a.pagination-item:hover { + background-color: var(--border-color); +} + +@media (min-width: 30em) { + .pagination { + margin: var(--spacer-3) 0; + } + + .pagination-item { + float: left; + width: 50%; + border-width: 1px; + + &:first-child { + margin-bottom: 0; + border-top-left-radius: var(--border-radius); + border-bottom-left-radius: var(--border-radius); + } + &:last-child { + margin-left: -1px; + border-top-right-radius: var(--border-radius); + border-bottom-right-radius: var(--border-radius); + } + } +} diff --git a/site/assets/style/_posts.scss b/site/assets/style/_posts.scss new file mode 100644 index 0000000..1ef5201 --- /dev/null +++ b/site/assets/style/_posts.scss @@ -0,0 +1,67 @@ +// Posts and pages +// +// Each post is wrapped in `.post` and is used on default and post layouts. Each +// page is wrapped in `.page` and is only used on the page layout. + +.page, +.post { + margin-bottom: 4em; + + li + li { + margin-top: .25rem; + } +} + +// Blog post or page title +.page-title, +.post-title { + color: var(--heading-color); +} +.page-title, +.post-title { + margin-top: 0; +} +.post-title a { + color: inherit; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } +} + +// Meta data line below post title +.post-date { + display: block; + margin-top: -.5rem; + margin-bottom: var(--spacer); + color: var(--gray-600); +} + + +// Related posts +.related { + padding-top: var(--spacer-2); + padding-bottom: var(--spacer-2); + margin-bottom: var(--spacer-2); + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); +} + +.related-posts { + padding-left: 0; + list-style: none; + + h3 { + margin-top: 0; + } + + a { + text-decoration: none; + + small { + color: var(--gray-600); + } + } +} diff --git a/site/assets/style/_syntax.scss b/site/assets/style/_syntax.scss new file mode 100644 index 0000000..15ad797 --- /dev/null +++ b/site/assets/style/_syntax.scss @@ -0,0 +1,65 @@ +.highlight .hll { background-color: #ffc; } +.highlight .c { color: #999; } /* Comment */ +.highlight .err { color: #a00; background-color: #faa } /* Error */ +.highlight .k { color: #069; } /* Keyword */ +.highlight .o { color: #555 } /* Operator */ +.highlight .cm { color: #09f; font-style: italic } /* Comment.Multiline */ +.highlight .cp { color: #099 } /* Comment.Preproc */ +.highlight .c1 { color: #999; } /* Comment.Single */ +.highlight .cs { color: #999; } /* Comment.Special */ +.highlight .gd { background-color: #fcc; border: 1px solid #c00 } /* Generic.Deleted */ +.highlight .ge { font-style: italic } /* Generic.Emph */ +.highlight .gr { color: #f00 } /* Generic.Error */ +.highlight .gh { color: #030; } /* Generic.Heading */ +.highlight .gi { background-color: #cfc; border: 1px solid #0c0 } /* Generic.Inserted */ +.highlight .go { color: #aaa } /* Generic.Output */ +.highlight .gp { color: #009; } /* Generic.Prompt */ +.highlight .gs { } /* Generic.Strong */ +.highlight .gu { color: #030; } /* Generic.Subheading */ +.highlight .gt { color: #9c6 } /* Generic.Traceback */ +.highlight .kc { color: #069; } /* Keyword.Constant */ +.highlight .kd { color: #069; } /* Keyword.Declaration */ +.highlight .kn { color: #069; } /* Keyword.Namespace */ +.highlight .kp { color: #069 } /* Keyword.Pseudo */ +.highlight .kr { color: #069; } /* Keyword.Reserved */ +.highlight .kt { color: #078; } /* Keyword.Type */ +.highlight .m { color: #f60 } /* Literal.Number */ +.highlight .s { color: #d44950 } /* Literal.String */ +.highlight .na { color: #4f9fcf } /* Name.Attribute */ +.highlight .nb { color: #366 } /* Name.Builtin */ +.highlight .nc { color: #0a8; } /* Name.Class */ +.highlight .no { color: #360 } /* Name.Constant */ +.highlight .nd { color: #99f } /* Name.Decorator */ +.highlight .ni { color: #999; } /* Name.Entity */ +.highlight .ne { color: #c00; } /* Name.Exception */ +.highlight .nf { color: #c0f } /* Name.Function */ +.highlight .nl { color: #99f } /* Name.Label */ +.highlight .nn { color: #0cf; } /* Name.Namespace */ +.highlight .nt { color: #2f6f9f; } /* Name.Tag */ +.highlight .nv { color: #033 } /* Name.Variable */ +.highlight .ow { color: #000; } /* Operator.Word */ +.highlight .w { color: #bbb } /* Text.Whitespace */ +.highlight .mf { color: #f60 } /* Literal.Number.Float */ +.highlight .mh { color: #f60 } /* Literal.Number.Hex */ +.highlight .mi { color: #f60 } /* Literal.Number.Integer */ +.highlight .mo { color: #f60 } /* Literal.Number.Oct */ +.highlight .sb { color: #c30 } /* Literal.String.Backtick */ +.highlight .sc { color: #c30 } /* Literal.String.Char */ +.highlight .sd { color: #c30; font-style: italic } /* Literal.String.Doc */ +.highlight .s2 { color: #c30 } /* Literal.String.Double */ +.highlight .se { color: #c30; } /* Literal.String.Escape */ +.highlight .sh { color: #c30 } /* Literal.String.Heredoc */ +.highlight .si { color: #a00 } /* Literal.String.Interpol */ +.highlight .sx { color: #c30 } /* Literal.String.Other */ +.highlight .sr { color: #3aa } /* Literal.String.Regex */ +.highlight .s1 { color: #c30 } /* Literal.String.Single */ +.highlight .ss { color: #fc3 } /* Literal.String.Symbol */ +.highlight .bp { color: #366 } /* Name.Builtin.Pseudo */ +.highlight .vc { color: #033 } /* Name.Variable.Class */ +.highlight .vg { color: #033 } /* Name.Variable.Global */ +.highlight .vi { color: #033 } /* Name.Variable.Instance */ +.highlight .il { color: #f60 } /* Literal.Number.Integer.Long */ + +.css .o, +.css .o + .nt, +.css .nt + .nt { color: #999; } diff --git a/site/assets/style/_toc.scss b/site/assets/style/_toc.scss new file mode 100644 index 0000000..f004db7 --- /dev/null +++ b/site/assets/style/_toc.scss @@ -0,0 +1,16 @@ +// Table of Contents + +#markdown-toc { + padding: var(--spacer-2) var(--spacer-3); + margin-bottom: var(--spacer-2); + border: solid var(--border-color); + border-width: 1px 0; + + &::before { + display: block; + margin-left: calc(var(--spacer-3) * -1); + content: "Contents"; + font-size: 85%; + font-weight: 500; + } +} diff --git a/site/assets/style/_type.scss b/site/assets/style/_type.scss new file mode 100644 index 0000000..a26964f --- /dev/null +++ b/site/assets/style/_type.scss @@ -0,0 +1,115 @@ +// Typography +// +// Headings, body text, lists, and other misc typographic elements. + +h1, h2, h3, h4, h5, h6 { + margin-bottom: .5rem; + font-weight: 600; + line-height: 1.25; + color: var(--heading-color); +} + +h1 { + font-size: 2rem; +} + +h2 { + margin-top: 1rem; + font-size: 1.5rem; +} + +h3 { + margin-top: 1.5rem; + font-size: 1.25rem; +} + +h4, h5, h6 { + margin-top: 1rem; + font-size: 1rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +ul, ol, dl { + margin-top: 0; + margin-bottom: 1rem; +} + +dt { + font-weight: bold; +} + +dd { + margin-bottom: .5rem; +} + +hr { + position: relative; + margin: var(--spacer-2) 0; + border: 0; + border-top: 1px solid var(--border-color); +} + +abbr { + font-size: 85%; + font-weight: bold; + color: var(--gray-600); + text-transform: uppercase; + + &[title] { + cursor: help; + border-bottom: 1px dotted var(--border-color); + } +} + +blockquote { + padding: .5rem 1rem; + margin: .8rem 0; + color: var(--gray-500); + border-left: .25rem solid var(--border-color); + + p:last-child { + margin-bottom: 0; + } + + @media (min-width: 30em) { + padding-right: 5rem; + padding-left: 1.25rem; + } +} + +figure { + margin: 0; +} + + +// Markdown footnotes +// +// See the example content post for an example. + +// Footnote number within body text +a[href^="#fn:"], +// Back to footnote link +a[href^="#fnref:"] { + display: inline-block; + margin-left: .1rem; + font-weight: bold; +} + +// List of footnotes +.footnotes { + margin-top: 2rem; + font-size: 85%; +} + +// Custom type +// +// Extend paragraphs with `.lead` for larger introductory text. + +.lead { + font-size: 1.25rem; + font-weight: 300; +} diff --git a/site/assets/style/_variables.scss b/site/assets/style/_variables.scss new file mode 100644 index 0000000..7d443cc --- /dev/null +++ b/site/assets/style/_variables.scss @@ -0,0 +1,66 @@ +:root { + --gray-000: #f8f9fa; + --gray-100: #f1f3f5; + --gray-200: #e9ecef; + --gray-300: #dee2e6; + --gray-400: #ced4da; + --gray-500: #adb5bd; + --gray-600: #868e96; + --gray-700: #495057; + --gray-800: #343a40; + --gray-900: #212529; + + --red: #fa5252; + --pink: #e64980; + --grape: #be4bdb; + --purple: #7950f2; + --indigo: #4c6ef5; + --blue: #228be6; + --cyan: #15aabf; + --teal: #12b886; + --green: #40c057; + --yellow: #fab005; + --orange: #fd7e14; + + --blue-300: #74c0fc; + --blue-400: #4dabf7; + --yellow-100: #fff3bf; + + --body-font: "Geist Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --body-font-size: 16px; + --body-line-height: 1.5; + --body-color: var(--gray-700); + --body-bg: #fff; + + --link-color: var(--blue); + --link-hover-color: #1c7ed6; + + --heading-color: var(--gray-900); + + --border-color: var(--gray-300); + --border-radius: .25rem; + + --code-font: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --code-color: var(--grape); + --code-bg: var(--gray-000); + + --spacer: 1rem; + --spacer-2: calc(var(--spacer) * 1.5); + --spacer-3: calc(var(--spacer) * 3); +} + +@media (prefers-color-scheme: dark) { + :root { + --body-color: var(--gray-300); + --body-bg: var(--gray-800); + + --heading-color: #fff; + + --link-color: var(--blue-300); + --link-hover-color: var(--blue-400); + + --border-color: rgba(255,255,255,.15); + + --code-bg: var(--gray-900); + } +} diff --git a/site/assets/style/styles.scss b/site/assets/style/styles.scss new file mode 100644 index 0000000..a390e15 --- /dev/null +++ b/site/assets/style/styles.scss @@ -0,0 +1,42 @@ +// +// ___ +// /\_ \ +// _____ ___ ___\//\ \ __ +// /\ '__`\ / __`\ / __`\\ \ \ /'__`\ +// \ \ \_\ \/\ \_\ \/\ \_\ \\_\ \_/\ __/ +// \ \ ,__/\ \____/\ \____//\____\ \____\ +// \ \ \/ \/___/ \/___/ \/____/\/____/ +// \ \_\ +// \/_/ +// +// Designed, built, and released under MIT license by @mdo. Learn more at +// https://github.com/poole/poole. + +@import "variables"; +@import "base"; +@import "type"; +@import "syntax"; +@import "code"; +@import "layout"; +@import "masthead"; +@import "posts"; +@import "pagination"; +@import "message"; +@import "toc"; + +// Sass for creating the swatches +.colors { + display: grid; + grid-template-columns: max-content 1fr; + + dt { + width: 3rem; + height: 3rem; + border-radius: var(--border-radius); + box-shadow: inset 0 0 0 1px rgba(255,255,255,.15); + } + + dd { + margin-left: var(--spacer); + } +} \ No newline at end of file diff --git a/site/diary/dag_watching.vox b/site/diary/dag_watching.vox new file mode 100644 index 0000000..7a7a4df --- /dev/null +++ b/site/diary/dag_watching.vox @@ -0,0 +1,112 @@ +--- +title = "DAG Visualisation & Watching" +date = 2024-05-29T00:00:00+00:00 +layout = "post" +permalink = "date" +--- + +{% markdown %} + +# Notes +I've been using two particular terminal invocations frequently. + +## Linting & formatting +```sh +cargo fix --edition --edition-idioms --allow-dirty; cargo clippy --fix -Z unstable-options --allow-dirty; cargo fix --edition --edition-idioms --bin vox --features="cli" --allow-dirty; cargo clippy --fix -Z unstable-options --bin vox --features="cli" --allow-dirty; cargo fmt +``` + +These commands lint Vox, check it for errors, and then format its code. + +## Installing the local copy of Vox +```sh +cargo install --path . --features="cli" +``` + +This command allows me to use the `vox` CLI built with my latest local changes. + +--- + +# Goals + +Today, I'm concerned with: +- Colouring pages differently when visualising the DAG; currently, the same colour is used for all pages. +- Adding an optional path parameter to the CLI; currently, the current working directory is used by the CLI. +- Finishing up the watching feature; currently, this feature is untested and certainly broken. +- Putting together this development diary. +- Adding a `meta` templating context. + +## DAG Visualisation +Colouring should be done based on the node's label. +To me, beige (ie, a light orange) is the colour of layouts, and a light blue complement is appropriate for collection-less pages. +- If the page is a layout page, set its colour to beige (#FFDFBA). +- If the page is a page in a collection, set its colour to light green (#DAFFBA). +- If the page is a page not in a collection, set its colour to light blue (#BADAFF). + +## CLI Path Parameter +The CLI should take an `Option`. If this path is `None`, do nothing. Otherwise, use this path to set the current environment path. + +## Watching +If changes are made, wait until a certain period (eg, five seconds) has elapsed where no further changes have been made. +When such a period has elapsed, do the following: + +1. Build a new DAG. +2. Obtain the difference between the old and new DAGs; ie, calculate the set of added or modified nodes. + - A node is modified if it has the same label, but its page is different (not comparing `url` or `rendered`). + - If a node's page is the same (excluding `url` or `rendered`), it is unchanged. + - A node is added if its label appears in the new DAG, but not the old one. + - A node is removed if its label appears in the old DAG, but not the new one. +3. Compute which pages need to be rendered, noting their node IDs. + - All pages that were modified need to be re-rendered. + - Their descendants in the new DAG also need to be rendered. + - All pages that were added need to be rendered. + - Their descendants in the new DAG also need to be rendered. + - Nothing is done with the pages that were removed. Necessary changes are covered by the two cases above. +4. Merge the DAGs. + - In the new DAG, replace all pages not needing rendering with their rendered counterparts from the old DAG. +5. Render & output the appropriate pages. + +## Development Diary +Maintaining a development diary is important for three primary reasons: +1. It conveys to others that the project is being developed. +2. It aids me in returning to the project when my attention temporarily turned away from in-progress work. +3. It helps me think through the logic before beginning to implement features or bug fixes. + +To build this development diary, I'll need to perform the following tasks: +1. Find design inspiration for the site. +2. Put together the layouts for the site (and write the `global.toml`). +3. Put together the stylesheet for the site. +4. Write the index page of the diary. + +To publish this development diary, I'll use a GitHub workflow similar to the one I wrote for [`vox-basic`](https://github.com/emmyoh/vox-basic). + +## `meta` Context +The `meta` context comprises the following: +- `meta.date`, being the current date-time of the build. +- `meta.builder`, being the name of the software building the site ('Vox'). +- `meta.version`, being the current version number of Vox. + + +Regarding `meta.builder`: I envision Vox as the reference implementation of a standard way of putting static sites together. +Often, static site generators offer benefits & tradeoffs that are unrelated to the variation in the user experience. Users should be able to migrate their sites between implementations of what is ultimately the same class of software without needing to rewrite everything. +This makes Vox (and the abstract 'standard' it describes) very opinionated; it specifies TOML as the frontmatter language (and `---` as the frontmatter delimiters) and Liquid as the templating language. I've come to the conclusion that these choices make sense for any future static site generator anyway, but am of course cognisant of the fact that if too many significantly disagree, then I've simply created [yet another way](https://xkcd.com/927) of building static sites. + +--- + +# Future Goals + +In the future, this tool needs: +1. A blog pertaining to the project, very similar in appearance to the development diary, but different in scope. + - Posts regarding the project's milestones, and perhaps more 'philosophical' posts about static site generators belong here. Essentially, this blog will be public-facing, while the development diary is intended for those working on Vox. +2. Documentation on usage, essentially doubling as a specification of the 'standard' I described earlier. +3. A friendly introduction to Vox and what it can do, including the following resources: + - Installation instructions. + - Previously mentioned usage documentation. + - Previously mentioned blog. + - Previously mentioned development diary. + - The code documentation generated using the Rust toolchain. + +Additionally, it'd be useful if I documented the CLI code. While such documentation would not be public-facing (ie, not in the generated code documentation on the site), it would be useful to have while developing the CLI. + +That's all for now. Thanks for reading! + +{% endmarkdown %} \ No newline at end of file diff --git a/site/global.toml b/site/global.toml new file mode 100644 index 0000000..29651ab --- /dev/null +++ b/site/global.toml @@ -0,0 +1,6 @@ +title = "Vox" +description = "A performant static site generator built to scale." +author = "Emil Sayahi" +locale = "en_US" +url = "https://emmyoh.github.io/vox" +# url = "http://localhost:80" \ No newline at end of file diff --git a/site/index.vox b/site/index.vox new file mode 100644 index 0000000..d8a3e40 --- /dev/null +++ b/site/index.vox @@ -0,0 +1,36 @@ +--- +layout = "default" +permalink = "index.html" +--- + +{% markdown %} + +Vox is a static site generator that leverages technologies you know & love to build & deploy sites faster than any other static site generator available today. + +## Features +* Fast build times ({% math %}<0.5{% endmath %} seconds for a medium-sized site). +* Intelligent rebuilding. +* Flexible data model, perfect for any static site. +* Industry-standard languages (Liquid & TOML). +* Comes as a single binary, able to build or serve sites. +* Built site can be deployed anywhere. + +## Resources +* Developer Diary +* [Developer Documentation](https://emmyoh.github.io/vox/vox/) + +## Overview +{% raw %} +Sites have the following structure: +- A `global.toml` file. Anything here defines the `{{ global }}` context. +- `.vox` files: + - Inside the `layouts` folder, defining pages which other pages can use as a template; layout pages provide the `{{ layout }}` context to their child pages. + - Inside any other subdirectory, defining pages inside a collection; a collection provides its own context, referred to by the name of the subdirectory. + - Inside the root folder, defining pages not inside a collection. +- `.voxs` files: + - Inside the `snippets` folder, defining partial pages which can be embedded in other pages. +- Anything else is simply ignored. +All of the items above are optional; even the `global.toml` file is optional if no page requires it. +{% endraw %} + +{% endmarkdown %} \ No newline at end of file diff --git a/site/layouts/default.vox b/site/layouts/default.vox new file mode 100644 index 0000000..9fce31b --- /dev/null +++ b/site/layouts/default.vox @@ -0,0 +1,24 @@ +--- +--- + + + {% include head.voxs %} + +

+
+

+ {{ global.title }} + {{ global.description }} +

+
+
+ {{ page.rendered }} +
+ +
+ + \ No newline at end of file diff --git a/site/layouts/page.vox b/site/layouts/page.vox new file mode 100644 index 0000000..c4aa543 --- /dev/null +++ b/site/layouts/page.vox @@ -0,0 +1,8 @@ +--- +layout = "default" +--- + +
+

{{ page.data.title }}

+ {{ page.rendered }} +
\ No newline at end of file diff --git a/site/layouts/post.vox b/site/layouts/post.vox new file mode 100644 index 0000000..8dcd4a7 --- /dev/null +++ b/site/layouts/post.vox @@ -0,0 +1,9 @@ +--- +layout = "default" +--- + +
+

{{ page.data.title }}

+ + {{ page.rendered }} +
\ No newline at end of file diff --git a/site/pages/diary_atom.vox b/site/pages/diary_atom.vox new file mode 100644 index 0000000..ffe9cf1 --- /dev/null +++ b/site/pages/diary_atom.vox @@ -0,0 +1,5 @@ +--- +collections = ["diary"] +permalink = "diary/atom.xml" +--- +{% include atom.voxs posts = diary %} \ No newline at end of file diff --git a/site/pages/diary_index.vox b/site/pages/diary_index.vox new file mode 100644 index 0000000..d515efd --- /dev/null +++ b/site/pages/diary_index.vox @@ -0,0 +1,19 @@ +--- +layout = "default" +collections = ["diary"] +permalink = "diary/index.html" +--- +{% assign posts = diary | sort: "diary.date" %} +
+ {% for post in posts %} +
+

+ + {{ post.data.title }} + +

+ + {{ post.rendered }} +
+ {% endfor %} +
\ No newline at end of file diff --git a/site/prebuild.sh b/site/prebuild.sh new file mode 100755 index 0000000..381ca6a --- /dev/null +++ b/site/prebuild.sh @@ -0,0 +1,3 @@ +#!/bin/sh +mkdir -p ./output/ +grass ./assets/style/styles.scss > ./output/styles.css \ No newline at end of file diff --git a/site/snippets/atom.voxs b/site/snippets/atom.voxs new file mode 100644 index 0000000..8c2d614 --- /dev/null +++ b/site/snippets/atom.voxs @@ -0,0 +1,22 @@ + + + + {{ global.title }} + + + {{ global.url }} + + {{ global.author }} + + + {% for post in include.posts %} + + {{ post.data.title | escape }} + + {{ post.date.rfc_2822 }} + {{ post.url | escape }} + {{ post.rendered | escape }} + + {% endfor %} + + \ No newline at end of file diff --git a/site/snippets/head.voxs b/site/snippets/head.voxs new file mode 100644 index 0000000..d5eb4af --- /dev/null +++ b/site/snippets/head.voxs @@ -0,0 +1,16 @@ + + + + + {% if page.data.title %} + {{ page.data.title }} · {{ global.title }} + {% else %} + {{ global.title }}{% if global.description %} · {{ global.description }}{% endif %} + {% endif %} + + + + + + + \ No newline at end of file diff --git a/src/builds.rs b/src/builds.rs index 7938a04..e9495fd 100644 --- a/src/builds.rs +++ b/src/builds.rs @@ -61,8 +61,19 @@ impl Build { let node = vg.element_mut(node_handle); let old_shape = node.shape.clone(); if let ShapeKind::Circle(label) = old_shape { - node.shape = ShapeKind::Box(label); - node.look.fill_color = Some(Color::fast("#FFDFBA")); + node.shape = ShapeKind::Box(label.clone()); + if Page::is_layout_path(label.clone())? { + node.look.fill_color = Some(Color::fast("#FFDFBA")); + } else { + match Page::get_collection_name_from_path(label)? { + Some(_) => { + node.look.fill_color = Some(Color::fast("#DAFFBA")); + } + None => { + node.look.fill_color = Some(Color::fast("#BADAFF")); + } + } + } } } vg.do_it(false, false, false, &mut svg); @@ -75,6 +86,31 @@ impl Build { Ok(()) } + /// Get all descendants of a page in a DAG. + /// + /// # Arguments + /// + /// * `dag` - The DAG to search. + /// + /// * `root_index` - The index of the page in the DAG. + /// + /// # Returns + /// + /// A list of indices of all descendants of the page. + pub fn get_descendants( + dag: &StableDag, + root_index: NodeIndex, + ) -> Vec { + let mut descendants = Vec::new(); + let children = dag.children(root_index).iter(dag).collect::>(); + for child in children { + descendants.push(child.1); + let child_descendants = Build::get_descendants(dag, child.1); + descendants.extend(child_descendants); + } + descendants + } + /// Render all pages in the DAG. /// /// # Returns @@ -95,7 +131,7 @@ impl Build { Ok(rendered_indices) } - /// Render a page and its child pages. + /// Render a page and its descendant pages. /// /// # Arguments /// diff --git a/src/main.rs b/src/main.rs index 721a0d1..12f61ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use actix_files::NamedFile; use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{App, HttpServer}; -use ahash::AHashMap; +use ahash::{AHashMap, AHashSet, HashSet, HashSetExt}; use clap::{arg, crate_version}; use clap::{Parser, Subcommand}; use daggy::Walker; @@ -12,18 +12,16 @@ use miette::{Context, IntoDiagnostic}; use mimalloc::MiMalloc; use notify_debouncer_full::{ new_debouncer, - notify::{ - event::{ModifyKind, RemoveKind, RenameMode}, - EventKind, RecursiveMode, Watcher, - }, + notify::{RecursiveMode, Watcher}, }; use std::net::Ipv4Addr; use std::path::Path; use std::sync::mpsc::channel; use std::{fs, path::PathBuf, time::Duration}; use ticky::Stopwatch; +use tokio::time::{sleep, Instant}; use toml::Table; -use tracing::{debug, info, warn, Level}; +use tracing::{debug, error, info, trace, warn, Level}; use vox::builds::EdgeType; use vox::date::{self}; use vox::{builds::Build, page::Page, templates::create_liquid_parser}; @@ -41,20 +39,26 @@ struct Cli { enum Commands { /// Build the site. Build { - /// Watch for changes (defaults to `false`). + /// An optional path to the site directory. + #[arg(default_value = None)] + path: Option, + /// Watch for changes. #[arg(short, long, default_value_t = false)] watch: bool, /// The level of log output; recoverable errors, warnings, information, debugging information, and trace information. #[arg(short, long, action = clap::ArgAction::Count, default_value_t = 0)] verbosity: u8, - /// Whether to visualise the DAG (defaults to `false`). + /// Visualise the DAG. #[arg(short = 'd', long, default_value_t = false)] visualise_dag: bool, }, /// Serve the site. Serve { - /// Watch for changes (defaults to `true`). - #[arg(short, long, default_value_t = true)] + /// An optional path to the site directory. + #[arg(default_value = None)] + path: Option, + /// Watch for changes. + #[arg(short, long, default_value_t = false)] watch: bool, /// The port to serve the site on. #[arg(short, long, default_value_t = 80)] @@ -62,7 +66,7 @@ enum Commands { /// The level of log output; recoverable errors, warnings, information, debugging information, and trace information. #[arg(short, long, action = clap::ArgAction::Count, default_value_t = 0)] verbosity: u8, - /// Whether to visualise the DAG (defaults to `false`). + /// Visualise the DAG. #[arg(short = 'd', long, default_value_t = false)] visualise_dag: bool, }, @@ -85,10 +89,14 @@ async fn main() -> miette::Result<()> { let cli = Cli::parse(); match cli.command { Some(Commands::Build { + path, watch, verbosity, visualise_dag, }) => { + if let Some(path) = path { + std::env::set_current_dir(path).into_diagnostic()?; + } let verbosity_level = match verbosity { 0 => Level::ERROR, 1 => Level::WARN, @@ -97,25 +105,44 @@ async fn main() -> miette::Result<()> { 4 => Level::TRACE, _ => Level::TRACE, }; - tracing_subscriber::fmt() + let mut subscriber_builder = tracing_subscriber::fmt() .pretty() - .with_thread_ids(true) - .with_thread_names(true) - .with_file(true) - .with_line_number(true) - .with_max_level(verbosity_level) - .init(); + .with_max_level(verbosity_level); + if verbosity >= 3 { + subscriber_builder = subscriber_builder + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true); + } + subscriber_builder.init(); info!("Building … "); - tokio::spawn(build(watch, visualise_dag)) - .await - .into_diagnostic()??; + let build_loop = tokio::spawn(async move { + loop { + let building = tokio::spawn(build(watch, visualise_dag)); + match building.await { + Ok(_) => {} + Err(err) => { + error!("Building failed: {:#?}", err); + info!("Retrying in 5 seconds … "); + sleep(Duration::from_secs(5)).await; + continue; + } + } + } + }); + build_loop.await.into_diagnostic()?; } Some(Commands::Serve { + path, watch, port, verbosity, visualise_dag, }) => { + if let Some(path) = path { + std::env::set_current_dir(path).into_diagnostic()?; + } let verbosity_level = match verbosity { 0 => Level::ERROR, 1 => Level::WARN, @@ -124,49 +151,87 @@ async fn main() -> miette::Result<()> { 4 => Level::TRACE, _ => Level::TRACE, }; - tracing_subscriber::fmt() + let mut subscriber_builder = tracing_subscriber::fmt() .pretty() - .with_thread_ids(true) - .with_thread_names(true) - .with_file(true) - .with_line_number(true) - .with_max_level(verbosity_level) - .init(); - println!("Serving on {}:{} … ", Ipv4Addr::UNSPECIFIED, port); - tokio::spawn(build(watch, visualise_dag)) - .await - .into_diagnostic()??; - tokio::spawn( - HttpServer::new(|| { - let mut service = actix_files::Files::new("/", "output") - .prefer_utf8(true) - .use_hidden_files() - .use_etag(true) - .use_last_modified(true) - .show_files_listing() - .redirect_to_slash_directory(); - if Path::new("output/index.html").is_file() { - service = service.index_file("index.html"); + .with_max_level(verbosity_level); + if verbosity >= 3 { + subscriber_builder = subscriber_builder + .with_thread_ids(true) + .with_thread_names(true) + .with_file(true) + .with_line_number(true); + } + subscriber_builder.init(); + let build_loop = tokio::spawn(async move { + loop { + let building = tokio::spawn(build(watch, visualise_dag)); + match building.await { + Ok(_) => {} + Err(err) => { + error!("Building failed: {:#?}", err); + info!("Retrying in 5 seconds … "); + sleep(Duration::from_secs(5)).await; + continue; + } } - if Path::new("output/404.html").is_file() { - service = service.default_handler(|req: ServiceRequest| { - let (http_req, _payload) = req.into_parts(); - async { - let response = - NamedFile::open("output/404.html")?.into_response(&http_req); - Ok(ServiceResponse::new(http_req, response)) - } - }); - }; - App::new().service(service) - }) - .bind((Ipv4Addr::UNSPECIFIED, port)) - .into_diagnostic()? - .run(), - ) - .await - .into_diagnostic()? - .into_diagnostic()?; + } + }); + let serve_loop = tokio::spawn(async move { + loop { + let serving = tokio::spawn( + HttpServer::new(|| { + let mut service = actix_files::Files::new("/", "output") + .prefer_utf8(true) + .use_hidden_files() + .use_etag(true) + .use_last_modified(true) + .show_files_listing() + .redirect_to_slash_directory(); + // if Path::new("output/index.html").is_file() { + service = service.index_file("index.html"); + // } + // if Path::new("output/404.html").is_file() { + service = service.default_handler(|req: ServiceRequest| { + let (http_req, _payload) = req.into_parts(); + async { + let response = NamedFile::open("output/404.html")? + .into_response(&http_req); + Ok(ServiceResponse::new(http_req, response)) + } + }); + // }; + App::new().service(service) + }) + .bind((Ipv4Addr::UNSPECIFIED, port)) + .unwrap() + .run(), + ); + println!("Serving on {}:{} … ", Ipv4Addr::UNSPECIFIED, port); + match serving.await { + Ok(_) => {} + Err(err) => { + error!("Serving failed: {:#?}", err); + info!("Retrying in 5 seconds … "); + sleep(Duration::from_secs(5)).await; + continue; + } + } + } + }); + tokio::spawn(async move { + match tokio::signal::ctrl_c().await { + Ok(()) => { + info!("Exiting … "); + std::process::exit(0); + } + Err(err) => { + error!("Unable to listen for shutdown signal: {}", err); + std::process::exit(0); + } + } + }); + build_loop.await.into_diagnostic()?; + serve_loop.await.into_diagnostic()?; } None => println!("Vox {}", crate_version!()), }; @@ -178,7 +243,8 @@ fn insert_or_update_page( layout_index: Option, dag: &mut StableDag, pages: &mut AHashMap, - layouts: &mut AHashMap, + layouts: &mut AHashMap>, + collection_dependents: &mut AHashMap>, locale: String, ) -> miette::Result<()> { let entry = fs::canonicalize(entry).into_diagnostic()?; @@ -214,12 +280,33 @@ fn insert_or_update_page( // A page's parents are pages in the collections it depends on. Its layout is a child. let layout = page.layout.clone(); let collections = page.collections.clone(); - debug!("Layout: {:?} … ", layout); - debug!("Collections: {:?} … ", collections); + debug!("Layout used: {:?} … ", layout); + debug!("Collections used: {:?} … ", collections); if let Some(layout) = layout { + // Layouts are inserted multiple times, once for each page that uses them. let layout_path = fs::canonicalize(format!("layouts/{}.vox", layout)) .into_diagnostic() .with_context(|| format!("Layout not found: `layouts/{}.vox`", layout))?; + let children = dag.children(index).iter(dag).collect::>(); + // If this page is being updated, the old layout should be replaced with the current one in the DAG. + let old_layout = children + .iter() + .find(|child| *dag.edge_weight(child.0).unwrap() == EdgeType::Layout); + if let Some(old_layout) = old_layout { + trace!("Removing old layout … "); + dag.remove_node(old_layout.1); + } + info!("Inserting layout: {:?} … ", layout_path); + let layout_page = path_to_page(layout_path.clone(), locale.clone())?; + debug!("{:#?}", layout_page); + let layout_index = dag.add_child(index, EdgeType::Layout, layout_page); + if let Some(layouts) = layouts.get_mut(&layout_path) { + layouts.insert(layout_index.1); + } else { + let mut new_set = HashSet::new(); + new_set.insert(layout_index.1); + layouts.insert(layout_path.clone(), new_set); + } // if pages.get(&layout_path).is_none() { // info!("Inserting layout: {:?} … ", layout_path); // let layout_page = path_to_page(layout_path.clone(), locale.clone())?; @@ -236,15 +323,17 @@ fn insert_or_update_page( // dag.add_edge(index, layout_index, EdgeType::Layout) // .into_diagnostic()?; // } - // Layouts are inserted multiple times, once for each page that uses them. - info!("Inserting layout: {:?} … ", layout_path); - let layout_page = path_to_page(layout_path.clone(), locale.clone())?; - debug!("{:#?}", layout_page); - let layout_index = dag.add_child(index, EdgeType::Layout, layout_page); - layouts.insert(layout_index.1, (page.to_path_string().into(), layout_path)); + // layouts_by_index.insert(layout_index.1, (page.to_path_string().into(), layout_path)); } if let Some(collections) = collections { for collection in collections { + if let Some(collection_dependents) = collection_dependents.get_mut(&collection) { + collection_dependents.insert(index); + } else { + let mut new_set = HashSet::new(); + new_set.insert(index); + collection_dependents.insert(collection.clone(), new_set); + } let collection = fs::canonicalize(collection).into_diagnostic()?; for entry in glob(&format!("{}/**/*.vox", collection.to_string_lossy())).into_diagnostic()? @@ -279,7 +368,9 @@ async fn build(watch: bool, visualise_dag: bool) -> miette::Result<()> { let global = get_global_context()?; let mut dag = StableDag::new(); let mut pages: AHashMap = AHashMap::new(); - let mut layouts: AHashMap = AHashMap::new(); + // let mut layouts_by_index: AHashMap = AHashMap::new(); + let mut layouts: AHashMap> = AHashMap::new(); + let mut collection_dependents: AHashMap> = AHashMap::new(); // Initial DAG construction. info!("Constructing DAG … "); @@ -295,39 +386,39 @@ async fn build(watch: bool, visualise_dag: bool) -> miette::Result<()> { &mut dag, &mut pages, &mut layouts, + &mut collection_dependents, global.1.clone(), )?; } // We update the layouts with their parents and children once all other pages have been inserted. - for (layout, (_layout_parent_path, layout_path)) in layouts.clone() { - insert_or_update_page( - layout_path, - Some(layout), - &mut dag, - &mut pages, - &mut layouts, - global.1.clone(), - )?; + // for (layout, (_layout_parent_path, layout_path)) in layouts_by_index.clone() { + for (layout_path, layout_indices) in layouts.clone() { + for layout_index in layout_indices { + insert_or_update_page( + layout_path.clone(), + Some(layout_index), + &mut dag, + &mut pages, + &mut layouts, + &mut collection_dependents, + global.1.clone(), + )?; + } } // Write the initial site to the output directory. info!("Performing initial build … "); - let (_updated_pages, updated_dag) = tokio::spawn(async move { - generate_site( - parser.clone(), - global.0.clone(), - global.1.clone(), - dag, - visualise_dag, - ) - .await - }) - .await - .into_diagnostic()??; + let (_updated_pages, updated_dag) = generate_site( + parser.clone(), + global.0.clone(), + global.1.clone(), + dag, + visualise_dag, + ) + .await?; dag = updated_dag; // Watch for changes to the site. - info!("Watching for changes … "); if watch { let current_path = std::env::current_dir().into_diagnostic()?; let output_path = current_path.join("output"); @@ -335,238 +426,200 @@ async fn build(watch: bool, visualise_dag: bool) -> miette::Result<()> { let (sender, receiver) = channel(); let mut debouncer = new_debouncer(Duration::from_secs(1), None, sender).into_diagnostic()?; + info!("Watching {:?} … ", current_path); debouncer .watcher() .watch(¤t_path, RecursiveMode::Recursive) .into_diagnostic()?; - // Changes to the output directory or version control are irrelevant. - debouncer.watcher().unwatch(&git_path).into_diagnostic()?; - debouncer - .watcher() - .unwatch(&output_path) - .into_diagnostic()?; + // info!("Ignoring changes to {:?} … ", git_path); + // debouncer.watcher().unwatch(&git_path).into_diagnostic()?; + // info!("Ignoring changes to {:?} … ", output_path); + // debouncer + // .watcher() + // .unwatch(&output_path) + // .into_diagnostic()?; + let mut changed_times = Vec::new(); loop { if let Ok(events) = receiver.recv().into_diagnostic()? { - for event in events { - match event.kind { - // If a new page is created, insert it into the DAG. - EventKind::Create(_) => { - info!("New files created … "); - let parser = create_liquid_parser()?; - let global = get_global_context()?; - let page_paths: Vec<&PathBuf> = event - .paths - .iter() - .filter(|path| { - path.exists() - && path.is_file() - && path.extension().unwrap_or_default() == "vox" - && !Page::is_layout_path(path).unwrap() - }) - .collect(); - debug!("Pages created: {:?}", page_paths); - for path in page_paths { - insert_or_update_page( - path.clone(), - None, - &mut dag, - &mut pages, - &mut layouts, - global.1.clone(), - )?; - } - let (_updated_pages, updated_dag) = tokio::spawn(async move { - generate_site( - parser.clone(), - global.0.clone(), - global.1.clone(), - dag, - visualise_dag, - ) - .await - }) - .await - .into_diagnostic()??; - dag = updated_dag; - } - EventKind::Modify(modify_kind) => match modify_kind { - ModifyKind::Name(rename_mode) => match rename_mode { - RenameMode::Both => { - let parser = create_liquid_parser()?; - let global = get_global_context()?; - let from_path = fs::canonicalize(event.paths[0].clone()) - .into_diagnostic()?; - let to_path = fs::canonicalize(event.paths[1].clone()) - .into_diagnostic()?; - info!("Renaming occurred: {:?} → {:?}", from_path, to_path); - // If the path is a file, update the page in the DAG. - if to_path.is_file() - && to_path.extension().unwrap_or_default() == "vox" - && !Page::is_layout_path(&to_path)? - { - info!("Renaming page … "); - let index = pages[&from_path]; - pages.remove(&from_path); - dag.remove_node(index); - insert_or_update_page( - to_path.clone(), - None, - &mut dag, - &mut pages, - &mut layouts, - global.1.clone(), - )?; - } - // If the path is a directory, update all pages in the DAG. - else if to_path.is_dir() { - info!("Renaming directory … "); - for (page_path, index) in pages.clone().into_iter() { - if page_path.starts_with(&from_path) { - let to_page_path = to_path.join( - page_path - .strip_prefix(&from_path) - .into_diagnostic()?, - ); - pages.remove(&page_path); - dag.remove_node(index); - insert_or_update_page( - to_page_path, - None, - &mut dag, - &mut pages, - &mut layouts, - global.1.clone(), - )?; - } - } - }; - let (_updated_pages, updated_dag) = tokio::spawn(async move { - generate_site( - parser.clone(), - global.0.clone(), - global.1.clone(), - dag, - visualise_dag, - ) - .await - }) - .await - .into_diagnostic()??; - dag = updated_dag; - } - _ => continue, - }, - // If a page is modified, update it in the DAG. - ModifyKind::Data(_) => { - let parser = create_liquid_parser()?; - let global = get_global_context()?; - let page_paths: Vec<&PathBuf> = event - .paths - .iter() - .filter(|path| { - path.exists() - && path.is_file() - && path.extension().unwrap_or_default() == "vox" - && !Page::is_layout_path(path).unwrap() - }) - .collect(); - info!("Pages were modified: {:#?}", page_paths); - for path in page_paths { - insert_or_update_page( - path.clone(), - None, - &mut dag, - &mut pages, - &mut layouts, - global.1.clone(), - )?; - } - let (_updated_pages, updated_dag) = tokio::spawn(async move { - generate_site( - parser.clone(), - global.0.clone(), - global.1.clone(), - dag, - visualise_dag, - ) - .await - }) - .await - .into_diagnostic()??; - dag = updated_dag; - } - _ => continue, - }, - EventKind::Remove(remove_kind) => match remove_kind { - // If a folder is removed, remove all pages in the folder from the DAG. - RemoveKind::Folder => { - let parser = create_liquid_parser()?; - let global = get_global_context()?; - let path = - fs::canonicalize(event.paths[0].clone()).into_diagnostic()?; - info!("Folder was removed: {:?}", path); - for (page_path, index) in pages.clone().into_iter() { - if page_path.starts_with(&path) { - pages.remove(&page_path); - dag.remove_node(index); - } - } - let (_updated_pages, updated_dag) = tokio::spawn(async move { - generate_site( - parser.clone(), - global.0.clone(), - global.1.clone(), - dag, - visualise_dag, - ) - .await - }) - .await - .into_diagnostic()??; - dag = updated_dag; - } - // If a file is removed, remove the page from the DAG. - RemoveKind::File => { - let parser = create_liquid_parser()?; - let global = get_global_context()?; - let page_paths: Vec<&PathBuf> = event - .paths - .iter() - .filter(|path| { - !path.exists() - && path.is_file() - && path.extension().unwrap_or_default() == "vox" - }) - .collect(); - info!("Pages were removed: {:#?}", page_paths); - for path in page_paths { - let path = fs::canonicalize(path).into_diagnostic()?; - if let Some(index) = pages.get(&path) { - dag.remove_node(*index); - pages.remove(&path); - } - } - let (_updated_pages, updated_dag) = tokio::spawn(async move { - generate_site( - parser.clone(), - global.0.clone(), - global.1.clone(), - dag, - visualise_dag, - ) - .await - }) - .await - .into_diagnostic()??; - dag = updated_dag; - } - _ => continue, - }, - _ => continue, + // Changes to the output directory or version control are irrelevant. + if !events.iter().any(|event| { + event + .paths + .iter() + .any(|path| !path.starts_with(&output_path) && !path.starts_with(&git_path)) + }) { + continue; + } + debug!("Changes detected: {:#?} … ", events); + changed_times.push(Instant::now()); + } + if changed_times.len() > 1 { + let first = changed_times.remove(0); + if first.elapsed() < Duration::from_secs(1) { + continue; + } + } + + // 1. Build a new DAG. + let parser = create_liquid_parser()?; + let global = get_global_context()?; + let mut new_dag = StableDag::new(); + let mut new_pages: AHashMap = AHashMap::new(); + let mut new_layouts: AHashMap> = AHashMap::new(); + let mut new_collection_dependents: AHashMap> = + AHashMap::new(); + + // New DAG construction. + info!("Constructing DAG … "); + for entry in glob("**/*.vox").into_diagnostic()? { + let entry = fs::canonicalize(entry.into_diagnostic()?).into_diagnostic()?; + if Page::is_layout_path(&entry)? { + continue; + } + insert_or_update_page( + entry, + None, + &mut new_dag, + &mut new_pages, + &mut new_layouts, + &mut new_collection_dependents, + global.1.clone(), + )?; + } + for (layout_path, layout_indices) in new_layouts.clone() { + for layout_index in layout_indices { + insert_or_update_page( + layout_path.clone(), + Some(layout_index), + &mut new_dag, + &mut new_pages, + &mut new_layouts, + &mut new_collection_dependents, + global.1.clone(), + )?; + } + } + + // 2. Obtain the difference between the old and new DAGs; ie, calculate the set of added or modified nodes. + // - A node is modified if it has the same label, but its page is different (not comparing `url` or `rendered`). + // - If a node's page is the same (excluding `url` or `rendered`), it is unchanged. + // - A node is added if its label appears in the new DAG, but not the old one. + // - A node is removed if its label appears in the old DAG, but not the new one. + + // let old_dag_node_weights = Into::>::into(dag.graph().clone()).raw_nodes().iter().map(|node| node.weight.clone()).collect::>(); + // let new_dag_node_weights = Into::>::into(new_dag.graph().clone()).raw_nodes().iter().map(|node| node.weight.clone()).collect::>(); + // let mut old_dag_pages = AHashMap::new(); + // for page in old_dag_node_weights { + // let page_label = page.to_path_string(); + // old_dag_pages.insert(page.to_path_string(), page); + // } + // // let old_dag_node_indices = dag.graph().node_indices().collect::>(); + let mut old_dag_pages = AHashMap::new(); + for (page_path, page_index) in &pages { + let page = dag.node_weight(*page_index).unwrap(); + old_dag_pages.insert(page_path.clone(), page); + } + let mut new_dag_pages = AHashMap::new(); + for (page_path, page_index) in &new_pages { + let page = new_dag.node_weight(*page_index).unwrap(); + new_dag_pages.insert(page_path.clone(), page); + } + let mut updated_pages = AHashSet::new(); + for (page_path, new_page) in new_dag_pages { + if let Some(old_page) = old_dag_pages.get(&page_path) { + // If the page has been modified, its index is noted. + if !new_page.is_equivalent(old_page) { + updated_pages.insert(new_pages[&page_path]); } + } else { + // If the page is new, its index is noted. + updated_pages.insert(new_pages[&page_path]); + } + } + // No need to continue if no pages were added or changed. + if updated_pages.is_empty() { + continue; + } + + // 3. Compute which pages need to be rendered, noting their node IDs. + // - All pages that were modified need to be re-rendered. + // - Their descendants in the new DAG also need to be rendered. + // - All pages that were added need to be rendered. + // - Their descendants in the new DAG also need to be rendered. + // - Nothing is done with the pages that were removed. Necessary changes are covered by the two cases above. + + let mut pages_to_render = updated_pages.clone(); + for updated_page_index in updated_pages.clone() { + let descendants = Build::get_descendants(&new_dag, updated_page_index); + for descendant in descendants { + pages_to_render.insert(descendant); } } + + // 4. Merge the DAGs. + // - In the new DAG, replace all pages not needing rendering with their rendered counterparts from the old DAG. + + for (page_path, page_index) in &new_pages { + if !pages_to_render.contains(page_index) { + // Pages may be added, so it is necessary to check if the page already exists in the old DAG. + if let Some(old_page) = dag.node_weight(pages[page_path]) { + let mut _new_page = new_dag.node_weight_mut(*page_index).unwrap(); + _new_page = &mut old_page.clone(); + } + } + } + dag = new_dag; + + // 5. Render & output the appropriate pages. + info!("Rebuilding … "); + let mut timer = Stopwatch::start_new(); + let mut build = Build { + template_parser: parser, + contexts: global.0, + locale: global.1, + dag, + }; + let mut rendered_pages = Vec::new(); + for page in updated_pages.iter() { + build.render_recursively(*page, &mut rendered_pages)?; + } + + info!("{} pages were rendered … ", rendered_pages.len()); + for updated_page_index in rendered_pages.iter() { + let updated_page = &build.dag.graph()[*updated_page_index]; + let output_path = if updated_page.url.is_empty() { + let layout_url = get_layout_url(updated_page_index, &build.dag); + layout_url.map(|layout_url| format!("output/{}", layout_url)) + } else if !updated_page.url.is_empty() { + Some(format!("output/{}", updated_page.url)) + } else { + None + }; + if output_path.is_none() { + warn!("Page has no URL: {:#?} … ", updated_page.to_path_string()); + continue; + } + let output_path = output_path.unwrap(); + info!("Writing to {} … ", output_path); + tokio::fs::create_dir_all( + Path::new(&output_path) + .parent() + .unwrap_or(Path::new(&output_path)), + ) + .await + .into_diagnostic()?; + tokio::fs::write(output_path, updated_page.rendered.clone()) + .await + .into_diagnostic()?; + } + timer.stop(); + println!( + "Generated {} pages in {:.2} seconds … ", + updated_pages.len(), + timer.elapsed_s() + ); + dag = build.dag; } } diff --git a/src/markdown_block.rs b/src/markdown_block.rs index 3feb1e8..47af27b 100644 --- a/src/markdown_block.rs +++ b/src/markdown_block.rs @@ -2,13 +2,12 @@ use comrak::markdown_to_html_with_plugins; use comrak::plugins::syntect::SyntectAdapter; use comrak::ComrakPlugins; use comrak::ListStyleType; -use liquid_core::error::ResultLiquidReplaceExt; +use liquid_core::parser; +use liquid_core::runtime; use liquid_core::Language; use liquid_core::Renderable; use liquid_core::Result; -use liquid_core::Runtime; use liquid_core::{BlockReflection, ParseBlock, TagBlock, TagTokenIter}; -use std::io::Write; /// Render Markdown as HTML /// @@ -29,9 +28,9 @@ pub fn render_markdown(text_to_render: String) -> String { options.extension.front_matter_delimiter = None; options.extension.multiline_block_quotes = true; options.extension.math_dollars = true; - options.extension.math_code = false; + options.extension.math_code = true; options.extension.shortcodes = true; - options.parse.smart = true; + options.parse.smart = false; options.parse.default_info_string = None; options.parse.relaxed_tasklist_matching = true; options.parse.relaxed_autolinks = true; @@ -80,15 +79,21 @@ impl ParseBlock for MarkdownBlock { &self, mut arguments: TagTokenIter<'_>, mut tokens: TagBlock<'_, '_>, - _options: &Language, + options: &Language, ) -> Result, liquid::Error> { arguments.expect_nothing()?; let raw_content = tokens.escape_liquid(false)?.to_string(); + // let runtime = RuntimeBuilder::new().build(); let content = render_markdown(raw_content); + let renderable = parser::parse(&html_escape::decode_html_entities(&content), options) + .map(runtime::Template::new) + .unwrap(); + // .render(&runtime)?; tokens.assert_empty(); - Ok(Box::new(Markdown { content })) + // Ok(Box::new(Markdown { content })) + Ok(Box::new(renderable)) } fn reflection(&self) -> &dyn BlockReflection { @@ -96,18 +101,18 @@ impl ParseBlock for MarkdownBlock { } } -#[derive(Clone, Debug)] -struct Markdown { - content: String, -} +// #[derive(Clone, Debug)] +// struct Markdown { +// content: String, +// } -impl Renderable for Markdown { - fn render_to( - &self, - writer: &mut dyn Write, - _runtime: &dyn Runtime, - ) -> Result<(), liquid::Error> { - write!(writer, "{}", self.content).replace("Failed to render")?; - Ok(()) - } -} +// impl Renderable for Markdown { +// fn render_to( +// &self, +// writer: &mut dyn Write, +// _runtime: &dyn Runtime, +// ) -> Result<(), liquid::Error> { +// write!(writer, "{}", self.content).replace("Failed to render")?; +// Ok(()) +// } +// } diff --git a/src/page.rs b/src/page.rs index 4a5c272..945260c 100644 --- a/src/page.rs +++ b/src/page.rs @@ -95,12 +95,52 @@ impl Page { .map(|c| c.as_os_str().to_string_lossy().to_string()) .collect(); let first_path_component = path_components[0].clone(); + if first_path_component == "layouts" { + return Ok(None); + } if Path::new(first_path_component.as_str()).is_file() { return Ok(None); } Ok(Some(first_path_component)) } + /// Determine if two pages are equivalent despite their rendered content. + /// + /// # Arguments + /// + /// * `lhs` - The first page to compare. + /// + /// * `rhs` - The second page to compare. + /// + /// # Returns + /// + /// Whether or not the two pages are equivalent. + pub fn are_equivalent(lhs: &Page, rhs: &Page) -> bool { + lhs.data == rhs.data + && lhs.content == rhs.content + && lhs.permalink == rhs.permalink + && lhs.date == rhs.date + && lhs.collections == rhs.collections + && lhs.layout == rhs.layout + && lhs.directory == rhs.directory + && lhs.name == rhs.name + && lhs.collection == rhs.collection + && lhs.is_layout == rhs.is_layout + } + + /// Determine if a page is equivalent to another page aside from rendered content. + /// + /// # Arguments + /// + /// * `other` - The page to compare to. + /// + /// # Returns + /// + /// Whether or not the two pages are equivalent. + pub fn is_equivalent(&self, other: &Page) -> bool { + Self::are_equivalent(self, other) + } + /// Determine if a page is a layout. /// /// # Returns