ensure the section hierarchy is spec-compliant

- automatically assign heading levels
- check that the maximum nesting depth is not exceeded
This commit is contained in:
Valentin Gagarin 2024-11-13 15:24:41 +01:00 committed by Valentin Gagarin
parent 4aeb9579d6
commit 661158223a
2 changed files with 105 additions and 26 deletions

View file

@ -117,4 +117,45 @@ in
in in
pkgs.runCommand "source" { } script; pkgs.runCommand "source" { } script;
}; };
# TODO: this is an artefact of exploration; needs to be adapted to actual use
config.templates.table-of-contents = { config, ... }:
let
outline = { ... }: {
options = {
value = mkOption {
# null denotes root
type = with types; nullOr (either str (listOf (attrTag categories.phrasing)));
subsections = mkOption {
type = with types; listOf (submodule outline);
default = with lib; map
# TODO: go into depth manually here,
# we don't want to pollute the DOM implementation
(c: (lib.head (attrValues c)).outline)
(filter (c: isAttrs c && (lib.head (attrValues c)) ? outline) config.content);
};
};
__toString = mkOption {
type = with types; functionTo str;
# TODO: convert to HTML
default = self: lib.squash ''
${if isNull self.value then "root" else self.value}
${if self.subsections != [] then
" " + lib.indent " " (lib.join "\n" self.subsections) else ""}
'';
};
};
};
in
{
options.outline = mkOption {
type = types.submodule outline;
default = {
value = null;
subsections = with lib;
map (c: (lib.head (attrValues c)).outline)
(filter (c: isAttrs c && (lib.head (attrValues c)) ? outline) config.content);
};
};
};
} }

View file

@ -26,14 +26,6 @@ let
"scripting" # https://html.spec.whatwg.org/multipage/dom.html#script-supporting-elements "scripting" # https://html.spec.whatwg.org/multipage/dom.html#script-supporting-elements
]; ];
get-section-depth = content: with lib; foldl'
(acc: elem:
if isAttrs elem
then max acc (lib.head (attrValues elem)).section-depth
else acc # TODO: parse with e.g. https://github.com/remarkjs/remark to avoid raw strings
) 0
content;
# base type for all DOM elements # base type for all DOM elements
element = { ... }: { element = { ... }: {
# TODO: add fields for upstream documentation references # TODO: add fields for upstream documentation references
@ -49,15 +41,6 @@ let
}; };
}; };
# TODO: rename to something about sectioning
content-element = { ... }: {
options.section-depth = mkOption {
type = types.ints.unsigned;
default = 0;
internal = true;
};
};
# 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 = types.submodule value; }) (name: value: mkOption { type = types.submodule value; })
@ -369,18 +352,74 @@ let
}; };
body = { config, name, ... }: { body = { config, name, ... }: {
imports = [ element content-element ]; imports = [ element ];
options = { options = {
attrs = mkAttrs { }; attrs = mkAttrs { };
content = mkOption { content = mkOption {
type = with types; type = with types;
let
# Type check that ensures spec-compliant section hierarchy
# https://html.spec.whatwg.org/multipage/sections.html#headings-and-outlines-2:concept-heading-7
with-section-constraints = baseType: baseType // {
merge = loc: defs:
with lib;
let
find-and-attach = def:
let
process-with-depth = depth: content:
map
(x:
if isAttrs x && x ? section && x.section ? heading
then x // {
section = x.section // {
heading-level = depth;
content = process-with-depth (depth + 1) (x.section.content or [ ]);
};
}
else x
)
content;
find-with-depth = depth: content:
let
sections = map (v: { inherit (def) file; value = v; depth = depth; })
(filter (x: isAttrs x && x ? section && x.section ? heading) content);
subsections = concatMap
(x:
if isAttrs x && x ? section && x.section ? content
then find-with-depth (depth + 1) x.section.content
else [ ])
content;
in
sections ++ subsections;
in
{
inherit def;
processed = process-with-depth 1 def.value;
validation = find-with-depth 1 def.value;
};
processed = map find-and-attach defs;
all-sections = flatten (map (p: p.validation) processed);
too-deep = filter (sec: sec.depth > 6) all-sections;
in
if too-deep != [ ] then
throw ''
The option `${lib.options.showOption loc}` has sections nested too deeply:
${concatMapStrings (sec: " - depth ${toString sec.depth} section in ${toString sec.file}\n") too-deep}
Section hierarchy must not be deeper than 6 levels.''
else baseType.merge loc (map (p: p.def // { value = p.processed; }) processed);
};
in
# HACK: bail out for now # HACK: bail out for now
with-section-constraints
# TODO: find a reasonable cut-off for where to place raw content # TODO: find a reasonable cut-off for where to place raw content
listOf (either str (attrTag categories.flow)); (listOf (either str (attrTag categories.flow)));
default = [ ]; default = [ ];
}; };
}; };
config.section-depth = get-section-depth config.content;
config.categories = [ ]; config.categories = [ ];
config.__toString = self: with lib; config.__toString = self: with lib;
print-element name self.attrs ( print-element name self.attrs (
@ -393,8 +432,9 @@ let
self.content) self.content)
); );
}; };
section = { config, name, ... }: { section = { config, name, ... }: {
imports = [ element content-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 {
@ -413,6 +453,7 @@ let
options = { options = {
attrs = mkAttrs { }; attrs = mkAttrs { };
content = mkOption { content = mkOption {
# https://html.spec.whatwg.org/multipage/sections.html#the-h1,-h2,-h3,-h4,-h5,-and-h6-elements
type = with types; either str (listOf (attrTag categories.phrasing)); type = with types; either str (listOf (attrTag categories.phrasing));
}; };
}; };
@ -429,14 +470,12 @@ let
default = [ ]; default = [ ];
}; };
}; };
config.section-depth = get-section-depth config.content + 1;
options.heading-level = mkOption { options.heading-level = mkOption {
# XXX: this will proudly fail if the invariant is violated, # XXX: this will proudly fail if the invariant is violated,
# but the error message will be inscrutable # but the error message will be inscrutable
type = with types; ints.between 1 6; type = with types; ints.between 1 6;
internal = true; internal = true;
}; };
config.heading-level = cfg.section-depth - config.section-depth + 1;
config.categories = [ "flow" "sectioning" "palpable" ]; config.categories = [ "flow" "sectioning" "palpable" ];
config.__toString = self: with lib; config.__toString = self: with lib;
let let
@ -457,12 +496,11 @@ let
}; };
in in
{ {
imports = [ element content-element ]; imports = [ element ];
options = { options = {
inherit (element-types) html; inherit (element-types) html;
}; };
config.section-depth = config.html.body.section-depth;
config.categories = [ ]; config.categories = [ ];
config.__toString = self: '' config.__toString = self: ''
<!DOCTYPE HTML > <!DOCTYPE HTML >