Compare commits

..

7 commits

Author SHA1 Message Date
Valentin Gagarin 765e34754b extract generic document type 2024-10-15 13:40:08 +02:00
Valentin Gagarin e962f92db8 add note 2024-10-14 17:26:17 +02:00
Valentin Gagarin 2e2bf6307b implement navigation 2024-10-14 17:26:17 +02:00
Valentin Gagarin 656fd790a2 split out template library 2024-10-14 13:18:20 +02:00
Valentin Gagarin 5303997e9a add TODO 2024-10-14 10:10:33 +02:00
Valentin Gagarin 6fc4ad6293 extract presentation module 2024-10-13 11:40:41 +02:00
Valentin Gagarin 2ce52f9530 add some documentation 2024-10-13 11:36:54 +02:00
6 changed files with 256 additions and 130 deletions

21
content/navigation.nix Normal file
View file

@ -0,0 +1,21 @@
{ config, ... }:
let
inherit (config) pages;
in
{
menus.main = {
label = "Main";
items = [
{
menu.label = "Consortium";
menu.items = map (page: { inherit page; }) (with pages; [ nlnet oid tweag nordunet ]);
}
{
page = pages.fediversity;
}
{
page = pages.grants;
}
];
};
}

View file

@ -22,6 +22,7 @@ in
modules = [ modules = [
./structure ./structure
./content ./content
./presentation
{ {
_module.args = { _module.args = {
inherit pkgs; inherit pkgs;

115
presentation/default.nix Normal file
View file

@ -0,0 +1,115 @@
{ config, options, lib, pkgs, ... }:
let
inherit (lib)
mkOption
types
;
templates = import ./templates.nix { inherit lib; };
in
{
options.templates = mkOption {
description = ''
Collection of named functions to convert page contents to files
Each template function takes the complete site `config` and the page data structure.
'';
type = with types; attrsOf (functionTo (functionTo options.files.type));
};
config.templates =
let
commonmark = name: markdown: pkgs.runCommand "${name}.html"
{
buildInputs = [ pkgs.cmark ];
} ''
cmark ${builtins.toFile "${name}.md" markdown} > $out
'';
in
{
page = lib.mkDefault (config: page: {
# TODO: create static redirects from `tail page.locations`
# TODO: reconsider using `page.outPath` and what to put into `locations`.
# maybe we can avoid having ".html" suffixes there.
# since templates can output multiple files, `html` is merely one of many things we *could* produce.
# TODO: maybe it would even make sense to split routing and rendering altogether
${page.outPath} = builtins.toFile "${page.name}.html" (templates.html {
head = ''
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
<link rel="canonical" href="${page.outPath}" />
'';
body = ''
${templates.nav { menu = { menu = config.menus.main; }; }}
${builtins.readFile (commonmark page.name page.body)}
'';
});
});
article = lib.mkDefault (config: page: {
# TODO: create static redirects from `tail page.locations`
${page.outPath} = builtins.toFile "${page.name}.html" (templates.html {
head = ''
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
<meta name="author" content="${with lib; if isList page.author then join ", " page.author else page.author}" />
'';
body = ''
${templates.nav { menu = { menu = config.menus.main; }; }}
${builtins.readFile (commonmark page.name page.body)}
'';
});
});
};
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.
'';
type = with types; attrsOf path;
};
config.files =
let
pages = lib.concatMapAttrs
(name: page: page.template config page)
config.pages;
collections =
let
byCollection = with lib; mapAttrs
(_: collection:
map (entry: entry.template config entry) collection.entry
)
config.collections;
in
with lib; concatMapAttrs
(collection: entries:
foldl' (acc: entry: acc // entry) { } entries
)
byCollection;
in
pages // collections;
options.build = mkOption {
description = ''
The final output of the web site
'';
type = types.package;
default =
let
script = ''
mkdir $out
'' + lib.join "\n" copy;
copy = lib.mapAttrsToList
(
path: file: ''
mkdir -p $out/$(dirname ${path})
cp -r ${file} $out/${path}
''
)
config.files;
in
pkgs.runCommand "source" { } script;
};
}

View file

@ -0,0 +1,37 @@
{ lib }:
rec {
html = { head, body }: ''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${lib.indent " " head}
</head>
<body>
${lib.indent " " body}
<body>
</html>
'';
nav = { menu }:
let
render-item = item:
if item ? menu then
''
<li>${item.menu.label}
${lib.indent " " (nav { menu = item; })}
''
else
if item ? page then ''<li><a href="${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.menu.items))}
</ul>
</nav>
'';
}

View file

@ -14,15 +14,11 @@ in
}; };
}; };
config.content-types = { config.content-types = {
page = { name, config, ... }: { document = { name, config, ... }: {
options = { options = {
name = mkOption { name = mkOption {
description = "Symbolic name for the page, used as a human-readable identifier"; description = "Symbolic name, used as a human-readable identifier";
type = types.str;
default = name;
};
title = mkOption {
description = "Page title";
type = types.str; type = types.str;
default = name; default = name;
}; };
@ -40,6 +36,7 @@ in
type = with types; functionTo str; type = with types; functionTo str;
default = target: "TODO: compute the relative path based on `locations`"; default = target: "TODO: compute the relative path based on `locations`";
}; };
# TODO: may not need it when using `link`; could repurpose it to render the default template
outPath = mkOption { outPath = mkOption {
description = '' description = ''
Location of the page, used for transparently creating links Location of the page, used for transparently creating links
@ -47,6 +44,28 @@ in
type = types.str; type = types.str;
default = lib.head config.locations; default = lib.head config.locations;
}; };
# TODO: maybe it would even make sense to split routing and rendering altogether.
# in that case, templates would return strings, and a different
# piece of the machinery resolves rendering templates to files
# using `locations`.
# then we'd have e.g. `templates.html` and `templates.atom` for
# different output formats.
template = mkOption {
description = ''
Function that converts the page contents to files
'';
type = with types; functionTo (functionTo options.files.type);
};
};
};
page = { name, config, ... }: {
imports = [ cfg.content-types.document ];
options = {
title = mkOption {
description = "Page title";
type = types.str;
default = name;
};
description = mkOption { description = mkOption {
description = '' description = ''
One-sentence description of page contents One-sentence description of page contents
@ -65,14 +84,8 @@ in
''; '';
type = types.str; type = types.str;
}; };
template = mkOption {
description = ''
Function that converts the page contents to files
'';
type = with types; functionTo (functionTo options.files.type);
default = cfg.templates.page;
};
}; };
config.template = cfg.templates.page;
}; };
article = { config, collectionName, ... }: { article = { config, collectionName, ... }: {
@ -91,7 +104,43 @@ in
}; };
config.name = lib.slug config.title; config.name = lib.slug config.title;
config.outPath = "${collectionName}/${lib.head config.locations}"; config.outPath = "${collectionName}/${lib.head config.locations}";
config.template = cfg.templates.article; config.template = lib.mkForce cfg.templates.article;
};
named-link = { ... }: {
options = {
label = mkOption {
description = "Link label";
type = types.str;
};
url = mkOption {
description = "Link URL";
type = types.str;
};
};
};
navigation = { name, ... }: {
options = {
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
type = types.str;
default = name;
};
label = mkOption {
description = "Menu label";
type = types.str;
default = name;
};
items = mkOption {
description = "List of menu items";
type = with types; listOf (attrTag {
menu = mkOption { type = submodule cfg.content-types.navigation; };
page = mkOption { type = submodule cfg.content-types.page; };
link = mkOption { type = submodule cfg.content-types.named-link; };
});
};
};
}; };
}; };
} }

