Compare commits

...

5 commits

Author SHA1 Message Date
Valentin Gagarin 166dc5058a override page template for articles 2024-11-07 05:11:58 +01:00
Valentin Gagarin 6437ee43dc make page templates granularly overridable 2024-11-07 05:11:54 +01:00
Valentin Gagarin 71dc57b7d5 add resulting Nix value to default.nix outputs
this is practical for debugging and demonstration purposes
2024-11-07 03:17:09 +01:00
Valentin Gagarin 2ddd94faa5 move things to more appropriate places 2024-11-07 01:51:51 +01:00
Valentin Gagarin 38379f8a8f unify template parameters 2024-11-07 01:31:11 +01:00
12 changed files with 313 additions and 286 deletions

View file

@ -1,13 +1,14 @@
{ config, lib, ... }:
let
inherit (config) pages;
cfg = config;
in
{
imports = lib.nixFiles ./.;
collections.news.type = config.content-types.article;
collections.news.type = cfg.content-types.article;
pages.index = { link, ... }: {
pages.index = { config, link, ... }: {
title = "Fediversity";
description = "Fediversity web site";
summary = ''
@ -52,12 +53,19 @@ in
${
let
sorted = with lib; reverseList (sortOn (entry: entry.date) config.collections.news.entry);
sorted = with lib; reverseList (sortOn (entry: entry.date) cfg.collections.news.entry);
in
lib.join "\n" (map (article: ''
- ${article.date} [${article.title}](${link article})
'') sorted)
}
'';
outputs.html = (cfg.templates.html.page config).override {
html.body.content = lib.mkForce [
# don't show the page title as a heading
(cfg.menus.main.outputs.html config)
(cfg.templates.html.markdown { inherit (config) name body; })
];
};
};
}

View file

@ -15,25 +15,22 @@ let
new // { types = prev.recursiveUpdate prev.types new.types; };
lib'' = lib.extend lib';
in
{
rec {
lib = import ./lib.nix { inherit lib; };
result = lib''.evalModules {
modules = [
./structure
./content
./presentation
{
_module.args = {
inherit pkgs;
};
}
];
};
build =
let
result = lib''.evalModules {
modules = [
./structure
./content
./presentation
{
_module.args = {
inherit pkgs;
};
}
];
};
in
result.config.build;
inherit (result.config) build;
shell = pkgs.mkShellNoCC {
packages = with pkgs; [

View file

@ -1,5 +1,8 @@
{ lib }:
rec {
template = g: f: x:
(g (f x)) // { override = o: g (lib.recursiveUpdate (f x) o); };
/**
Recursively replace occurrences of `from` with `to` within `string`

View file

@ -4,98 +4,35 @@ let
mkOption
types
;
templates = import ./templates.nix { inherit lib; };
# TODO: optionally run the whole thing through the validator
# https://github.com/validator/validator
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ./dom.nix) ];
};
in
toString eval.config;
in
{
imports = lib.nixFiles ./.;
options.templates =
let
# arbitrarily nested attribute set where the leaves are of type `type`
# NOTE: due to how `either` works, the first match is significant,
# so if `type` happens to be an attrset, the typecheck will consider
# `type`, not `attrsOf`
recursiveAttrs = type: with types; attrsOf (either type (recursiveAttrs type));
recursiveAttrs = type: with types;
# NOTE: due to how `either` works, the first match is significant,
# so if `type` happens to be an attrset, the typecheck will consider
# `type`, not `attrsOf`
attrsOf (either type (recursiveAttrs type));
in
mkOption {
description = ''
Collection of named functions to convert document contents to a string representation
Each template function takes the complete site `config` and the document's data structure.
Collection of named helper functions for conversion different structured representations which can be rendered to a string
'';
# TODO: this function should probably take a single attrs,
# otherwise it's quite inflexible.
# named parameters would also help with readability at the call site
type = recursiveAttrs (with types; functionTo (functionTo str));
type = recursiveAttrs (with types; functionTo (either str attrs));
};
config.templates.html = {
markdown = name: text:
let
commonmark = pkgs.runCommand "${name}.html"
{
buildInputs = [ pkgs.cmark ];
} ''
cmark ${builtins.toFile "${name}.md" text} > $out
'';
in
builtins.readFile commonmark;
nav = menu: page:
let
render-item = item:
if item ? menu then ''
<li>${item.menu.label}
${lib.indent " " (item.menu.outputs.html page)}
</li>
''
else if item ? page then ''<li><a href="${page.link item.page}">${item.page.title}</a></li>''
else ''<li><a href="${item.link.url}">${item.link.label}</a></li>''
;
in
''
<nav>
<ul>
${with lib; indent " " (join "\n" (map render-item menu.items))}
</ul>
</nav>
'';
};
options.files = mkOption {
description = ''
Files that make up the site, mapping from output path to contents
By default, all elements in `option`{pages} are converted to files using their template or the default template.
Add more files to the output by assigning to this attribute set.
'';
# TODO: this should be attrsOf string-coercible instead.
# we can convert this to file at the very end.
type = with types; attrsOf path;
};
config.files =
# TODO: create static redirects from `tail page.locations`
let
pages = lib.attrValues config.pages;
collections = with lib; concatMap (collection: collection.entry) (attrValues config.collections);
in
with lib; foldl
(acc: elem: acc // {
# TODO: we may or may not want to enforce the mapping of file types to output file name suffixes
"${head elem.locations}.html" = builtins.toFile "${elem.name}.html" elem.outputs.html;
})
{ }
(pages ++ collections);
options.build = mkOption {
description = ''
The final output of the web site

View file

@ -9,6 +9,7 @@
let
cfg = config;
inherit (lib) mkOption types;
inherit (types) submodule;
# https://html.spec.whatwg.org/multipage/dom.html#content-models
# https://html.spec.whatwg.org/multipage/dom.html#kinds-of-content
@ -43,7 +44,7 @@ let
# options with types for all the defined DOM elements
element-types = lib.mapAttrs
(name: value: mkOption { type = types.submodule value; })
(name: value: mkOption { type = submodule value; })
elements;
# attrset of categories, where values are module options with the type of the
@ -52,7 +53,7 @@ let
genAttrs
content-categories
(category:
(mapAttrs (_: e: mkOption { type = types.submodule e; })
(mapAttrs (_: e: mkOption { type = submodule e; })
# HACK: don't evaluate the submodule types, just grab the config directly
(filterAttrs (_: e: elem category (e { name = "dummy"; config = { }; }).config.categories) elements))
);
@ -118,7 +119,7 @@ let
mkAttrs = attrs: with lib;
mkOption {
type = types.submodule {
type = submodule {
options = global-attrs // attrs;
};
default = { };
@ -147,12 +148,21 @@ let
print-element = name: attrs: content:
with lib;
# TODO: be smarter about content to save some space and repetition at the call sites
squash (trim ''
<${name}${print-attrs attrs}>
${lib.indent " " content}
</${name}>
'');
toString-unwrap = e:
with lib;
if isAttrs e
then toString (head (attrValues e))
else if isList e
then toString (map toString-unwrap e)
else e;
elements = rec {
document = { ... }: {
imports = [ element ];
@ -206,7 +216,7 @@ let
# https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag#viewport_width_and_screen_width
# this should not exist and no one should ever have to think about it
meta.viewport = mkOption {
type = types.submodule ({ ... }: {
type = submodule ({ ... }: {
# TODO: figure out how to render only non-default values
options = {
width = mkOption {
@ -422,21 +432,13 @@ let
config.categories = [ ];
config.__toString = self: with lib;
print-element name self.attrs (
join "\n" (map
(e:
if isAttrs e
then toString (lib.head (attrValues e))
else e
)
self.content)
);
print-element name self.attrs (join "\n" (map toString-unwrap self.content));
};
section = { config, name, ... }: {
imports = [ element ];
options = {
# setting to an attribute set will wrap the section in <section>
# setting to an attribute set will wrap the section in `<section>`
attrs = mkOption {
type = with types; nullOr (submodule { options = global-attrs; });
default = null;
@ -450,14 +452,18 @@ let
# such an outline is rather meaningless without headings for navigation,
# which is why we enforce headings in sections.
# arguably, and this is encoded here, a section *is defined* by its heading.
type = with types; submodule ({ ... }: {
type = with types; submodule ({ config, ... }: {
imports = [ element ];
options = {
attrs = mkAttrs { };
# TODO: make `before`/`after` wrap the heading in `<hgroup>` if non-empty
# setting to an attribute set will wrap the section in `<hgroup>`
hgroup.attrs = mkOption {
type = with types; nullOr (submodule { options = global-attrs; });
default = with lib; mkIf (!isNull config.before || !isNull config.after) { };
};
# https://html.spec.whatwg.org/multipage/sections.html#the-hgroup-element
before = mkOption {
type = with types; listOf (attrTag ({ inherit p; } // categories.scripting));
type = with types; listOf (attrTag ({ inherit (element-types) p; } // categories.scripting));
default = [ ];
};
content = mkOption {
@ -466,7 +472,7 @@ let
};
after = mkOption {
type = with types;
listOf (attrTag ({ inherit p; } // categories.scripting));
listOf (attrTag ({ inherit (element-types) p; } // categories.scripting));
default = [ ];
};
};
@ -489,20 +495,32 @@ let
__toString = self: with lib;
let
n = toString config.heading-level;
content = ''<h${n}${print-attrs self.heading.attrs}>${self.heading.content}</h${n}>
'' + join "\n" (map
(e:
if isAttrs e
then toString (lib.head (attrValues e))
else e
)
self.content);
heading = ''<h${n}${print-attrs self.heading.attrs}>${self.heading.content}</h${n}>'';
hgroup = with lib; print-element "hgroup" self.heading.hgroup.attrs (squash ''
${optionalString (!isNull self.heading.before) (toString-unwrap self.heading.before)}
${heading}
${optionalString (!isNull self.heading.after) (toString-unwrap self.heading.after)}
'');
content = if isNull self.heading.hgroup.attrs then heading else hgroup
+ join "\n" (map toString-unwrap self.content);
in
if !isNull self.attrs
then print-element name self.attrs content
else content;
};
};
p = { name, ... }: {
imports = [ element ];
options = {
attrs = mkAttrs { };
content = mkOption {
type = with types; either str (listOf (attrTag categories.phrasing));
};
};
config.categories = [ "flow" "palpable" ];
config.__toString = self: print-element name self.attrs (toString self.content);
};
};
in
{

View file

@ -0,0 +1,52 @@
{ config, options, lib, pkgs, ... }:
let
inherit (lib)
mkOption
types
;
in
{
config.templates.html = {
dom = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ./dom.nix) ];
};
in
{
__toString = _: toString eval.config;
value = eval.config;
};
markdown = { name, body }:
let
commonmark = pkgs.runCommand "${name}.html"
{
buildInputs = [ pkgs.cmark ];
} ''
cmark ${builtins.toFile "${name}.md" body} > $out
'';
in
builtins.readFile commonmark;
nav = { menu, page }:
let
render-item = item:
if item ? menu then ''
<li>${item.menu.label}
${lib.indent " " (item.menu.outputs.html page)}
</li>
''
else if item ? page then ''<li><a href="${page.link item.page}">${item.page.title}</a></li>''
else ''<li><a href="${item.link.url}">${item.link.label}</a></li>''
;
in
''
<nav>
<ul>
${with lib; indent " " (join "\n" (map render-item menu.items))}
</ul>
</nav>
'';
};
}

View file

@ -5,14 +5,6 @@ let
types
;
cfg = config;
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ../presentation/dom.nix) ];
};
in
toString eval.config;
in
{
content-types.article = { config, collection, ... }: {
@ -35,18 +27,22 @@ in
};
};
config.name = lib.slug config.title;
config.outputs.html = lib.mkForce (render-html {
config.outputs.html = lib.mkForce ((cfg.templates.html.page config).override {
html = {
head = {
title.text = config.title;
meta.description = config.description;
meta.authors = if lib.isList config.author then config.author else [ config.author ];
link.canonical = lib.head config.locations;
};
body.content = [
# TODO: make authors always a list
head.meta.authors = if lib.isList config.author then config.author else [ config.author ];
body.content = lib.mkForce [
(cfg.menus.main.outputs.html config)
{ section.heading.content = config.title; }
(cfg.templates.html.markdown config.name config.body)
{
section.heading = {
# TODO: i18n support
# TODO: structured dates
before = [{ p.content = "Published ${config.date}"; }];
content = config.title;
after = [{ p.content = "Written by ${config.author}"; }];
};
}
(cfg.templates.html.markdown { inherit (config) name body; })
];
};
});

75
structure/collections.nix Normal file
View file

@ -0,0 +1,75 @@
{ config, options, lib, pkgs, ... }:
let
inherit (lib)
mkOption
types
;
cfg = config;
in
{
options.collections = mkOption {
description = ''
Named collections of unnamed pages
Define the content type of a new collection `example` to be `article`:
```nix
config.collections.example.type = config.types.article;
```
Add a new entry to the `example` collection:
```nix
config.collections.example.entry = {
# contents here
}
```
'';
type = with types; attrsOf (submodule ({ name, config, ... }: {
options = {
type = mkOption {
description = "Type of entries in the collection";
type = types.deferredModule;
};
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
type = types.str;
default = name;
};
prefixes = mkOption {
description = ''
List of historic output locations for files in the collection
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
The default entry is the symbolic name of the collection.
When changing the symbolic name, append the old one to your custom list and use `lib.mkForce` to make sure the default element will be overridden.
'';
type = with types; nonEmptyListOf str;
example = [ "." ];
default = [ config.name ];
};
entry = mkOption {
description = "An entry in the collection";
type = types.collection (types.submodule ({
imports = [ config.type ];
_module.args.collection = config;
process-locations = ls: with lib; concatMap (l: map (p: "${p}/${l}") config.prefixes) ls;
}));
};
};
}));
};
config.files =
# TODO: create static redirects from `tail <collection>.locations`
let
collections = with lib; concatMap (collection: collection.entry) (attrValues config.collections);
in
with lib; foldl
(acc: elem: acc // {
"${head elem.locations}.html" = builtins.toFile "${elem.name}.html" "${elem.outputs.html}";
})
{ }
collections;
}

View file

@ -14,73 +14,51 @@ in
type = with types; attrsOf deferredModule;
};
# TODO: enable i18n, e.g. via a nested attribute for language-specific content
options.pages = mkOption {
description = ''
Collection of pages on the site
'';
type = with types; attrsOf (submodule config.content-types.page);
};
options.collections = mkOption {
description = ''
Named collections of unnamed pages
Define the content type of a new collection `example` to be `article`:
```nix
config.collections.example.type = config.types.article;
```
Add a new entry to the `example` collection:
```nix
config.collections.example.entry = {
# contents here
}
```
'';
type = with types; attrsOf (submodule ({ name, config, ... }: {
options = {
type = mkOption {
description = "Type of entries in the collection";
type = types.deferredModule;
};
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
type = types.str;
default = name;
};
prefixes = mkOption {
description = ''
List of historic output locations for files in the collection
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
The default entry is the symbolic name of the collection.
When changing the symbolic name, append the old one to your custom list and use `lib.mkForce` to make sure the default element will be overridden.
'';
type = with types; nonEmptyListOf str;
example = [ "." ];
default = [ config.name ];
};
entry = mkOption {
description = "An entry in the collection";
type = types.collection (types.submodule ({
imports = [ config.type ];
_module.args.collection = config;
process-locations = ls: with lib; concatMap (l: map (p: "${p}/${l}") config.prefixes) ls;
}));
};
config.content-types.document = { name, config, options, link, ... }: {
config._module.args.link = config.link;
options = {
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
type = types.str;
default = name;
};
}));
};
locations = mkOption {
description = ''
List of historic output locations for the resulting file
options.menus = mkOption {
description = ''
Collection navigation menus
'';
type = with types; attrsOf (submodule config.content-types.navigation);
Elements are relative paths to output files, without suffix.
The suffix will be added depending on output file type.
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
The default entry is the symbolic name of the document.
When changing the symbolic name, append the old one to your custom list and use `lib.mkForce` to make sure the default element will be overridden.
'';
type = with types; nonEmptyListOf str;
apply = config.process-locations;
example = [ "about/overview" "index" ];
default = [ config.name ];
};
process-locations = mkOption {
description = "Function to post-process the output locations of contained document";
type = types.functionTo options.locations.type;
default = lib.id;
};
link = mkOption {
description = "Helper function for transparent linking to other pages";
type = with types; functionTo str;
# TODO: we may want links to other representations,
# and currently the mapping of output types to output file
# names is soft.
default = target: with lib; "${relativePath (head config.locations) (head target.locations)}.html";
};
outputs = mkOption {
description = ''
Representations of the document in different formats
'';
type = with types; attrsOf (either str attrs);
};
};
};
}

View file

@ -1,58 +0,0 @@
{ lib, ... }:
let
inherit (lib)
mkOption
types
;
in
{
content-types.document = { name, config, options, link, ... }: {
config._module.args.link = config.link;
options = {
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
type = types.str;
default = name;
};
locations = mkOption {
description = ''
List of historic output locations for the resulting file
Elements are relative paths to output files, without suffix.
The suffix will be added depending on output file type.
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
The default entry is the symbolic name of the document.
When changing the symbolic name, append the old one to your custom list and use `lib.mkForce` to make sure the default element will be overridden.
'';
type = with types; nonEmptyListOf str;
apply = config.process-locations;
example = [ "about/overview" "index" ];
default = [ config.name ];
};
process-locations = mkOption {
description = "Function to post-process the output locations of contained document";
type = types.functionTo options.locations.type;
default = lib.id;
};
link = mkOption {
description = "Helper function for transparent linking to other pages";
type = with types; functionTo str;
# TODO: we may want links to other representations,
# and currently the mapping of output types to output file
# names is soft.
default = target: with lib; "${relativePath (head config.locations) (head target.locations)}.html";
};
outputs.html = mkOption {
# TODO: make this of type DOM and convert to string at the output.
# the output aggregator then only needs something string-coercible
description = ''
Representations of the document in different formats
'';
type = with types; str;
};
};
};
}

View file

@ -16,7 +16,14 @@ let
];
in
{
content-types.named-link = { ... }: {
options.menus = mkOption {
description = ''
Collection navigation menus
'';
type = with types; attrsOf (submodule config.content-types.navigation);
};
config.content-types.named-link = { ... }: {
options = {
label = mkOption {
description = "Link label";
@ -29,7 +36,7 @@ in
};
};
content-types.navigation = { name, config, ... }: {
config.content-types.navigation = { name, config, ... }: {
options = {
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
@ -56,7 +63,9 @@ in
It must be a function that takes the page on which the navigation is to be shown, such that relative links get computed correctly.
'';
type = with types; attrsOf (functionTo str);
default.html = cfg.templates.html.nav config;
default.html = page: cfg.templates.html.nav {
menu = config; inherit page;
};
};
};
};

View file

@ -5,17 +5,26 @@ let
types
;
cfg = config;
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ../presentation/dom.nix) ];
};
in
toString eval.config;
in
{
content-types.page = { name, config, ... }: {
# TODO: enable i18n, e.g. via a nested attribute for language-specific content
options.pages = mkOption {
description = ''
Collection of pages on the site
'';
type = with types; attrsOf (submodule config.content-types.page);
};
config.files = with lib;
foldl'
(acc: elem: acc // {
# TODO: create static redirects from `tail page.locations`
# TODO: the file name could correspond to the canonical location in the HTML representation
"${head elem.locations}.html" = builtins.toFile "${elem.name}.html" "${elem.outputs.html}";
})
{ }
(attrValues config.pages);
config.content-types.page = { name, config, ... }: {
imports = [ cfg.content-types.document ];
options = {
title = mkOption {
@ -42,19 +51,22 @@ in
type = types.str;
};
};
config.outputs.html = render-html {
html = {
head = {
title.text = config.title;
meta.description = config.description;
link.canonical = lib.head config.locations;
};
body.content = [
(cfg.menus.main.outputs.html config)
{ section.heading.content = config.title; }
(cfg.templates.html.markdown config.name config.body)
];
};
};
config.outputs.html = cfg.templates.html.page config;
};
config.templates.html.page = lib.template cfg.templates.html.dom (page: {
html = {
head = {
title.text = page.title;
meta.description = page.description;
link.canonical = lib.head page.locations;
};
body.content = [
(cfg.menus.main.outputs.html page)
{ section.heading.content = page.title; }
(cfg.templates.html.markdown { inherit (page) name body; })
];
};
});
}