implement collections

This commit is contained in:
Valentin Gagarin 2024-11-13 15:24:41 +01:00
parent e807aec04b
commit f4b3a08a37
6 changed files with 286 additions and 65 deletions

View file

@ -1,8 +1,6 @@
{ config, ... }:
{ config, lib, ... }:
let
inherit (config) pages;
files = dir:
map (name: dir + /${name}) (with builtins; attrNames (readDir dir));
in
{
imports = [
@ -10,7 +8,9 @@ in
./fediversity.nix
]
++
(files ./partners)
lib.fileset.toList ./partners
++
lib.fileset.toList ./news
;
pages.index = {
@ -46,6 +46,12 @@ in
[Read more about ${partner.title}](./${partner})
'') (with pages; [ nlnet oid tweag nordunet ]))}
# News
${lib.concatStringsSep "\n" (map (article: ''
- ${article.date} [${article.title}](./${article})
'') config.collections.news.entry)}
'';
};
}

View file

@ -0,0 +1,19 @@
{ ... }:
{
collections.news.entry = {
title = "Fediversity website launch";
description = "Announcing our new website for the Fediversity project";
date = "2024-05-15";
author = "Laurens Hof";
locations = [
"website-launch.html"
];
body = ''
We are pleased to introduce the launch of our new website dedicated to the Fediversity project.
The project is broad in scope, and the website reflects this. Whether you are a developer, an individual interested in the project, or want to know how the grant money is spend, the website keeps you up to date with everything you need to know.
We're excited to show you more of the progress of the Fediversity project, and how we can build a next generation of the open internet together!
'';
};
}

View file

