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, ... }: { config, lib, ... }:
let let
inherit (config) pages; inherit (config) pages;
cfg = config;
in in
{ {
imports = lib.nixFiles ./.; 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"; title = "Fediversity";
description = "Fediversity web site"; description = "Fediversity web site";
summary = '' summary = ''
@ -52,12 +53,19 @@ in
${ ${
let 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 in
lib.join "\n" (map (article: '' lib.join "\n" (map (article: ''
- ${article.date} [${article.title}](${link article}) - ${article.date} [${article.title}](${link article})
'') sorted) '') 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,11 +15,8 @@ let
new // { types = prev.recursiveUpdate prev.types new.types; }; new // { types = prev.recursiveUpdate prev.types new.types; };
lib'' = lib.extend lib'; lib'' = lib.extend lib';
in in
{ rec {
lib = import ./lib.nix { inherit lib; }; lib = import ./lib.nix { inherit lib; };
build =
let
result = lib''.evalModules { result = lib''.evalModules {
modules = [ modules = [
./structure ./structure
@ -32,8 +29,8 @@ in
} }
]; ];
}; };
in
result.config.build; inherit (result.config) build;
shell = pkgs.mkShellNoCC { shell = pkgs.mkShellNoCC {
packages = with pkgs; [ packages = with pkgs; [

View file

@ -1,5 +1,8 @@
{ lib }: { lib }:
rec { 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` Recursively replace occurrences of `from` with `to` within `string`

View file

@ -4,98 +4,35 @@ let
mkOption mkOption
types 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 in
{ {
imports = lib.nixFiles ./.;
options.templates = options.templates =
let let
# arbitrarily nested attribute set where the leaves are of type `type` # arbitrarily nested attribute set where the leaves are of type `type`
recursiveAttrs = type: with types;
# NOTE: due to how `either` works, the first match is significant, # NOTE: due to how `either` works, the first match is significant,
# so if `type` happens to be an attrset, the typecheck will consider # so if `type` happens to be an attrset, the typecheck will consider
# `type`, not `attrsOf` # `type`, not `attrsOf`
recursiveAttrs = type: with types; attrsOf (either type (recursiveAttrs type)); attrsOf (either type (recursiveAttrs type));
in in
mkOption { mkOption {
description = '' description = ''
Collection of named functions to convert document contents to a string representation Collection of named helper functions for conversion different structured representations which can be rendered to a string
Each template function takes the complete site `config` and the document's data structure.
''; '';
# TODO: this function should probably take a single attrs, type = recursiveAttrs (with types; functionTo (either str attrs));
# otherwise it's quite inflexible.
# named parameters would also help with readability at the call site
type = recursiveAttrs (with types; functionTo (functionTo str));
};
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 { options.files = mkOption {
description = '' description = ''
Files that make up the site, mapping from output path to contents 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. 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; 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 { options.build = mkOption {
description = '' description = ''
The final output of the web site The final output of the web site

View file

@ -9,6 +9,7 @@
let let
cfg = config; cfg = config;
inherit (lib) mkOption types; 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#content-models
# https://html.spec.whatwg.org/multipage/dom.html#kinds-of-content # 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 # options with types for all the defined DOM elements
element-types = lib.mapAttrs element-types = lib.mapAttrs
(name: value: mkOption { type = types.submodule value; }) (name: value: mkOption { type = submodule value; })
elements; elements;
# attrset of categories, where values are module options with the type of the # attrset of categories, where values are module options with the type of the
@ -52,7 +53,7 @@ let
genAttrs genAttrs
content-categories content-categories
(category: (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 # HACK: don't evaluate the submodule types, just grab the config directly
(filterAttrs (_: e: elem category (e { name = "dummy"; config = { }; }).config.categories) elements)) (filterAttrs (_: e: elem category (e { name = "dummy"; config = { }; }).config.categories) elements))
); );
@ -118,7 +119,7 @@ let
mkAttrs = attrs: with lib; mkAttrs = attrs: with lib;
mkOption { mkOption {
type = types.submodule { type = submodule {
options = global-attrs // attrs; options = global-attrs // attrs;
}; };
default = { }; default = { };
@ -147,12 +148,21 @@ let
print-element = name: attrs: content: print-element = name: attrs: content:
with lib; with lib;
# TODO: be smarter about content to save some space and repetition at the call sites
squash (trim '' squash (trim ''
<${name}${print-attrs attrs}> <${name}${print-attrs attrs}>
${lib.indent " " content} ${lib.indent " " content}
</${name}> </${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 { elements = rec {
document = { ... }: { document = { ... }: {
imports = [ element ]; imports = [ element ];
@ -206,7 +216,7 @@ let
# https://developer.mozilla.org/en-US/docs/Web/HTML/Viewport_meta_tag#viewport_width_and_screen_width # 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 # this should not exist and no one should ever have to think about it
meta.viewport = mkOption { meta.viewport = mkOption {
type = types.submodule ({ ... }: { type = submodule ({ ... }: {
# TODO: figure out how to render only non-default values # TODO: figure out how to render only non-default values
options = { options = {
width = mkOption { width = mkOption {
@ -422,21 +432,13 @@ let
config.categories = [ ]; config.categories = [ ];
config.__toString = self: with lib; config.__toString = self: with lib;
print-element name self.attrs ( print-element name self.attrs (join "\n" (map toString-unwrap self.content));
join "\n" (map
(e:
if isAttrs e
then toString (lib.head (attrValues e))
else e
)
self.content)
);
}; };
section = { config, name, ... }: { section = { config, name, ... }: {
imports = [ element ]; imports = [ element ];
options = { 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 { attrs = mkOption {
type = with types; nullOr (submodule { options = global-attrs; }); type = with types; nullOr (submodule { options = global-attrs; });
default = null; default = null;
@ -450,14 +452,18 @@ let
# such an outline is rather meaningless without headings for navigation, # such an outline is rather meaningless without headings for navigation,
# which is why we enforce headings in sections. # which is why we enforce headings in sections.
# arguably, and this is encoded here, a section *is defined* by its heading. # arguably, and this is encoded here, a section *is defined* by its heading.
type = with types; submodule ({ ... }: { type = with types; submodule ({ config, ... }: {
imports = [ element ]; imports = [ element ];
options = { options = {
attrs = mkAttrs { }; 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 # https://html.spec.whatwg.org/multipage/sections.html#the-hgroup-element
before = mkOption { before = mkOption {
type = with types; listOf (attrTag ({ inherit p; } // categories.scripting)); type = with types; listOf (attrTag ({ inherit (element-types) p; } // categories.scripting));
default = [ ]; default = [ ];
}; };
content = mkOption { content = mkOption {
@ -466,7 +472,7 @@ let
}; };
after = mkOption { after = mkOption {
type = with types; type = with types;
listOf (attrTag ({ inherit p; } // categories.scripting)); listOf (attrTag ({ inherit (element-types) p; } // categories.scripting));
default = [ ]; default = [ ];
}; };
}; };
@ -489,20 +495,32 @@ let
__toString = self: with lib; __toString = self: with lib;
let let
n = toString config.heading-level; n = toString config.heading-level;
content = ''<h${n}${print-attrs self.heading.attrs}>${self.heading.content}</h${n}> heading = ''<h${n}${print-attrs self.heading.attrs}>${self.heading.content}</h${n}>'';
'' + join "\n" (map hgroup = with lib; print-element "hgroup" self.heading.hgroup.attrs (squash ''
(e: ${optionalString (!isNull self.heading.before) (toString-unwrap self.heading.before)}
if isAttrs e ${heading}
then toString (lib.head (attrValues e)) ${optionalString (!isNull self.heading.after) (toString-unwrap self.heading.after)}
else e '');
) content = if isNull self.heading.hgroup.attrs then heading else hgroup
self.content); + join "\n" (map toString-unwrap self.content);
in in
if !isNull self.attrs if !isNull self.attrs
then print-element name self.attrs content then print-element name self.attrs content
else 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 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 types
; ;
cfg = config; cfg = config;
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ../presentation/dom.nix) ];
};
in
toString eval.config;
in in
{ {
content-types.article = { config, collection, ... }: { content-types.article = { config, collection, ... }: {
@ -35,18 +27,22 @@ in
}; };
}; };
config.name = lib.slug config.title; 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 = { html = {
head = { # TODO: make authors always a list
title.text = config.title; head.meta.authors = if lib.isList config.author then config.author else [ config.author ];
meta.description = config.description; body.content = lib.mkForce [
meta.authors = if lib.isList config.author then config.author else [ config.author ];
link.canonical = lib.head config.locations;
};
body.content = [
(cfg.menus.main.outputs.html config) (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; type = with types; attrsOf deferredModule;
}; };
# TODO: enable i18n, e.g. via a nested attribute for language-specific content config.content-types.document = { name, config, options, link, ... }: {
options.pages = mkOption { config._module.args.link = config.link;
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 = { options = {
type = mkOption {
description = "Type of entries in the collection";
type = types.deferredModule;
};
name = mkOption { name = mkOption {
description = "Symbolic name, used as a human-readable identifier"; description = "Symbolic name, used as a human-readable identifier";
type = types.str; type = types.str;
default = name; default = name;
}; };
prefixes = mkOption { locations = mkOption {
description = '' description = ''
List of historic output locations for files in the collection 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. The first element is the canonical location.
All other elements are used to create redirects to 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. 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. 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; type = with types; nonEmptyListOf str;
example = [ "." ]; apply = config.process-locations;
example = [ "about/overview" "index" ];
default = [ config.name ]; default = [ config.name ];
}; };
entry = mkOption { process-locations = mkOption {
description = "An entry in the collection"; description = "Function to post-process the output locations of contained document";
type = types.collection (types.submodule ({ type = types.functionTo options.locations.type;
imports = [ config.type ]; default = lib.id;
_module.args.collection = config;
process-locations = ls: with lib; concatMap (l: map (p: "${p}/${l}") config.prefixes) ls;
}));
}; };
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 {
};
options.menus = mkOption {
description = '' description = ''
Collection navigation menus Representations of the document in different formats
''; '';
type = with types; attrsOf (submodule config.content-types.navigation); 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 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 = { options = {
label = mkOption { label = mkOption {
description = "Link label"; description = "Link label";
@ -29,7 +36,7 @@ in
}; };
}; };
content-types.navigation = { name, config, ... }: { config.content-types.navigation = { name, config, ... }: {
options = { options = {
name = mkOption { name = mkOption {
description = "Symbolic name, used as a human-readable identifier"; 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. 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); 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 types
; ;
cfg = config; cfg = config;
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ../presentation/dom.nix) ];
};
in
toString eval.config;
in 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 ]; imports = [ cfg.content-types.document ];
options = { options = {
title = mkOption { title = mkOption {
@ -42,19 +51,22 @@ in
type = types.str; type = types.str;
}; };
}; };
config.outputs.html = render-html {
config.outputs.html = cfg.templates.html.page config;
};
config.templates.html.page = lib.template cfg.templates.html.dom (page: {
html = { html = {
head = { head = {
title.text = config.title; title.text = page.title;
meta.description = config.description; meta.description = page.description;
link.canonical = lib.head config.locations; link.canonical = lib.head page.locations;
}; };
body.content = [ body.content = [
(cfg.menus.main.outputs.html config) (cfg.menus.main.outputs.html page)
{ section.heading.content = config.title; } { section.heading.content = page.title; }
(cfg.templates.html.markdown config.name config.body) (cfg.templates.html.markdown { inherit (page) name body; })
]; ];
}; };
}; });
};
} }