fiberplane-templates 1.0.0-beta.15

Programmatically generate Fiberplane Notebooks for repeatable workflows
Documentation
/**
 * @overview The Fiberplane Template library
 * @version 0.1
 */

// Helper functions

local validate = {
  types: [],
  assertType(name, value)::
    if std.member(self.types, std.type(value)) then
      value
    else error 'expected ' + name + ' to be of type: ' + std.join(', ', self.types),
  nullOr:: self + { types+: ['null'] },
  string(name, value):: (self + { types+: ['string'] }).assertType(name, value),
  boolean(name, value):: (self + { types+: ['boolean'] }).assertType(name, value),
  number(name, value):: (self + { types+: ['number'] }).assertType(name, value),
  object(name, value):: (self + { types+: ['object'] }).assertType(name, value),
  array(name, value):: (self + { types+: ['array'] }).assertType(name, value),
};
local isCell(value) = std.isObject(value) && std.objectHasAll(value, '_class') && value._class == 'CELL';
local isFormattedContent(value) = std.isObject(value) && std.objectHasAll(value, '_class') && value._class == 'FORMATTED_CONTENT';

local formEncodingReplacements = {
  ' ': '+',
  '\b': '%08',
  '\t': '%09',
  '\n': '%0A',
  '\f': '%0C',
  '\r': '%0D',
  '!': '%21',
  '"': '%22',
  '#': '%23',
  '$': '%24',
  ',': '%2C',
  '^': '%5E',
  '&': '%26',
  "'": '%27',
  '(': '%28',
  ')': '%29',
  '+': '%2B',
  '/': '%2F',
  ':': '%3A',
  ';': '%3B',
  '=': '%3D',
  '?': '%3F',
  '@': '%40',
  '[': '%5B',
  '\\': '%5C',
  ']': '%5D',
  '`': '%60',
  '{': '%7B',
  '|': '%7C',
  '}': '%7D',
  '~': '%7E',
  '%': '%25',
};
local formEscapeCharacter(character) =
  if std.objectHas(formEncodingReplacements, character) then
    formEncodingReplacements[character]
  else
    character;

// Helper function to perform form encoding
local encodeFormComponent(string) =
  std.foldl(
    function(result, character) result + formEscapeCharacter(character),
    std.stringChars(string),
    ''
  );

/**
 * @class format.FormattedContent
 * @classdesc A class representing formatted text. Each of the formatting functions can be called as methods to append text with the given formatting.
 * @example fp.format.bold('hello ').italic('world')
 */
