Added @author.

Added @author.

jsoniq version "1.0";

(:
 : Copyright 2006-2011 The FLWOR Foundation.
 :
 : Licensed under the Apache License, Version 2.0 (the "License");
 : you may not use this file except in compliance with the License.
 : You may obtain a copy of the License at
 :
 : http://www.apache.org/licenses/LICENSE-2.0
 :
 : Unless required by applicable law or agreed to in writing, software
 : distributed under the License is distributed on an "AS IS" BASIS,
 : WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 : See the License for the specific language governing permissions and
 : limitations under the License.
 :)

(:===========================================================================:)

(:~
 : This module provides an API for parsing and serializing CSV (comma-separated
 : values) files.
 : See RFC 4180,
 : "Common Format and MIME Type for Comma-Separated Values (CSV) Files."
 :
 : @author Paul J. Lucas
 : @project Zorba/CSV
 :)

module namespace csv = "http://zorba.io/modules/json-csv";

declare namespace err = "http://www.w3.org/2005/xqt-errors";
declare namespace zerr = "http://zorba.io/errors";

declare namespace ver = "http://zorba.io/options/versioning";
declare option ver:module-version "1.0";

(:===========================================================================:)

(:~
 : Parses a CSV (comma-separated values) string using the given options.
 : A newline (U+000A), optionally preceeded by a carriage-return (U+000D),
 : terminates lines, aka, "records."
 :

 : Quoted values are always considered strings;
 : unquoted values are attempted to be cast to other types, e.g., integer
 : (unless the cast-unquoted-values option is false).
 : Casting is attempted in the following order:
 : integer, decimal, double, and boolean.
 : If casting fails, the value is considered a string.
 : Header field names are always considered strings even if unquoted.
 :

 : In addition to the "normal" values of
 : true and false for boolean,
 : T and Y are also considered "true"
 : and F and N are also considered "false."
 :
 : @param $csv The CSV string to parse.
 : @param $options The options to use:
 :

 :
cast-unquoted-values

 :

 :        Whether to attempt to cast unquoted values to
 :        integer, decimal, double, or boolean;
 :        default: true.
 :

 :
extra-name

 :

 :        The field name for extra values, if any;
 :        default: none (error zerr:ZCSV0003 is raised).
 :        If this option is given and a line contains one or more extra values
 :        (that is, values that have no corresponding field names),
 :        then the extra values are assigned as the values
 :        for fields having extra-name as their names.
 :

 :        If extra-name contains a # (U+0023),
 :        then the # is substituted with the field number
 :        (where field numbers start at 1).
 :        If extra-name does not contains a #,
 :        then the field number is appended.
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        If this option is given, then the first CSV line is assumed not to be
 :        a header line; if omitted, then the first CSV line is assumed to be a
 :        header line and the field names are taken from this line.
 :

 :
missing-value

 :

 :        What should happen when a missing value is detected;
 :        default: "null".
 :        A "missing" value is one of:
 :

 :
Two consecutive separator characters.

 :
A separator character as either the first
 :              or last character on a line.

 :
Fewer values than the number of field names.

 :

 :        When a missing value is detected,
 :        the value of this option determines what happens:
 :

 :
"error"

 :
Error zerr:ZCSV0002 is raised.

 :
"omit"

 :
Both the value and its key are omitted from the result
 :                object.

 :
"null"

 :
The value is set to null.

 :

 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        If quote-escape equals quote-char,
 :        it means that quote-char must be doubled to escape it.
 :        If quote-escape does not equal quote-char,
 :        it means that quote-escape is used to escape
 :        quote-char.
 :        For example, a quote-char of " (U+0022)
 :        and a quote-escape of \ (U+005C) means that
 :        quotes will be escaped by \".
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 : @return a sequence of zero or more JSON objects where each key is a field
 : name and each value is a parsed value.
 : @error zerr:ZCSV0001 if the quote-char,
 : quote-escape, or separator option is given
 : and it's not a single ASCII character.
 : @error zerr:ZCSV0002 if a missing value is detected and the
 : missing-value option is "error".
 : @error zerr:ZCSV0003 if an extra value is detected and the
 : extra-name option is not set.
 :)
declare function csv:parse( $csv as string, $options as object() )
  as object()* external;