@ -6,16 +6,23 @@
overlays = [ ];
}
, lib ? import "${sources.nixpkgs}/lib"
,
}:
let
lib' = final: prev: import ./lib.nix { lib = final; };
lib'' = lib.extend lib';
in
{
build =
let
result = pkgs.lib.evalModules {
result = lib''.evalModules {
modules = [
./structure
./content
{ _module.args = { inherit pkgs; }; }
{
_module.args = {
inherit pkgs;
};
}
];
};
in

40
lib.nix Normal file
View file

@ -0,0 +1,40 @@
{ lib }:
rec {
/**
Create a URL-safe slug from any string
*/
slug = str:
let
# Replace non-alphanumeric characters with hyphens
replaced = join ""
(
builtins.map
(c:
if (c >= "a" && c <= "z") || (c >= "0" && c <= "9")
then c
else "-"
)
(with lib; stringToCharacters (toLower str)));
# Remove leading and trailing hyphens
trimHyphens = s:
let
matched = builtins.match "(-*)([^-].*[^-]|[^-])(-*)" s;
in
with lib; optionalString (!isNull matched) (builtins.elemAt matched 1);
collapseHyphens = s:
let
result = builtins.replaceStrings [ "--" ] [ "-" ] s;
in
if result == s then s else collapseHyphens result;
in
trimHyphens (collapseHyphens replaced);
join = lib.concatStringsSep;
splitLines = s: with builtins; filter (x: !isList x) (split "\n" s);
indent = prefix: s:
join "\n" (map (x: if x == "" then x else "${prefix}${x}") (splitLines s));
}

View file

@ -5,71 +5,130 @@ let
types
;
cfg = config;
types' = import ./types.nix { inherit lib; } // {
article = { config, collectionName, ... }: {
imports = [ types'.page ];
options = {
date = mkOption {
description = "Publication date";
type = with types; nullOr str;
default = null;
};
author = mkOption {
description = "Page author";
type = with types; nullOr (either str (listOf str));
default = null;
};
};
config.name = lib.slug config.title;
config.outPath = "${collectionName}/${lib.head config.locations}";
config.template = cfg.templates.article;
};
page = { name, config, ... }: {
options = {
name = mkOption {
description = "Symbolic name for the page, used as a human-readable identifier";
type = types.str;
default = name;
};
title = mkOption {
description = "Page title";
type = types.str;
default = name;
};
locations = mkOption {
description = ''
List of historic output locations for the resulting file
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
'';
type = with types; nonEmptyListOf str;
};
outPath = mkOption {
description = ''
Location of the page, used for transparently creating links
'';
type = types.str;
default = lib.head config.locations;
};
description = mkOption {
description = ''
One-sentence description of page contents
'';
type = types.str;
};
summary = mkOption {
description = ''
One-paragraph summary of page contents
'';
type = types.str;
};
body = mkOption {
description = ''
Page contents in CommonMark
'';
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;
};
};
};
};
in
{
# TODO: split out:
# - extra module system types into lib'
# - page and article types into their own module values under structure/${page,article}.nix
# yes, actually. those types should probably be configurable
config.collections.news.type = types'.article;
options.pages = mkOption {
description = ''
Collection of pages on the site
'';
type = with types; attrsOf (submodule ({ name, config, ... }:
{
options = {
title = mkOption {
type = types.str;
};
locations = mkOption {
description = ''
List of historic output locations for the resulting file
type = with types; attrsOf (submodule types'.page);
};
The first element is the canonical location.
All other elements are used to create redirects to the canonical location.
'';
type = with types; nonEmptyListOf str;
options.collections = mkOption
{
description = ''
Named collections of unnamed pages
'';
type = with types; attrsOf (submodule ({ name, config, ... }: {
options = {
type = mkOption {
description = "Type of entries in the collection";
type = types.deferredModule;
};
outPath = mkOption {
description = ''
Canonical location of the page
'';
type = types.str;
default = lib.head config.locations;
entry = mkOption {
description = "An entry in the collection";
type = types'.collection (types.submodule ({
_module.args.collection = config.entry;
_module.args.collectionName = name;
imports = [ config.type ];
}));
};
description = mkOption {
description = ''
One-sentence description of page contents
'';
type = types.str;
};
summary = mkOption {
description = ''
One-paragraph summary of page contents
'';
type = types.str;
};
body = mkOption {
description = ''
Page contents in CommonMark
'';
type = types.str;
};
template = mkOption
{
description = ''
Function that converts the page contents to files
'';
type = with types; functionTo (functionTo (functionTo options.files.type));
default = cfg.templates.default;
};
};
}));
};
};
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 (functionTo options.files.type)));
type = with types; attrsOf (functionTo (functionTo options.files.type));
};
config.templates.default =
config.templates =
let
commonmark = name: markdown: pkgs.runCommand "${name}.html"
{
@ -78,10 +137,10 @@ in
cmark ${builtins.toFile "${name}.md" markdown} > $out
'';
in
lib.mkDefault
(config: name: page: {
# TODO: create static redirects from the tail
${lib.head page.locations} = builtins.toFile "${name}.html" ''
{
page = 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" />
@ -90,14 +149,39 @@ in
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
<link rel="canonical" href="${lib.head page.locations}" />
<link rel="canonical" href="${page.outPath}" />
</head>
<body>
${builtins.readFile (commonmark name page.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 = ''
@ -108,9 +192,27 @@ in
'';
type = with types; attrsOf path;
};
config.files = lib.concatMapAttrs
(name: page: page.template config name page)
config.pages;
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 = ''
@ -121,7 +223,7 @@ in
let
script = ''
mkdir $out
'' + lib.concatStringsSep "\n" copy;
'' + lib.join "\n" copy;
copy = lib.mapAttrsToList
(
path: file: ''

47
structure/types.nix Normal file
View file

@ -0,0 +1,47 @@
{ lib, ... }:
let
inherit (lib) types;
in
rec {
collection = elemType:
let
unparenthesize = class: class == "noun";
desc = type:
types.optionDescriptionPhrase unparenthesize type;
desc' = type:
let
typeDesc = types.optionDescriptionPhrase unparenthesize type;
in
if type.descriptionClass == "noun"
then
typeDesc + "s"
else
"many instances of ${typeDesc}";
in
types.mkOptionType {
name = "collection";
description = "separately specified ${desc elemType} for a collection of ${desc' elemType}";
merge = loc: defs:
map
(def:
let
merged = lib.mergeDefinitions
(loc ++ [ "[definition ${toString def.file}]" ])
elemType
[{ inherit (def) file; value = def.value; }];
in
if merged ? mergedValue then merged.mergedValue else merged.value
)
defs;
check = elemType.check;
getSubOptions = elemType.getSubOptions;
getSubModules = elemType.getSubModules;
substSubModules = m: collection (elemType.substSubModules m);
functor = (lib.defaultFunctor "collection") // {
type = collection;
wrapped = elemType;
payload = { };
};
nestedTypes.elemType = elemType;
};
}