local formattedContent(content='') =
  // Add content, either as a string, a formatted content object, or an array of strings and/or formatted content objects
  local addContent(fc, content='') =
    if std.type(content) == 'null' then
      fc
    else if std.isString(content) then
      fc {
        content+: content,
      }
    else if isFormattedContent(content) then
      local additionalOffset = std.length(fc.content);
      fc {
        content+: validate.string('content.content', content.content),
        formatting+: std.map(function(f) f { offset+: additionalOffset }, content.formatting),
      }
    else if std.isArray(content) then
      std.foldl(function(formatted, item) addContent(formatted, item), content, fc)
    else
      error 'Invalid content. Expected string, format object, or array. Got: ' + std.toString(content);

  // Helper function to add formatting annotations of a given type and the
  local addContentAndFormatting(fc, content='', format=null, url=null) =
    if std.isString(format) then
      // Add the formatting annotation and the content to fc
      local withStart = fc {
        formatting+: [{
          type: 'start_' + format,
          offset: std.length(fc.content),
          // Only include the url if it is not null
          [if std.isString(url) then 'url']: url,
        }],
      };
      local withContent = addContent(withStart, content);
      // Once the content is added, add the end formatting annotation with the correct offset
      withContent {
        formatting+: [{
          type: 'end_' + format,
          offset: std.length(withContent.content),
        }],
      }
    else
      addContent(fc, content);

  local addLabelContent(fc, key, value=null) =
    fc {
      content+: key + if std.length(value) > 0 then ':' + value else '',
      formatting+: [{
        type: 'label',
        offset: std.length(fc.content),
        key: key,
        value: value,
      }],
    };

  local fc = {
    content: '',
    formatting: [],
    _class:: 'FORMATTED_CONTENT',
    /**
     * Add raw text.
     * Note that if this is added inside a formatting helper, the outer formatting will be applied to this text.
     *
     * @function format.FormattedContent#raw
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    raw(content):: addContent(self, content),
    /**
     * Add bold text
     *
     * @function format.FormattedContent#bold
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    bold(content):: addContentAndFormatting(self, content, 'bold'),
    /**
     * Add italicized text
     *
     * @function format.FormattedContent#italics
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    italics(content):: addContentAndFormatting(self, content, 'italics'),
    /**
     * Add code-formatted text
     *
     * @function format.FormattedContent#code
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    code(content):: addContentAndFormatting(self, content, 'code'),
    /**
     * Add highlighted text
     *
     * @function format.FormattedContent#highlight
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    highlight(content):: addContentAndFormatting(self, content, 'highlight'),
    /**
     * Add strikethrough text
     *
     * @function format.FormattedContent#strikethrough
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    strikethrough(content):: addContentAndFormatting(self, content, 'strikethrough'),
    /**
     * Add underlined text
     *
     * @function format.FormattedContent#underline
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @returns {format.FormattedContent}
     */
    underline(content):: addContentAndFormatting(self, content, 'underline'),
    /**
     * Add a link
     *
     * @function format.FormattedContent#link
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @param {string | null} url - The URL of the link. If none is provided, the content will be used as the link URL
     * @returns {format.FormattedContent}
     *
     * @example fmt.link('Example', 'https://example.com')
     * @example fmt.link('https://example.com')
     */
    link(content, url=null):: addContentAndFormatting(self, content, 'link', url=if std.type(url) == 'null' then content else validate.string('url', url)),
    /**
     * Add a mention
     *
     * @function format.FormattedContent#mention
     * @param {string} userName - The username to mention
     * @param {string} userId - The ID of the user to mention
     * @returns {format.FormattedContent}
     */
    mention(userName, userId):: self {
      content+: '@' + userId,
      formatting+: [{
        type: 'mention',
        name: userName,
        userId: userId,
      }],
    },
    /**
     * Add a timestamp
     *
     * @function format.FormattedContent#timestamp
     * @param {string} timestamp - The RFC3339-formatted timestamp to add
     * @returns {format.FormattedContent}
     */
    timestamp(timestamp):: self {
      content+: timestamp,
      formatting+: [{
        type: 'timestamp',
        timestamp: timestamp,
      }],
    },
    /**
     * Add a label
     *
     * @function format.FormattedContent#label
     * @param {string} key - The label's key
     * @param {string} url - The label's value (optional)
     * @returns {format.FormattedContent}
     */
    label(key, value=''):: addLabelContent(self, key, value),
  };
  addContent(fc, content);

/**
 * @class notebook.Notebook
 * @classdesc A Fiberplane Notebook.
 *
 * @see {@link notebook.new notebook\.new} to create a Notebook
 */