View file

@ -9,6 +9,7 @@ in
{ {
imports = [ ./content-types.nix ]; imports = [ ./content-types.nix ];
# TODO: enable i18n, e.g. via a nested attribute for language-specific content
options.pages = mkOption { options.pages = mkOption {
description = '' description = ''
Collection of pages on the site Collection of pages on the site
@ -20,6 +21,20 @@ in
{ {
description = '' description = ''
Named collections of unnamed pages 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, ... }: { type = with types; attrsOf (submodule ({ name, config, ... }: {
options = { options = {
@ -39,122 +54,10 @@ in
})); }));
}; };
options.templates = mkOption { options.menus = mkOption {
description = '' description = ''
Collection of named functions to convert page contents to files Collection navigation menus
Each template function takes the complete site `config` and the page data structure.
''; '';
type = with types; attrsOf (functionTo (functionTo options.files.type)); type = with types; attrsOf (submodule config.content-types.navigation);
};
# TODO: split out templates and all related helper junk into `../presentation`
config.templates =
let
commonmark = name: markdown: pkgs.runCommand "${name}.html"
{
buildInputs = [ pkgs.cmark ];
} ''
cmark ${builtins.toFile "${name}.md" markdown} > $out
'';
in
{
page = lib.mkDefault (config: page: {
# TODO: create static redirects from `tail page.locations`
# TODO: reconsider using `page.outPath` and what to put into `locations`.
# maybe we can avoid having ".html" suffixes there.
# since templates can output multiple files, `html` is merely one of many things we *could* produce.
${page.outPath} = builtins.toFile "${page.name}.html" ''
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
<link rel="canonical" href="${page.outPath}" />
</head>
<body>
${lib.indent " " (builtins.readFile (commonmark page.name page.body))}
<body>
</html>
'';
});
article = lib.mkDefault (config: page: {
# TODO: create static redirects from `tail page.locations`
${page.outPath} = builtins.toFile "${page.name}.html" ''
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
${with lib;
if ! isNull page.author then
''<meta name="author" content="${if isList page.author then join ", " page.author else page.author}" />''
else ""
}
<link rel="canonical" href="${page.outPath}" />
</head>
<body>
${lib.indent " " (builtins.readFile (commonmark page.name page.body))}
<body>
</html>
'';
});
};
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.
'';
type = with types; attrsOf path;
};
config.files =
let
pages = lib.concatMapAttrs
(name: page: page.template config page)
config.pages;
collections =
let
byCollection = with lib; mapAttrs
(_: collection:
map (entry: entry.template config entry) collection.entry
)
config.collections;
in
with lib; concatMapAttrs
(collection: entries:
foldl' (acc: entry: acc // entry) { } entries
)
byCollection;
in
pages // collections;
options.build = mkOption {
description = ''
The final output of the web site
'';
type = types.package;
default =
let
script = ''
mkdir $out
'' + lib.join "\n" copy;
copy = lib.mapAttrsToList
(
path: file: ''
mkdir -p $out/$(dirname ${path})
cp -r ${file} $out/${path}
''
)
config.files;
in
pkgs.runCommand "source" { } script;
}; };
} }