(:~
 : Parses a CSV (comma-separated values) string using the default options.
 : A newline (U+000A), optionally preceeded by a carriage-return (U+000D),
 : terminates lines, aka, "records."
 :

 : Quoted values are always considered strings;
 : unquoted values are attempted to be cast to other types, e.g., integer
 : (unless the cast-unquoted-values option is false).
 : Casting is attempted in the following order:
 : integer, decimal, double, and boolean.
 : If casting fails, the value is considered a string.
 : Header field names are always considered strings even if unquoted.
 :

 : In addition to the "normal" values of
 : true and false for boolean,
 : T and Y are also considered "true"
 : and F and N are also considered "false."
 :

 : The default options are:
 :

 :
cast-unquoted-values

 :

 :        Whether to attempt to cast unquoted values to
 :        integer, decimal, double, or boolean;
 :        default: true.
 :

 :
extra-name

 :

 :        The field name for extra values, if any;
 :        default: none (error zerr:ZCSV0003 is raised).
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        The first CSV line is assumed to be a header line
 :        and the field names are taken from this line.
 :

 :
missing-value

 :

 :        What should happen when a missing value is detected;
 :        default: "null".
 :        A "missing" value is one of:
 :

 :
Two consecutive quote-char characters.

 :
A quote-char character as either the first
 :              or last character on a line.

 :
Fewer values than the number of field names.

 :

 :        When a missing value is detected,
 :        the value is set to null.
 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        This means that an escaped quote is doubled as "".
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 :
 : @param $csv The CSV string to parse.
 : @return a sequence of zero or more JSON objects where each key is a field
 : name and each value is a parsed value.
 : @error zerr:ZCSV0003 if an extra value is detected.
 :)
declare function csv:parse( $csv as string )
  as object()*
{
  csv:parse( $csv, {} )
};

(:~
 : Serializes a sequence of JSON objects as CSV (comma-separated values) using
 : the given options.
 :
 : @param $obj The sequence of JSON objects to serialize.
 : @param $options The options to use:
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        If this option is not set,
 :        the field names are taken from the first JSON object
 :        and the order of the fields is implementation dependent.
 :        If this option is set,
 :        the fields are serielized in the order they are in the array.
 :        In either case,
 :        every JSON object must have the same keys as the first object.
 :

 :
serialize-boolean-as

 :

 :        What strings to serialize true and false as;
 :        default: true and false.
 :        This must be a sub-object with the two keys
 :        "true" and "false", e.g.:
 :        { "true" : "Y", "false" : "N" }.
 :

 :
serialize-header

 :

 :        Whether a header line is included;
 :        default: true.
 :        If true,
 :        the first string result is the header line
 :        comprised of all the objects' keys' names;
 :        if false,
 :        the heder line is not returned.
 :

 :
serialize-null-as

 :

 :        What string to serialize JSON null values as;
 :        default: null.
 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        If quote-escape equals quote-char,
 :        it means that quote-char must be doubled to escape it.
 :        If quote-escape does not equal quote-char,
 :        it means that quote-escape is used to escape
 :        quote-char.
 :        For example, a quote-char of " (U+0022)
 :        and a quote-escape of \ (U+005C) means that
 :        quotes will be escaped by \".
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 : @return a sequence of strings where each string corresponds to a JSON
 : object, aka, "record."
 :)
declare function csv:serialize( $obj as object()*, $options as object() )
  as string* external;

(:~
 : Serializes a sequence of JSON objects as CSV (comma-separated values) using
 : the default options.
 : The default options are:
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        The field names are taken from the first JSON object
 :        and the order of the fields is implementation dependent.
 :

 :
serialize-boolean-as

 :

 :        What strings to serialize true and false as;
 :        default: true and false.
 :

 :
serialize-header

 :

 :        Whether a header line is included;
 :        default: true.
 :        The first string result is the header line
 :        comprised of all the objects' keys' names.
 :

 :
serialize-null-as

 :

 :        What string to serialize JSON null values as;
 :        default: null.
 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        This means that quote-char is doubled to escape it.
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 :
 : @param $obj The sequence of JSON objects to serialize.
 : @return a sequence of strings where each string corresponds to a JSON
 : object, aka, "record."
 :)
declare function csv:serialize( $obj as object()* )
  as string*
{
  csv:serialize( $obj, {} )
};

(:===========================================================================:)

(: vim:set syntax=xquery et sw=2 ts=2: :)

jsoniq version "1.0";

(:
 : Copyright 2006-2011 The FLWOR Foundation.
 :
 : Licensed under the Apache License, Version 2.0 (the "License");
 : you may not use this file except in compliance with the License.
 : You may obtain a copy of the License at
 :
 : http://www.apache.org/licenses/LICENSE-2.0
 :
 : Unless required by applicable law or agreed to in writing, software
 : distributed under the License is distributed on an "AS IS" BASIS,
 : WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 : See the License for the specific language governing permissions and
 : limitations under the License.
 :)

