Compare commits

..

No commits in common. "166dc5058a5f4341aaaa25cef2752a43d6a4e1d2" and "bcd432d0c2a12c7514a2bf98c06904fbf0e96e39" have entirely different histories.

12 changed files with 283 additions and 310 deletions

View file

@ -1,14 +1,13 @@
{ config, lib, ... }:
let
inherit (config) pages;
cfg = config;
in
{
imports = lib.nixFiles ./.;
collections.news.type = cfg.content-types.article;
collections.news.type = config.content-types.article;
pages.index = { config, link, ... }: {
pages.index = { link, ... }: {
title = "Fediversity";
description = "Fediversity web site";
summary = ''
@ -53,19 +52,12 @@ in
${
let
sorted = with lib; reverseList (sortOn (entry: entry.date) cfg.collections.news.entry);
sorted = with lib; reverseList (sortOn (entry: entry.date) config.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,22 +15,25 @@ 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;
};
}
];
};
inherit (result.config) build;
build =
let
result = lib''.evalModules {
modules = [
./structure
./content
./presentation
{
_module.args = {
inherit pkgs;
};
}
];
};
in
result.config.build;
shell = pkgs.mkShellNoCC {
packages = with pkgs; [

View file

@ -1,8 +1,5 @@
{ 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,35 +4,98 @@ 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`
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));
# 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));
in
mkOption {
description = ''
Collection of named helper functions for conversion different structured representations which can be rendered to a string
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.
'';
type = recursiveAttrs (with types; functionTo (either str attrs));
# 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));
};
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,7 +9,6 @@
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
@ -44,7 +43,7 @@ let
# options with types for all the defined DOM elements
element-types = lib.mapAttrs
(name: value: mkOption { type = submodule value; })
(name: value: mkOption { type = types.submodule value; })
elements;
# attrset of categories, where values are module options with the type of the
@ -53,7 +52,7 @@ let
genAttrs
content-categories
(category:
(mapAttrs (_: e: mkOption { type = submodule e; })
(mapAttrs (_: e: mkOption { type = types.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))
);
@ -119,7 +118,7 @@ let
mkAttrs = attrs: with lib;
mkOption {
type = submodule {
type = types.submodule {
options = global-attrs // attrs;
};
default = { };
@ -148,21 +147,12 @@ 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 ];
@ -216,7 +206,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 = submodule ({ ... }: {
type = types.submodule ({ ... }: {
# TODO: figure out how to render only non-default values
options = {
width = mkOption {
@ -432,13 +422,21 @@ let
config.categories = [ ];
config.__toString = self: with lib;
print-element name self.attrs (join "\n" (map toString-unwrap self.content));
print-element name self.attrs (
join "\n" (map
(e:
if isAttrs e
then toString (lib.head (attrValues e))
else e
)
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;
@ -452,18 +450,14 @@ 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 ({ config, ... }: {
type = with types; submodule ({ ... }: {
imports = [ element ];
options = {
attrs = mkAttrs { };
# 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) { };
};
# TODO: make `before`/`after` wrap the heading in `<hgroup>` if non-empty
# https://html.spec.whatwg.org/multipage/sections.html#the-hgroup-element
before = mkOption {
type = with types; listOf (attrTag ({ inherit (element-types) p; } // categories.scripting));
type = with types; listOf (attrTag ({ inherit p; } // categories.scripting));
default = [ ];
};
content = mkOption {
@ -472,7 +466,7 @@ let
};
after = mkOption {
type = with types;
listOf (attrTag ({ inherit (element-types) p; } // categories.scripting));
listOf (attrTag ({ inherit p; } // categories.scripting));
default = [ ];
};
};
@ -495,32 +489,20 @@ let
__toString = self: with lib;
let
n = toString config.heading-level;
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);
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);
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

@ -1,52 +0,0 @@
{ 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,6 +5,14 @@ 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, ... }: {
@ -27,22 +35,18 @@ in
};
};
config.name = lib.slug config.title;
config.outputs.html = lib.mkForce ((cfg.templates.html.page config).override {
config.outputs.html = lib.mkForce (render-html {
html = {
# TODO: make authors always a list
head.meta.authors = if lib.isList config.author then config.author else [ config.author ];
body.content = lib.mkForce [
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 = [
(cfg.menus.main.outputs.html config)
{
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; })
{ section.heading.content = config.title; }
(cfg.templates.html.markdown config.name config.body)
];
};
});

View file

@ -1,75 +0,0 @@
{ 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,51 +14,73 @@ in
type = with types; attrsOf deferredModule;
};
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
# 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);
};
Elements are relative paths to output files, without suffix.
The suffix will be added depending on output file type.
options.collections = mkOption {
description = ''
Named collections of unnamed pages
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
Define the content type of a new collection `example` to be `article`:
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 ];
```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;
}));
};
};
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);
};
};
}));
};
options.menus = mkOption {
description = ''
Collection navigation menus
'';
type = with types; attrsOf (submodule config.content-types.navigation);
};
}

58
structure/document.nix Normal file
View file

@ -0,0 +1,58 @@
{ 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,14 +16,7 @@ let
];
in
{
options.menus = mkOption {
description = ''
Collection navigation menus
'';
type = with types; attrsOf (submodule config.content-types.navigation);
};
config.content-types.named-link = { ... }: {
content-types.named-link = { ... }: {
options = {
label = mkOption {
description = "Link label";
@ -36,7 +29,7 @@ in
};
};
config.content-types.navigation = { name, config, ... }: {
content-types.navigation = { name, config, ... }: {
options = {
name = mkOption {
description = "Symbolic name, used as a human-readable identifier";
@ -63,9 +56,7 @@ 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 = page: cfg.templates.html.nav {
menu = config; inherit page;
};
default.html = cfg.templates.html.nav config;
};
};
};

View file

@ -5,26 +5,17 @@ let
types
;
cfg = config;
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ../presentation/dom.nix) ];
};
in
toString eval.config;
in
{
# 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, ... }: {
content-types.page = { name, config, ... }: {
imports = [ cfg.content-types.document ];
options = {
title = mkOption {
@ -51,22 +42,19 @@ in
type = types.str;
};
};
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;
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)
];
};
body.content = [
(cfg.menus.main.outputs.html page)
{ section.heading.content = page.title; }
(cfg.templates.html.markdown { inherit (page) name body; })
];
};
});
};
}