Compare commits

..

1 commit

Author SHA1 Message Date
Valentin Gagarin 9da5bdde9e WIP: implement the DOM 2024-11-03 19:55:19 +01:00
7 changed files with 151 additions and 372 deletions

View file

@ -6,7 +6,6 @@ in
menus.main = {
label = "Main";
items = [
{ page = pages.index // { title = "Start"; }; }
{
menu.label = "For you";
menu.items = map (page: { inherit page; })

38
lib.nix
View file

@ -1,19 +1,5 @@
{ lib }:
rec {
/**
Recursively replace occurrences of `from` with `to` within `string`
Example:
replaceStringRec "--" "-" "hello-----world"
=> "hello-world"
*/
replaceStringsRec = from: to: string:
let
replaced = lib.replaceStrings [ from ] [ to ] string;
in
if replaced == string then string else replaceStringsRec from to replaced;
/**
Create a URL-safe slug from any string
*/
@ -36,30 +22,14 @@ rec {
matched = builtins.match "(-*)([^-].*[^-]|[^-])(-*)" s;
in
with lib; optionalString (!isNull matched) (builtins.elemAt matched 1);
in
trimHyphens (replaceStringsRec "--" "-" replaced);
squash = replaceStringsRec "\n\n" "\n";
/**
Trim trailing spaces and squash non-leading spaces
*/
trim = string:
let
trimLine = line:
with lib;
collapseHyphens = s:
let
# separate leading spaces from the rest
parts = split "(^ *)" line;
spaces = head (elemAt parts 1);
rest = elemAt parts 2;
# drop trailing spaces
body = head (split " *$" rest);
result = builtins.replaceStrings [ "--" ] [ "-" ] s;
in
if body == "" then "" else
spaces + replaceStringsRec " " " " body;
if result == s then s else collapseHyphens result;
in
join "\n" (map trimLine (splitLines string));
trimHyphens (collapseHyphens replaced);
join = lib.concatStringsSep;

View file

@ -5,14 +5,6 @@ let
types
;
templates = import ./templates.nix { inherit lib; };
render-html = document:
let
eval = lib.evalModules {
class = "DOM";
modules = [ document (import ./dom.nix { inherit lib; }).document ];
};
in
toString eval.config;
in
{
options.templates =
@ -46,38 +38,30 @@ in
in
{
nav = lib.mkDefault templates.nav;
page = lib.mkDefault (config: page: render-html {
html = {
head = {
title.text = page.title;
meta.description = page.description;
link.canonical = lib.head page.locations;
};
body.content = ''
${config.menus.main.outputs.html page}
<h1>${page.title}</h1>
${builtins.readFile (commonmark page.name page.body)}
'';
};
page = lib.mkDefault (config: page: templates.html {
head = ''
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
<link rel="canonical" href="${lib.head page.locations}" />
'';
body = ''
${config.menus.main.outputs.html page}
${builtins.readFile (commonmark page.name page.body)}
'';
});
article = lib.mkDefault (config: page: render-html {
html = {
head = {
title.text = page.title;
meta.description = page.description;
meta.authors = if lib.isList page.author then page.author else [ page.author ];
link.canonical = lib.head page.locations;
};
body.content = ''
${config.menus.main.outputs.html page}
<h1>${page.title}</h1>
${builtins.readFile (commonmark page.name page.body)}
'';
};
article = lib.mkDefault (config: page: templates.html {
head = ''
<title>${page.title}</title>
<meta name="description" content="${page.description}" />
${with lib; join "\n" (map
(author: ''<meta name="author" content="${author}" />'')
(if isList page.author then page.author else [page.author]))
}
'';
body = ''
${config.menus.main.outputs.html page}
${builtins.readFile (commonmark page.name page.body)}
'';
});
};

View file

@ -7,8 +7,6 @@
*/
{ lib, ... }:
let
inherit (lib) mkOption types;
# https://html.spec.whatwg.org/multipage/dom.html#content-models
# https://html.spec.whatwg.org/multipage/dom.html#kinds-of-content
content-categories = [
@ -27,8 +25,6 @@ let
# base type for all DOM elements
element = { name, config, ... }: {
# TODO: add fields for upstream documentation references
# TODO: programmatically generate documentation
options = with lib; {
attrs = mkAttrs { };
categories = mkOption {
@ -43,7 +39,7 @@ let
# options with types for all the defined DOM elements
element-types = lib.mapAttrs
(name: value: mkOption { type = types.submodule value; })
(name: value: mkOption { type = types.submodule e; })
elements;
# attrset of categories, where values are module options with the type of the
@ -57,98 +53,57 @@ let
(filterAttrs (_: e: elem category (e { name = "dummy"; }).config.categories) elements))
);
global-attrs = lib.mapAttrs (name: value: mkOption value) {
class = {
type = with types; listOf nonEmptyStr;
default = [ ];
};
hidden = {
type = types.bool;
default = false;
};
id = {
type = with types; nullOr nonEmptyStr;
default = null;
};
lang = {
# TODO: https://www.rfc-editor.org/rfc/rfc5646.html
type = with types; nullOr str;
default = null;
};
style = {
# TODO: CSS type ;..)
type = with types; nullOr str;
default = null;
};
title = {
type = with types; nullOr lines;
default = null;
};
# TODO: more global attributes
# https://html.spec.whatwg.org/#global-attributes
# https://html.spec.whatwg.org/#attr-aria-*
# https://html.spec.whatwg.org/multipage/microdata.html#encoding-microdata
};
attrs = lib.mapAttrs (name: value: mkOption value) {
href = {
# TODO: https://url.spec.whatwg.org/#valid-url-string
# ;..O
type = types.str;
};
target = {
# https://html.spec.whatwg.org/multipage/document-sequences.html#valid-navigable-target-name-or-keyword
type =
let
is-valid-target = s:
let
inherit (lib) match;
has-lt = s: match ".*<.*" s != null;
has-tab-or-newline = s: match ".*[\t\n].*" s != null;
has-valid-start = s: match "^[^_].*$" s != null;
in
has-valid-start s && !(has-lt s && has-tab-or-newline s);
in
with types; either
(enum [ "_blank" "_self" "_parent" "_top" ])
(types.addCheck str is-valid-target)
;
};
};
mkAttrs = attrs: with lib;
mkOption {
type = types.submodule {
options = global-attrs // attrs;
};
default = { };
};
print-attrs = with lib; attrs:
# TODO: figure out how let attributes know how to print themselves without polluting the interface
global-attrs =
let
result = trim (join " "
(mapAttrsToList
# TODO: this needs to be smarter for boolean attributes
# where the value must be written out explicitly.
# probably the attribute itself should have its own `__toString`.
(name: value:
if isBool value then
if value then name else ""
# TODO: some attributes must be explicitly empty
else optionalString (toString value != "") ''${name}=${value}''
)
attrs)
);
inherit (lib) mkOption types;
in
optionalString (stringLength result > 0) " " + result
;
{
class = mkOption {
type = with types; listOf nonEmptyStr;
};
hidden = mkOption {
type = types.bool;
};
id = mkOption {
type = types.nonEmptyStr;
};
lang = mkOption {
# TODO: https://www.rfc-editor.org/rfc/rfc5646.html
type = types.str;
};
style = mkOption {
# TODO: CSS type ;..)
type = types.str;
};
title = mkOption {
type = types.lines;
};
# TODO: more global attributes
# https://html.spec.whatwg.org/#global-attributes
# https://html.spec.whatwg.org/#attr-aria-*
# https://html.spec.whatwg.org/multipage/microdata.html#encoding-microdata
};
mkAttrs = attrs: with lib; mkOption {
type = types.submodule {
options = global-attrs // attrs;
};
default = { };
};
print-element = name: attrs: content:
with lib;
let
attributes = join " " (mapAttrsToList
# TODO: this needs to be smarter for boolean attributes
# where the value must be written out explicitly.
# probably the attribute itself should have its own `__toString`.
(name: value: if isBool value then name else "${name}=${value}")
attrs);
in
lib.squash ''
<${name}${print-attrs attrs}>
${lib.indent " " content}
<${name}${optionalString (stringLength attributes > 0) " ${attributes}"}>
${lib.indent " " content}
</${name}>
'';
@ -161,7 +116,7 @@ let
config.categories = [ ];
config.__toString = self: ''
<!DOCTYPE HTML >
<!DOCTYPE HTML>
${self.html}
'';
};
@ -191,175 +146,32 @@ let
type = with types; nullOr (submodule base);
default = null;
};
# https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-charset
meta.charset = mkOption {
# TODO: create programmatically from https://encoding.spec.whatwg.org/encodings.json
type = types.enum [
"utf-8"
];
default = "utf-8";
};
# 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 = types.submodule ({ ... }: {
# TODO: figure out how to render only non-default values
options = {
width = mkOption {
type = with types; either
(ints.between 1 10000)
(enum [ "device-width" ]);
default = "device-width"; # not default by standard
};
height = mkOption {
type = with types; either
(ints.between 1 10000)
(enum [ "device-height" ]);
default = "device-height"; # not default by standard (but seems to work if you don't set it)
};
initial-scale = mkOption {
type = types.numbers.between 0.1 10;
default = 1;
};
minimum-scale = mkOption {
type = types.numbers.between 0.1 10;
# TODO: render only as many digits as needed
default = 0.1;
};
maximum-scale = mkOption {
type = types.numbers.between 0.1 10;
default = 10;
};
user-scalable = mkOption {
type = types.bool;
default = true;
};
interactive-widget = mkOption {
type = types.enum [
"resizes-visual"
"resizes-content"
"overlays-content"
];
default = "resizes-visual";
};
};
});
default = { };
};
meta.authors = mkOption {
type = with types; listOf str;
default = [ ];
};
meta.description = mkOption {
type = with types; nullOr str;
default = null;
};
link.canonical = mkOption {
type = with types; nullOr str;
default = null;
};
# TODO: figure out `meta` elements
# https://html.spec.whatwg.org/multipage/semantics.html#the-meta-element:concept-element-attributes
# https://html.spec.whatwg.org/multipage/semantics.html#other-metadata-names
content = with types;
listOf (attrTag (
removeAttrs categories.metadata [ "title" "base" ]
));
};
config.categories = [ ];
config.__toString = self:
with lib;
print-element name self.attrs ''
${self.title}
${with lib; optionalString (!isNull self.base) self.base}
<meta charset="${self.meta.charset}" />
${/* https://html.spec.whatwg.org/multipage/semantics.html#attr-meta-http-equiv-x-ua-compatible */
""}<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="${join ", " (mapAttrsToList
(name: value: "${name}=${toString value}") self.meta.viewport)
}" />
${join "\n" (map
(author: ''<meta name="author" content="${author}" />'')
self.meta.authors)
}
'';
config.__toString = self: print-element name self.attrs ''
${self.title}
${with lib; optionalString (!isNull self.base) self.base}
${join "\n" (map (s: "${s}") self.content)}
'';
};
title = { name, ... }: {
imports = [ element ];
options.text = mkOption {
type = types.str;
};
# TODO
config.categories = [ "metadata" ];
config.__toString = self: "<${name}${print-attrs self.attrs}>${self.text}</${name}>";
};
base = { name, ... }: {
imports = [ element ];
# TODO: "A base element must have either an href attribute, a target attribute, or both."
attrs = mkAttrs { inherit (attrs) href target; };
# TODO
config.categories = [ "metadata" ];
};
link = { name, ... }: {
imports = [ element ];
options = mkAttrs
{
# TODO: more attributes
# https://html.spec.whatwg.org/multipage/semantics.html#the-link-element:concept-element-attributes
inherit (attrs) href;
} // {
# XXX: there are variants of `rel` for `link`, `a`/`area`, and `form`
rel = mkOption {
# https://html.spec.whatwg.org/multipage/semantics.html#attr-link-rel
type = with types; listOfUnique str (enum
# TODO: work out link types in detail, there are lots of additional constraints
# https://html.spec.whatwg.org/multipage/links.html#linkTypes
[
"alternate"
"dns-prefetch"
"expect"
"help"
"icon"
"license"
"manifest"
"modulepreload"
"next"
"pingback"
"preconnect"
"prefetch"
"preload"
"prev"
"privacy-policy"
"search"
"stylesheet"
"terms-of-service"
]
);
};
};
# TODO: figure out how to make body-ok `link` elements
# https://html.spec.whatwg.org/multipage/semantics.html#allowed-in-the-body
config.categories = [ "metadata" ];
config.__toString = self: "<name${print-attrs self.attrs} />";
};
body = { name, ... }: {
imports = [ element ];
options = {
content = mkOption {
type = with types;
# HACK: bail out for now
# TODO: find a reasonable cut-off for where to place raw content
either str (listOf (attrTag categories.flow));
};
};
config.categories = [ ];
config.__toString = self: with lib;
if isList self.content then join "\n" (toString self.content) else self.content;
# TODO
};
};
in

View file

@ -1,5 +1,19 @@
{ 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: page:
let
render-item = item:

View file

@ -22,60 +22,62 @@ in
type = with types; attrsOf (submodule config.content-types.page);
};
options.collections = mkOption {
description = ''
Named collections of unnamed pages
options.collections = mkOption
{
description = ''
Named collections of unnamed pages
Define the content type of a new collection `example` to be `article`:
Define the content type of a new collection `example` to be `article`:
```nix
config.collections.example.type = config.types.article;
```
```nix
config.collections.example.type = config.types.article;
```
Add a new entry to the `example` collection:
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;
```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;
}));
};
};
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;
}));
};
};
}));
};
}));
};
options.menus = mkOption {
description = ''

View file

@ -46,8 +46,6 @@ in
default = target: with lib; "${relativePath (head config.locations) (head target.locations)}.html";
};
outputs = mkOption {
# TODO: figure out how to make this overridable at any granularity.
# it should be possible with the DOM module now.
description = ''
Representations of the document in different formats
'';