(:===========================================================================:)

(:~
 : This module provides an API for parsing and serializing CSV (comma-separated
 : values) files.
 : See RFC 4180,
 : "Common Format and MIME Type for Comma-Separated Values (CSV) Files."
 : @project Zorba/CSV
 :)

module namespace csv = "http://zorba.io/modules/json-csv";

declare namespace err = "http://www.w3.org/2005/xqt-errors";
declare namespace zerr = "http://zorba.io/errors";

declare namespace ver = "http://zorba.io/options/versioning";
declare option ver:module-version "1.0";

(:===========================================================================:)

(:~
 : Parses a CSV (comma-separated values) string using the given options.
 : A newline (U+000A), optionally preceeded by a carriage-return (U+000D),
 : terminates lines, aka, "records."
 :

 : Quoted values are always considered strings;
 : unquoted values are attempted to be cast to other types, e.g., integer
 : (unless the cast-unquoted-values option is false).
 : Casting is attempted in the following order:
 : integer, decimal, double, and boolean.
 : If casting fails, the value is considered a string.
 : Header field names are always considered strings even if unquoted.
 :

 : In addition to the "normal" values of
 : true and false for boolean,
 : T and Y are also considered "true"
 : and F and N are also considered "false."
 :
 : @param $csv The CSV string to parse.
 : @param $options The options to use:
 :

 :
cast-unquoted-values

 :

 :        Whether to attempt to cast unquoted values to
 :        integer, decimal, double, or boolean;
 :        default: true.
 :

 :
extra-name

 :

 :        The field name for extra values, if any;
 :        default: none (error zerr:ZCSV0003 is raised).
 :        If this option is given and a line contains one or more extra values
 :        (that is, values that have no corresponding field names),
 :        then the extra values are assigned as the values
 :        for fields having extra-name as their names.
 :

 :        If extra-name contains a # (U+0023),
 :        then the # is substituted with the field number
 :        (where field numbers start at 1).
 :        If extra-name does not contains a #,
 :        then the field number is appended.
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        If this option is given, then the first CSV line is assumed not to be
 :        a header line; if omitted, then the first CSV line is assumed to be a
 :        header line and the field names are taken from this line.
 :

 :
missing-value

 :

 :        What should happen when a missing value is detected;
 :        default: "null".
 :        A "missing" value is one of:
 :

 :
Two consecutive separator characters.

 :
A separator character as either the first
 :              or last character on a line.

 :
Fewer values than the number of field names.

 :

 :        When a missing value is detected,
 :        the value of this option determines what happens:
 :

 :
"error"

 :
Error zerr:ZCSV0002 is raised.

 :
"omit"

 :
Both the value and its key are omitted from the result
 :                object.

 :
"null"

 :
The value is set to null.

 :

 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        If quote-escape equals quote-char,
 :        it means that quote-char must be doubled to escape it.
 :        If quote-escape does not equal quote-char,
 :        it means that quote-escape is used to escape
 :        quote-char.
 :        For example, a quote-char of " (U+0022)
 :        and a quote-escape of \ (U+005C) means that
 :        quotes will be escaped by \".
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 : @return a sequence of zero or more JSON objects where each key is a field
 : name and each value is a parsed value.
 : @error zerr:ZCSV0001 if the quote-char,
 : quote-escape, or separator option is given
 : and it's not a single ASCII character.
 : @error zerr:ZCSV0002 if a missing value is detected and the
 : missing-value option is "error".
 : @error zerr:ZCSV0003 if an extra value is detected and the
 : extra-name option is not set.
 :)
declare function csv:parse( $csv as string, $options as object() )
  as object()* external;

