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, ... }: { config, lib, ... }:
let let
inherit (config) pages; inherit (config) pages;
cfg = config;
in in
{ {
imports = lib.nixFiles ./.; 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"; title = "Fediversity";
description = "Fediversity web site"; description = "Fediversity web site";
summary = '' summary = ''
@ -53,19 +52,12 @@ in
${ ${
let 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 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,8 +15,11 @@ 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
@ -29,8 +32,8 @@ rec {
} }
]; ];
}; };
in
inherit (result.config) build; result.config.build;
shell = pkgs.mkShellNoCC { shell = pkgs.mkShellNoCC {
packages = with pkgs; [ packages = with pkgs; [

View file

@ -1,8 +1,5 @@
{ 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,35 +4,98 @@ 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`
attrsOf (either type (recursiveAttrs type)); recursiveAttrs = type: with types; attrsOf (either type (recursiveAttrs type));
in in
mkOption { mkOption {
description = '' 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 { 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,7 +9,6 @@
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
@ -44,7 +43,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 = submodule value; }) (name: value: mkOption { type = types.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
@ -53,7 +52,7 @@ let
genAttrs genAttrs
content-categories content-categories
(category: (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 # 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))
); );
@ -119,7 +118,7 @@ let
mkAttrs = attrs: with lib; mkAttrs = attrs: with lib;
mkOption { mkOption {
type = submodule { type = types.submodule {
options = global-attrs // attrs; options = global-attrs // attrs;
}; };
default = { }; default = { };
@ -148,21 +147,12 @@ 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 ];
@ -216,7 +206,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 = submodule ({ ... }: { type = types.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 {
@ -432,13 +422,21 @@ let
config.categories = [ ]; config.categories = [ ];
config.__toString = self: with lib; 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, ... }: { 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;
@ -452,18 +450,14 @@ 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 ({ config, ... }: { type = with types; submodule ({ ... }: {
imports = [ element ]; imports = [ element ];
options = { options = {
attrs = mkAttrs { }; attrs = mkAttrs { };
# setting to an attribute set will wrap the section in `<hgroup>` # TODO: make `before`/`after` wrap the heading in `<hgroup>` if non-empty
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 (element-types) p; } // categories.scripting)); type = with types; listOf (attrTag ({ inherit p; } // categories.scripting));
default = [ ]; default = [ ];
}; };
content = mkOption { content = mkOption {
@ -472,7 +466,7 @@ let
}; };
after = mkOption { after = mkOption {
type = with types; type = with types;
listOf (attrTag ({ inherit (element-types) p; } // categories.scripting)); listOf (attrTag ({ inherit p; } // categories.scripting));
default = [ ]; default = [ ];
}; };
}; };
@ -495,32 +489,20 @@ let
__toString = self: with lib; __toString = self: with lib;
let let
n = toString config.heading-level; n = toString config.heading-level;
heading = ''<h${n}${print-attrs self.heading.attrs}>${self.heading.content}</h${n}>''; content = ''<h${n}${print-attrs self.heading.attrs}>${self.heading.content}</h${n}>
hgroup = with lib; print-element "hgroup" self.heading.hgroup.attrs (squash '' '' + join "\n" (map
${optionalString (!isNull self.heading.before) (toString-unwrap self.heading.before)} (e:
${heading} if isAttrs e
${optionalString (!isNull self.heading.after) (toString-unwrap self.heading.after)} then toString (lib.head (attrValues e))
''); else e
content = if isNull self.heading.hgroup.attrs then heading else hgroup )
+ join "\n" (map toString-unwrap self.content); 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

@ -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 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, ... }: {
@ -27,22 +35,18 @@ in
}; };
}; };
config.name = lib.slug config.title; 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 = { html = {
# TODO: make authors always a list head = {
head.meta.authors = if lib.isList config.author then config.author else [ config.author ]; title.text = config.title;
body.content = lib.mkForce [ meta.description = config.description;
(cfg.menus.main.outputs.html config) meta.authors = if lib.isList config.author then config.author else [ config.author ];
{ link.canonical = lib.head config.locations;
section.heading = {
# TODO: i18n support
# TODO: structured dates
before = [{ p.content = "Published ${config.date}"; }];
content = config.title;
after = [{ p.content = "Written by ${config.author}"; }];
}; };
} body.content = [
(cfg.templates.html.markdown { inherit (config) name body; }) (cfg.menus.main.outputs.html config)
{ 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; type = with types; attrsOf deferredModule;
}; };
config.content-types.document = { name, config, options, link, ... }: { # TODO: enable i18n, e.g. via a nested attribute for language-specific content
config._module.args.link = config.link; 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 = { 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;
}; };
locations = mkOption { prefixes = mkOption {
description = '' description = ''
List of historic output locations for the resulting file List of historic output locations for files in the collection
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 document. 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. 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;
apply = config.process-locations; example = [ "." ];
example = [ "about/overview" "index" ];
default = [ config.name ]; default = [ config.name ];
}; };
process-locations = mkOption { entry = mkOption {
description = "Function to post-process the output locations of contained document"; description = "An entry in the collection";
type = types.functionTo options.locations.type; type = types.collection (types.submodule ({
default = lib.id; imports = [ config.type ];
_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 = ''
Representations of the document in different formats Collection navigation menus
''; '';
type = with types; attrsOf (either str attrs); 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 in
{ {
options.menus = mkOption { content-types.named-link = { ... }: {
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";
@ -36,7 +29,7 @@ in
}; };
}; };
config.content-types.navigation = { name, 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";
@ -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. 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 = page: cfg.templates.html.nav { default.html = cfg.templates.html.nav config;
menu = config; inherit page;
};
}; };
}; };
}; };

View file

@ -5,26 +5,17 @@ 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
{ {
# TODO: enable i18n, e.g. via a nested attribute for language-specific content content-types.page = { name, config, ... }: {
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 {
@ -51,22 +42,19 @@ 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 = page.title; title.text = config.title;
meta.description = page.description; meta.description = config.description;
link.canonical = lib.head page.locations; link.canonical = lib.head config.locations;
}; };
body.content = [ body.content = [
(cfg.menus.main.outputs.html page) (cfg.menus.main.outputs.html config)
{ section.heading.content = page.title; } { section.heading.content = config.title; }
(cfg.templates.html.markdown { inherit (page) name body; }) (cfg.templates.html.markdown config.name config.body)
]; ];
}; };
}); };
};
} }