local notebook = {
  /**
   * Create a new notebook with the given title.
   *
   * @function notebook.new
   * @memberof notebook
   * @param title
   * @returns {notebook.Notebook}
   */
  new(title):: {
    title: validate.string('title', title),
    timeRange: { minutes: -60 },
    selectedDataSources: {},
    labels: [],
    cells: [],
    frontMatter: {},
    frontMatterSchema: [],
    frontMatterCollections: [],
    // This is used to generate the cell IDs in the addCell
    // method. It does not appear in the JSON output
    _nextCellId:: 1,

    /**
     * Set the notebook time range relative to when it is created.
     *
     * For example, specifying `minutes=60` will set the start timestamp
     * to 60 minutes before the notebook is created. The end timestamp
     * will automatically be set to the time when the notebook is created.
     *
     * By default, the time range is set to 60 minutes relative to when the notebook is created.
     *
     * @function notebook.Notebook#setTimeRangeRelative
     * @param {number} minutes
     * @returns {notebook.Notebook}
     */
    setTimeRangeRelative(minutes):: self {
      timeRange+: { minutes: -validate.number('minutes', minutes) },
    },

    /**
     * Set the time range of the notebook using absolute timestamps.
     *
     * Note: in most cases, you will want to use {@link notebook#setTimeRangeRelative} instead.
     *
     * @function notebook.Notebook#setTimeRangeAbsolute
     * @param {string} from - ISO 8601/RFC 3339 formatted start timestamp
     * @param {string} to - ISO 8601/RFC 3339 formatted end timestamp
     * @returns {notebook.Notebook}
     */
    setTimeRangeAbsolute(from, to):: self {
      timeRange+: {
        from: validate.string('from', from),
        to: validate.string('to', to),
      },
    },

    /**
     * Select a data source for the given provider type.
     *
     * The workspace defaults will be used if none is specified.
     *
     * @function notebook.Notebook#setSelectedDataSource
     * @param {string} providerType - The type of provider to select the data source for
     * @param {string} dataSourceName - The name of the data source to select for the given provider type
     * @param {string | null} proxyName - If the data source is configured in a proxy, the name of the proxy
     * @returns {notebook.Notebook}
     */
    setDataSourceForProviderType(providerType, dataSourceName, proxyName=null):: self {
      selectedDataSources+: {
        [providerType]: {
          name: validate.string('dataSourceName', dataSourceName),
          proxyName: validate.nullOr.string('proxyName', proxyName),
        },
      },
    },

    /**
     * Add a single cell to the notebook.
     *
     * @function notebook.Notebook#addCell
     * @param {cell.Cell} cell
     * @returns {notebook.Notebook}
     */
    addCell(cell)::
      if std.isObject(cell) then
        local cellId = self._nextCellId;
        // Remove all null values and add the id field as a string
        local cellWithId = std.prune(cell) + {
          id: cellId + '',
        };
        self {
          _nextCellId: cellId + 1,
          // Append the cell to the cells array
          cells+: [cellWithId],
        }
      else if std.type(cell) == null then
        self
      else error 'Cell must be an object',

    /**
     * Add an array of cells to the notebook.
     *
     * Note: this function supports nested arrays of cells.
     *
     * @function notebook.Notebook#addCells
     * @param {cell.Cell[]} cells
     * @returns {notebook.Notebook}
     */
    addCells(cells)::
      // Call addCell for each cell in the array
      // and recursively call addCells if there
      // are nested arrays
      std.foldl(function(n, cell) (
        if std.isArray(cell) then
          n.addCells(cell)
        else n.addCell(cell)
      ), validate.array('cells', cells), self),

    /**
     * Add a single label to the notebook.
     *
     * @function notebook.Notebook#addLabel
     * @param {string} key - Key of the label
     * @param {string} value - Value of the label
     * @returns {notebook.Notebook}
     *
     * @example notebook.addLabel(key='service', value='api')
     */
    addLabel(key, value=''):: self {
      labels+: [{
        key: validate.string('key', key),
        value: validate.string('value', value),
      }],
    },

    /**
     * Add an object of labels to the notebook.
     *
     * @function notebook.Notebook#addLabels
     * @param {object} labels - Map of keys and values
     * @returns {notebook.Notebook}
     *
     * @example notebook.addLabels({
     *  service: 'api',
     *  severity: 'high'
     * })
     */
    addLabels(labels):: std.foldl(
      function(nb, key)
        local value = if std.isString(labels[key]) then labels[key] else '';
        nb.addLabel(key, value),
      std.objectFields(validate.object('labels', labels)),
      self
    ),

    /**
     * Add a single front matter collection to the notebook.
     *
     * @function notebook.Notebook#addFrontMatterCollection
     * @param {string} name - Name of the front matter collection in the workspace
     * @returns {notebook.Notebook}
     *
     * @example notebook.addFrontMatterCollection(name='post-mortem')
     */
    addFrontMatterCollection(name):: self {
      frontMatterCollections+: [
        validate.string('name', name),
      ],
    },

    /**
     * Add multiple front matter collections to the notebook.
     *
     * @function notebook.Notebook#addFrontMatterCollections
     * @param {Array.<string>} names - Names of the front matter collections in the workspace
     * @returns {notebook.Notebook}
     *
     * @example notebook.addFrontMatterCollections(['post-mortem', 'opsgenie'])
     */
    addFrontMatterCollections(names):: std.foldl(
      function(nb, name)
        nb.addFrontMatterCollection(name),
      names,
      self
    ),

    /**
     * UNSTABLE: this function has no validation and the parameters might change.
     *
     * Append front matter schema to a notebook inline. The method allows describing the schema
     * directly in template source.
     *
     * @function notebook.Notebook#addFrontMatterSchema
     * @param {Array.<object>} frontMatterSchema - Front Matter Schema as expected by the API
     * @returns {notebook.Notebook}
     */
    addFrontMatterSchema(fMSchema):: self {
      frontMatterSchema+: fMSchema,
    },

    /**
     * Add a single front matter value to the notebook. The value will _not_ appear in the
     * notebook unless the front matter _schema_ of the notebook has an entry for the given key.
     *
     * @function notebook.Notebook#addFrontMatterValue
     * @param {string} key - Key of the front matter entry
     * @param {string | number} value - Front matter value
     * @returns {notebook.Notebook}
     *
     * @example notebook.addFrontMatterValue(key='status', value='Created')
     */
    addFrontMatterValue(key, value):: self {
      frontMatter+: { [key]: value },
    },

    /**
     * Add multiple front matter values to the notebook. The value will _not_ appear in the
     * notebook unless the front matter _schema_ of the notebook has an entry for the given key.
     *
     * @function notebook.Notebook#addFrontMatterValues
     * @param {object} vals - Map of keys and values
     * @returns {notebook.Notebook}
     *
     * @example notebook.addFrontMatterValues({
     *  status: 'Created',
     *  ticket: 23
     * })
     */
    addFrontMatterValues(vals):: std.foldl(
      function(nb, key)
        nb.addFrontMatterValue(key, vals[key]),
      std.objectFields(validate.object('vals', vals)),
      self
    ),

  },
};