(:~
 : Parses a CSV (comma-separated values) string using the default options.
 : A newline (U+000A), optionally preceeded by a carriage-return (U+000D),
 : terminates lines, aka, "records."
 :

 : Quoted values are always considered strings;
 : unquoted values are attempted to be cast to other types, e.g., integer
 : (unless the cast-unquoted-values option is false).
 : Casting is attempted in the following order:
 : integer, decimal, double, and boolean.
 : If casting fails, the value is considered a string.
 : Header field names are always considered strings even if unquoted.
 :

 : In addition to the "normal" values of
 : true and false for boolean,
 : T and Y are also considered "true"
 : and F and N are also considered "false."
 :

 : The default options are:
 :

 :
cast-unquoted-values

 :

 :        Whether to attempt to cast unquoted values to
 :        integer, decimal, double, or boolean;
 :        default: true.
 :

 :
extra-name

 :

 :        The field name for extra values, if any;
 :        default: none (error zerr:ZCSV0003 is raised).
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        The first CSV line is assumed to be a header line
 :        and the field names are taken from this line.
 :

 :
missing-value

 :

 :        What should happen when a missing value is detected;
 :        default: "null".
 :        A "missing" value is one of:
 :

 :
Two consecutive quote-char characters.

 :
A quote-char character as either the first
 :              or last character on a line.

 :
Fewer values than the number of field names.

 :

 :        When a missing value is detected,
 :        the value is set to null.
 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        This means that an escaped quote is doubled as "".
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 :
 : @param $csv The CSV string to parse.
 : @return a sequence of zero or more JSON objects where each key is a field
 : name and each value is a parsed value.
 : @error zerr:ZCSV0003 if an extra value is detected.
 :)
declare function csv:parse( $csv as string )
  as object()*
{
  csv:parse( $csv, {} )
};

(:~
 : Serializes a sequence of JSON objects as CSV (comma-separated values) using
 : the given options.
 :
 : @param $obj The sequence of JSON objects to serialize.
 : @param $options The options to use:
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        If this option is not set,
 :        the field names are taken from the first JSON object
 :        and the order of the fields is implementation dependent.
 :        If this option is set,
 :        the fields are serielized in the order they are in the array.
 :        In either case,
 :        every JSON object must have the same keys as the first object.
 :

 :
serialize-boolean-as

 :

 :        What strings to serialize true and false as;
 :        default: true and false.
 :        This must be a sub-object with the two keys
 :        "true" and "false", e.g.:
 :        { "true" : "Y", "false" : "N" }.
 :

 :
serialize-header

 :

 :        Whether a header line is included;
 :        default: true.
 :        If true,
 :        the first string result is the header line
 :        comprised of all the objects' keys' names;
 :        if false,
 :        the heder line is not returned.
 :

 :
serialize-null-as

 :

 :        What string to serialize JSON null values as;
 :        default: null.
 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        If quote-escape equals quote-char,
 :        it means that quote-char must be doubled to escape it.
 :        If quote-escape does not equal quote-char,
 :        it means that quote-escape is used to escape
 :        quote-char.
 :        For example, a quote-char of " (U+0022)
 :        and a quote-escape of \ (U+005C) means that
 :        quotes will be escaped by \".
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 : @return a sequence of strings where each string corresponds to a JSON
 : object, aka, "record."
 :)
declare function csv:serialize( $obj as object()*, $options as object() )
  as string* external;

(:~
 : Serializes a sequence of JSON objects as CSV (comma-separated values) using
 : the default options.
 : The default options are:
 :

 :
field-names

 :

 :        A JSON array of strings denoting field names;
 :        default: none.
 :        The field names are taken from the first JSON object
 :        and the order of the fields is implementation dependent.
 :

 :
serialize-boolean-as

 :

 :        What strings to serialize true and false as;
 :        default: true and false.
 :

 :
serialize-header

 :

 :        Whether a header line is included;
 :        default: true.
 :        The first string result is the header line
 :        comprised of all the objects' keys' names.
 :

 :
serialize-null-as

 :

 :        What string to serialize JSON null values as;
 :        default: null.
 :

 :
quote-char

 :

 :        The single ASCII character that may be used to quote values;
 :        default: " (U+0022).
 :

 :
quote-escape

 :

 :        The single ASCII character used to escape quote-char;
 :        default: same as quote-char.
 :        This means that quote-char is doubled to escape it.
 :

 :
separator

 :

 :        The single ASCII character used to separate values;
 :        default: , (U+002C).
 :

 :

 :
 : @param $obj The sequence of JSON objects to serialize.
 : @return a sequence of strings where each string corresponds to a JSON
 : object, aka, "record."
 :)
declare function csv:serialize( $obj as object()* )
  as string*
{
  csv:serialize( $obj, {} )
};

(:===========================================================================:)

(: vim:set syntax=xquery et sw=2 ts=2: :)


Fix issue when there is no published page.

    Generating Makefile "build/cache/dynamic.mk"...
    jq: error (at <stdin>:107): null (null) has no keys

