diff --git a/website/content/default.nix b/website/content/default.nix index 9687167..6f1a0e2 100644 --- a/website/content/default.nix +++ b/website/content/default.nix @@ -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)} ''; }; } diff --git a/website/content/news/website-launch.nix b/website/content/news/website-launch.nix new file mode 100644 index 0000000..ae79c07 --- /dev/null +++ b/website/content/news/website-launch.nix @@ -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! + ''; + }; +} diff --git a/website/default.nix b/website/default.nix index 96b2a4b..be41a93 100644 --- a/website/default.nix +++ b/website/default.nix @@ -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 diff --git a/website/lib.nix b/website/lib.nix new file mode 100644 index 0000000..36ad68f --- /dev/null +++ b/website/lib.nix @@ -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)); +} diff --git a/website/structure/default.nix b/website/structure/default.nix index dc7780c..4e9cdf0 100644 --- a/website/structure/default.nix +++ b/website/structure/default.nix @@ -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" '' @@ -90,14 +149,39 @@ in ${page.title} - + - ${builtins.readFile (commonmark name page.body)} + ${lib.indent " " (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" '' + + + + + + + ${page.title} + + ${with lib; + if ! isNull page.author then + '''' + else "" + } + + + + ${lib.indent " " (builtins.readFile (commonmark page.name page.body))} + + + ''; + }); + }; 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: '' diff --git a/website/structure/types.nix b/website/structure/types.nix new file mode 100644 index 0000000..8a27cc5 --- /dev/null +++ b/website/structure/types.nix @@ -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; + }; +}