/**
 * @class cell.Cell
 * @classdesc An individual cell in a notebook
 */
local cell = {
  // Base type that cells are built from.
  // Each cell-specific function will merge other
  // fields into the object returned here.
  local base = function(type, content, readOnly)
    formattedContent(content) + {
      id: '',
      type: type,
      readOnly: validate.nullOr.boolean('readOnly', readOnly),
      _class:: 'CELL',
      /**
       * Lock the cell
       *
       * @method cell.Cell#setReadOnly
       * @param {boolean} readOnly=true
       * @returns {cell.Cell}
       */
      setReadOnly(readOnly=true):: self {
        readOnly: readOnly,
      },
    },

  // List item
  local li = function(listType, content, startNumber, level, readOnly)
    base('list_item', content, readOnly) + {
      listType: listType,
      level: validate.nullOr.number('level', level),
      startNumber: validate.nullOr.number('startNumber', startNumber),
    },

  // Function to create a list from an array of strings, cells, and/or other lists.
  // It sets the startNumber field for all list items.
  // If it also sets the level field for all nested list items.
  local list = function(listType, cells, startNumber, level, readOnly)
    std.foldl(
      function(accumulator, content)
        // Treat strings as list items and increment the start number
        if std.isString(content) || isFormattedContent(content) then
          local cell = li(listType, content, accumulator.startNumber, level, readOnly);
          // Merge these values into the accumulator
          // (the + operator is optional when merging objects)
          accumulator {
            startNumber+: 1,
            array+: [cell],
          }
        else if std.isArray(content) then
          // Nested lists need to have their level incremented
          local nextLevel = if std.isNumber(level) then level + 1 else 1;
          accumulator {
            array+: list(listType, content, 1, nextLevel, readOnly),
          }
        else if isCell(content) then
          // Add the cell to the array and update the level if the cell is a list item
          local cellWithLevel = content {
            [if content.type == 'list_item' then 'level']: level,
          };
          accumulator {
            array+: [cellWithLevel],
          }
        else error 'Expected a string, formatted content, cell, or array of those. Got: ' + std.toString(content),
      cells,
      { startNumber: startNumber, array: [] },
    ).array,

  /**
   * Create a checkbox cell
   *
   * @function cell.checkbox
   * @param {boolean} checked=false - Whether the checkbox is checked
   * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
   * @param {number | null} level=null - Specify the indentation level.
   *  The top level is `null`, the first indented level is `1`, and so on
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  checkbox(content='', checked=false, level=null, readOnly=null)::
    base('checkbox', content, readOnly) + {
      checked: validate.boolean('checked', checked),
      level: validate.nullOr.number('level', level),
    },

  /**
   * Create a code cell
   *
   * @function cell.code
   * @param {boolean} checked=false - Whether the checkbox is checked
   * @param {string} content='' - Cell text content
   * @param {string | null} syntax=null - Specify the syntax to use for rendering the code
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  code(content='', syntax=null, readOnly=null)::
    base('code', validate.string('content', content), readOnly) + {
      syntax: validate.nullOr.string('syntax', syntax),
    },
  /**
   * Create a divider (horizontal rule) cell
   *
   * @function cell.divider
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  divider(readOnly=null)::
    base('divider', null, readOnly),

  /**
   * Heading cells
   * @namespace cell.heading
   */
  heading:: {
    local h = function(headingType, content, readOnly)
      base('heading', content, readOnly) + {
        headingType: headingType,
      },

    /**
     * Create an H1 cell
     *
     * Also accessible as `cell.h1`
     *
     * @function cell.heading.h1
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @param {boolean} readOnly=false - Whether the cell is locked
     * @returns {cell.Cell}
     */
    h1(content='', readOnly=null):: h('h1', content, readOnly),

    /**
     * Create an H2 cell
     *
     * Also accessible as `cell.h2`
     *
     * @function cell.heading.h2
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @param {boolean} readOnly=false - Whether the cell is locked
     * @returns {cell.Cell}
     */
    h2(content='', readOnly=null):: h('h2', content, readOnly),

    /**
     * Create an H3 cell
     *
     * Also accessible as `cell.h3`
     *
     * @function cell.heading.h3
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @param {boolean} readOnly=false - Whether the cell is locked
     * @returns {cell.Cell}
     */
    h3(content='', readOnly=null):: h('h3', content, readOnly),
  },
  h1:: cell.heading.h1,
  h2:: cell.heading.h2,
  h3:: cell.heading.h3,

  /**
    * Helper functions for easily creating lists
    *
    * @namespace cell.list
    */
  list:: {
    /**
     * Create an ordered list
     *
     * Also accessible as `cell.ol` and `cell.orderedList`
     *
     * @function cell.list.ordered
     * @param {Array.<(string | cell.Cell | Array)>} cells An array of strings, cells, or nested lists.
     *  Strings will become numbered list items. Other cell types are included as they are.
     *  Nested lists have their indentation `level` automatically incremented.
     * @param {number} startNumber=1 Starting number for the whole list. This function automatically handles
     *  numbering for all items in this list.
     * @param {number | null} level=null - Specify the indentation level.
     *  The top level is `null`, the first indented level is `1`, and so on
     * @param {boolean} readOnly=false - Whether the cells are locked
     * @returns {cell.Cell[]}
     */
    ordered(cells=[], startNumber=1, level=null, readOnly=null)::
      list('ordered', cells, startNumber, level, readOnly),

    /**
     * Create an unordered list
     *
     * Also accessible as `cell.ul` and `cell.unorderedList`
     *
     * @function cell.list.unordered
     * @param {Array.<(string | cell.Cell | Array)>} cells An array of strings, cells, or nested lists.
     *  Strings will become list items. Other cell types are included as they are.
     *  Nested lists have their indentation `level` automatically incremented.
     * @param {number | null} level=null - Specify the indentation level.
     *  The top level is `null`, the first indented level is `1`, and so on
     * @param {boolean} readOnly=false - Whether the cells are locked
     * @returns {cell.Cell[]}
     */
    unordered(cells=[], startNumber=1, level=null, readOnly=null)::
      list('unordered', cells, startNumber, level, readOnly),
  },
  ul:: cell.list.unordered,
  unorderedList:: cell.list.unordered,
  ol:: cell.list.ordered,
  orderedList:: cell.list.ordered,

  /**
    * Individual list items.
    *
    * In most cases, you will want to use {@link Cell.list} instead.
    *
    * @namespace cell.listItem
    */
  listItem:: {

    /**
     * Create an ordered list item
     *
     * @function cell.listItem.ordered
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @param {number | null} startNumber=null - Specify the starting number.
     *  Mostly useful if you want to start the list at a number other than `1`.
     * @param {number | null} level=null - Specify the indentation level.
     *  The top level is `null`, the first indented level is `1`, and so on
     * @param {boolean} readOnly=false - Whether the cell is locked
     * @returns {cell.Cell}
     */
    ordered(content='', level=null, startNumber=null, readOnly=null)::
      li('ordered', content, startNumber, level, readOnly),

    /**
     * Create an unordered list item
     *
     * @function cell.listItem.unordered
     * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
     * @param {number | null} level=null - Specify the indentation level.
     *  The top level is `null`, the first indented level is `1`, and so on
     * @param {boolean} readOnly=false - Whether the cell is locked
     * @returns {cell.Cell}
     */
    unordered(content='', level=null, startNumber=null, readOnly=null)::
      li('unordered', content, startNumber, level, readOnly),
  },

  local provider = function(intent='', title='', queryData=null, readOnly=null) {
    type: 'provider',
    output: [],
    intent: validate.string('intent', intent),
    queryData: validate.nullOr.string('queryData', queryData),
    readOnly: validate.nullOr.boolean('readOnly', readOnly),
  },

  /**
    * Create a provider cell
    *
    * @function cell.provider
    * @param {string} intent - The intent of the new provider cell
    * @param {string} title - Title for the new provider cell (deprecated)
    * @param {string} queryData - Query data that the provider will understand
    * @param {boolean} readOnly=false - Whether the cell is locked
   */
  provider:: provider,

  /**
   * Create a Prometheus query cell
   *
   * @function cell.prometheus
   * @param {string} content='' - Prometheus query
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  prometheus(content='', readOnly=null, title=''):: provider('prometheus,timeseries', title, if validate.string('content', content) == '' then null else 'application/x-www-form-urlencoded,query=' + encodeFormComponent(content), readOnly),

  /**
   * Create an Elasticsearch query cell
   *
   * @function cell.elasticsearch
   * @param {string} content='' - Elasticsearch query
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  elasticsearch(content='', readOnly=null, title=''):: provider('elasticsearch,events', title, if validate.string('content', content) == '' then null else 'application/x-www-form-urlencoded,query=' + encodeFormComponent(content), readOnly),

  /**
   * Create a Loki query cell
   *
   * @function cell.loki
   * @param {string} content='' - Loki query
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  loki(content='', readOnly=null, title=''):: provider('loki,events', title, if validate.string('content', content) == '' then null else 'application/x-www-form-urlencoded,query=' + encodeFormComponent(content), readOnly),

  /**
   * Create a plain text cell
   *
   * @function cell.text
   * @param {string | format.FormattedContent | Array.<(string | format.FormattedContent)>} content - The content to add
   * @param {boolean} readOnly=false - Whether the cell is locked
   * @returns {cell.Cell}
   */
  text(content='', readOnly=null):: base('text', content, readOnly),

  /**
   * Create an image cell
   *
   * @function cell.image
   * @param {string} url - URL of the image
   * @param {boolean} readOnly=false - Whether the cell is locked
   */
  image(url=null, readOnly=null)::
    base('image', '', readOnly) + {
      content: null,
      url: validate.nullOr.string('url', url),
    },
};