#!/usr/bin/env jq
# This is a collection of filters for `jq`, intended to manipulate JSON objects
# into JSON representing a website index, for further processing in a template.

# Convert "truthy" value to actual boolean.
def bool:
    [. == (null,false,0,{},[])] | any | not
;

# Generate this object and all its children.
def all_children:
    ., recurse(.contents[]?)
;

# To remove an object, we first mark it for removal, then actually remove it
# later. It would be nicer if we could just do `all_children ... |= empty`,
# which works in some cases but not all. I suppose it has to do with changing
# objects as we are iterating over them.
def mark:
    .remove = true
;

# Remove all objects marked for removal.
def remove_marked:
    if .remove == true
    then empty
    else (.contents // empty) |= map(remove_marked)
    end
;

# Wrap an object in other objects so that the original object exists at a
# particular "path". Like `{} | setpath(["a","b"], …)`, but instead of making
# objects like `{"a":{"b":…}}`, this makes objects like `{"name":"a",
# "contents":[{"name":"b",…}]}`.
def tree($path):
    if ($path | length) <= 1
    then .name = $path[0]
    else { "name": $path[0], "contents": [ tree($path[1:]) ] }
    end
;

# Merge an array of page objects into a single page object.
def merge:
    map(to_entries) | add | group_by(.key) | map(
        { "key": (.[0].key)
        , "value": (
            if .[0].key == "contents"
            then [ .[].value ] | add | group_by(.name) | map(merge)
            elif [ .[].value == .[0].value ] | all
            then .[0].value
            else error("can't merge incompatible values")
            end
        ) }
    ) | from_entries
;

# Add paths to each object, that is, the names of the ancestors.
def directory:
    def f($d): .directory = $d | .name as $n | .contents[]? |= f($d+[$n]);
    f([])
;

# Every leaf node gets a basename, eg name without extension.
def basename:
    (all_children | select(has("contents") | not)) |= (
        .basename = (.name | sub(".([A-z]*)$";""))
    )
;

# Any Markdown page gets a link to its HTML page.
def link:
    (all_children | select(has("directory") and has("basename") and (.name | test(".(md|markdown)$";"i")))) |= (
        .link = ((.directory + [.basename + ".html"]) | join("/") | ltrimstr("./"))
    )
;

# Front matter is the page to be associated with the enclosing directory rather
# than itself.
def frontmatter:
    (all_children | select(has("contents"))) |= (
        (.frontmatter = [.contents[] | select(.meta.frontmatter | bool)][0]) |
        (.contents = .contents - [.frontmatter])
    )
;

# Drafts can be excluded from being uploaded and included in the table of
# contents. To not count as a draft, a document should be explicitly marked as
# "publish" in the metadata.
def explicit_publish:
    def publish: has("contents") or .external or .meta.publish | bool;
    (( all_children | select(publish | not)) |= mark) | remove_marked
;

# Sort content first according to sort order in metadata.
def sort_content:
    (all_children | select(has("contents")) | .contents) |= (
        sort_by(.meta.sort // .meta.title // .name)
    )
;

# Add a note that tells us whether this has only children who have no more
# subchildren.
def annotate_leaves:
    (all_children | select(has("contents"))) |= (
        .only_leaves = (.contents | all(has("contents") | not))
    )
;

# Combines the given stream of JSON objects by merging them, and performs the
# given operations to turn it into a proper index.
def index:
    merge
    | directory
    | basename
    | link
    | explicit_publish
    | frontmatter
    | sort_content
    | annotate_leaves
;

# Get publishable targets from an index, as an object containing the names of
# the relevant Makefile recipes.
def targets($dest):
    def in_dir($dir; $file):
        $dir + [$file] | join("/") | ltrimstr("./") | ($dest + "/" + .)
    ;
    [ all_children
        | ., (.frontmatter // empty)
        | select(has("directory") and has("basename") and (has("external") | not))
        | {"target": "pdf-targets", "name": in_dir(.directory; .basename + ".pdf")}
        , {"target": "html-targets", "name": in_dir(.directory; .basename + ".html")}
        , {"target": "external-targets", "name": in_dir(.directory; .targets?[])}
        , {"target": "external-targets", "name": in_dir(["."]; (.meta.style // empty) + ".css")}
    ]
    | group_by(.target)
    | map({(.[0].target): map(.name)})
    | add
    | . // {}
;

# Turn a target object into makefile recipes.
def as_makefile:
    . + {".PHONY": keys} | to_entries[] | (.key + ": " + (.value | join(" ")))
;

#!/usr/bin/env jq
# This is a collection of filters for `jq`, intended to manipulate JSON objects
# into JSON representing a website index, for further processing in a template.

# Convert "truthy" value to actual boolean.
def bool:
    [. == (null,false,0,{},[])] | any | not
;

# Generate this object and all its children.
def all_children:
    ., recurse(.contents[]?)
;

# To remove an object, we first mark it for removal, then actually remove it
# later. It would be nicer if we could just do `all_children ... |= empty`,
# which works in some cases but not all. I suppose it has to do with changing
# objects as we are iterating over them.
def mark:
    .remove = true
;

# Remove all objects marked for removal.
def remove_marked:
    if .remove == true
    then empty
    else (.contents // empty) |= map(remove_marked)
    end
;

# Wrap an object in other objects so that the original object exists at a
# particular "path". Like `{} | setpath(["a","b"], …)`, but instead of making
# objects like `{"a":{"b":…}}`, this makes objects like `{"name":"a",
# "contents":[{"name":"b",…}]}`.
def tree($path):
    if ($path | length) <= 1
    then .name = $path[0]
    else { "name": $path[0], "contents": [ tree($path[1:]) ] }
    end
;

# Merge an array of page objects into a single page object.
def merge:
    map(to_entries) | add | group_by(.key) | map(
        { "key": (.[0].key)
        , "value": (
            if .[0].key == "contents"
            then [ .[].value ] | add | group_by(.name) | map(merge)
            elif [ .[].value == .[0].value ] | all
            then .[0].value
            else error("can't merge incompatible values")
            end
        ) }
    ) | from_entries
;

# Add paths to each object, that is, the names of the ancestors.
def directory:
    def f($d): .directory = $d | .name as $n | .contents[]? |= f($d+[$n]);
    f([])
;

# Every leaf node gets a basename, eg name without extension.
def basename:
    (all_children | select(has("contents") | not)) |= (
        .basename = (.name | sub(".([A-z]*)$";""))
    )
;

# Any Markdown page gets a link to its HTML page.
def link:
    (all_children | select(has("directory") and has("basename") and (.name | test(".(md|markdown)$";"i")))) |= (
        .link = ((.directory + [.basename + ".html"]) | join("/") | ltrimstr("./"))
    )
;

# Front matter is the page to be associated with the enclosing directory rather
# than itself.
def frontmatter:
    (all_children | select(has("contents"))) |= (
        (.frontmatter = [.contents[] | select(.meta.frontmatter | bool)][0]) |
        (.contents = .contents - [.frontmatter])
    )
;

# Drafts can be excluded from being uploaded and included in the table of
# contents. To not count as a draft, a document should be explicitly marked as
# "publish" in the metadata.
def explicit_publish:
    def publish: has("contents") or .external or .meta.publish | bool;
    (( all_children | select(publish | not)) |= mark) | remove_marked
;

# Sort content first according to sort order in metadata.
def sort_content:
    (all_children | select(has("contents")) | .contents) |= (
        sort_by(.meta.sort // .meta.title // .name)
    )
;

# Add a note that tells us whether this has only children who have no more
# subchildren.
def annotate_leaves:
    (all_children | select(has("contents"))) |= (
        .only_leaves = (.contents | all(has("contents") | not))
    )
;

# Combines the given stream of JSON objects by merging them, and performs the
# given operations to turn it into a proper index.
def index:
    merge
    | directory
    | basename
    | link
    | explicit_publish
    | frontmatter
    | sort_content
    | annotate_leaves
;

# Get publishable targets from an index, as an object containing the names of
# the relevant Makefile recipes.
def targets($dest):
    def in_dir($dir; $file):
        $dir + [$file] | join("/") | ltrimstr("./") | ($dest + "/" + .)
    ;
    [ all_children
        | ., (.frontmatter // empty)
        | select(has("directory") and has("basename") and (has("external") | not))
        | {"target": "pdf-targets", "name": in_dir(.directory; .basename + ".pdf")}
        , {"target": "html-targets", "name": in_dir(.directory; .basename + ".html")}
        , {"target": "external-targets", "name": in_dir(.directory; .targets?[])}
        , {"target": "external-targets", "name": in_dir(["."]; (.meta.style // empty) + ".css")}
    ]
    | group_by(.target)
    | map({(.[0].target): map(.name)})
    | add
;

# Turn a target object into makefile recipes.
def as_makefile:
    . + {".PHONY": keys} | to_entries[] | (.key + ": " + (.value | join(" ")))
;