// Create a dummy notebook just to reuse the addCells functionality (which adds cell IDs)
local snippet = function(cells)
  notebook.new('')
  .addCells(cells)
  .cells;

// Library exports
{
  /**
   * Functions for creating Fiberplane Notebooks
   * @namespace notebook
   *
   * @example fp.notebook.new('My Notebook')
   *  .setTimeRangeRelative(minutes=60)
   *  .addCells([...])
   */
  notebook: notebook,

  /**
   * Function for creating Snippets, or reusable groups of cells.
   * @function snippet
   * @param {cell.Cell[]} cells
   * @returns {cell.Cell[]}
   *
   * @example fp.snippet([
   *  c.text('Hello, world!'),
   *  c.code('This is a snippet'),
   * ])
   */
  snippet: snippet,
  /**
   * Functions for creating notebook cells
   * @namespace cell
   *
   * @example <caption>Adding cells to a notebook</caption>
   * notebook.addCells([
   *   cell.h1('Title'),
   *   cell.text('Hello world!'),
   *   // See below for all of the available cell types
   * ])
   */
  cell: cell,
  /**
   * Functions for formatting text
   *
   * @namespace format
   *
   * @example fp.format.bold('hello')
   * @example <caption>Nested formatting</caption>
   * fp.format.bold(fp.format.italic('hello'))
   * @example <caption>Creating a cell with different text formats</caption>
   * fp.cell.text(['hello ', fp.format.bold('world '), fp.format.italics('!')])
   * // This is equivalent to:
   * fp.cell.text(fp.format.raw('hello ').bold('world ').italics('!'))
   *
   * @borrows format.FormattedContent#raw as raw
   * @borrows format.FormattedContent#bold as bold
   * @borrows format.FormattedContent#code as code
   * @borrows format.FormattedContent#highlight as highlight
   * @borrows format.FormattedContent#italics as italics
   * @borrows format.FormattedContent#underline as underline
   * @borrows format.FormattedContent#strikethrough as strikethrough
   * @borrows format.FormattedContent#link as link
   */
  format: formattedContent(),
}