Skip to content

asimojs/lml

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

8 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

LML - List Markup Language

LML is a simplified JSON-based language to represent HTML (or JSX) data in JSON responses, as an alternative to server-side rendering.

LML was designed to be

  • easy to read/write for humans compared to an HTML Abstract Syntax Tree
  • fast to parse and transform on the browser side
  • framework agnostic, not bound to any server-side technology and cross platform (can be also used in mobile native apps)

A live LLM usage can be viewed throuth the DPA demo application.

Main benefits:

  • possibility to process an HTML response as JSON content prior to rendering (e.g. remove/transform elements, pick a subset to implement pagination, etc.)
  • safe HTML: the LML library sanitizes LML content when processed (cf. lml2jsx() util)
  • possibility to mix LML content with structured data (e.g. JSON list containing LML nodes that can contain JSON data as node attributes)
  • possibility to reference components
  • possibility to assign components to namespaces (and implement bundle lazy loading, like in the DPA demo)
  • possibility to pass LML content as component attributes
  • possibility to support richer HTML syntax (e.g. decorators, tagged children blocks)
  • same size as HTML in average (when uncompressed)

Example

// Text node
const ex1: LML =
    // Hello World
    "Hello World";
// Span, no attributes -> # prefix for elements
const ex2: LML =
    // <span class="hello"> Hello <em> World! </em></span>
    ["#span.hello", "Hello", ["#em", "World!"]]
// Span with attributes
const ex3: LML =
    // <span class="hello" "title"="Greetings!"> Hello <em> World! </em></span>
    ["#span.hello", { "title": "Greetings" }, "Hello", ["#em", "World!"]]
// Fragment
const ex4: LML =
    // <Fragment><em>Hello</em>World!</Fragment>
    [["#em", "Hello"], "World!"]
// Component -> * prefix instead of #
const ex5: LML =
    // <MyCpt className="abc" title="..."> Some <span class="em">content...</span> </MyCpt>
    ["*MyCpt.abc", { "title": "..." }, " Some ", ["#span.em", "content... "]]
// Node with type, id and empty attribute (here: checked - value will be ignored)
const ex6: LML =
    // <input type="checkbox" class="abc" id="subscribeNews" name="subscribe" value="newsletter" disabled />
    ["#input+checkbox.abc", { "id": "subscribeNews", "name": "subscribe", "value": "newsletter", "disabled": true }]
// Advanced component with bundle id + JSON and LML attributes
const ex7: LML =
    ["*b:MyCpt!abc-def-ghi", { // b = bundle id  key = abc-def-ghi
        "logo": ["*c:img", { "height": 22, "width": 22, "src": "..." }],
        "columnWidths": [1, 2, 3, 4]
    },
        ["#span.hello", "Some ", ["#em", "content..."]]
    ]

Install

npm i @asimojs/lml

Syntax

LML support 3 node types: text nodes, fragments and element/component nodes:

  • text nodes are represented as strings
  • elements and components are represented as Arrays
  • fragments are represented as Arrays (of strings or Arrays)

As such, the only part to memorize is the element node structure:

  • the first item in an element node contains the element type and name (that can be complemented with a few frequently used attributes: type, class elements and key)
  • the second item is optional and can be a JSON object containing the element attributes
  • the next items (starting from position 1 or 2 depending on attributes) are the element child nodes
// Element with no attributes and two child nodes
const el1 = ["#span", "Hello", ["#span.b", "World"]];
// Element with attributes and one childe node
const el2 = ["#div", {"title": "Greetings", "class": "header greeting"}, "Hello World"];
// Same element with the class elements shortcut in the element name
const el3 = ["#div.header.greeting", {"title": "Greetings"}, "Hello World"];
// Component -> different prefix (i.e. * instead of #)
const el3 = ["*section.header.greeting", {"title": "Greetings"}, "Hello World"];

The element name is composed of 6 parts:

  1. the element type: # for html tags and * for components (other key words are reserved) - e.g. "*"
  2. [optional] the element namespace - e.g. "c:"
  3. the element name (cannot contain "+" or "." or "!")
  4. [optional] the element type attribute (useful for input elements) - e.g. "+text"
  5. [optional] several class elements - e.g. ".foo.bar"
  6. [optional] a key attribute - useful for React rendering or top manage document updates (cf. below) - e.g. "!abc-def-ghi". Keys can contain any character, this is why they come last.
// Element name:
// [#|*|!|@] [namespace:?] [nodename] [+typeattribute?] [.classattributes*] [!keyattribute?]
export const RX_NODE_NAME = /^(\#|\*|\!|\@)(\w+\:)?([\w\-]+)(\+[\w\-]+)?(\.[\.\w\-]+)*(\!.+)?$/;

APIs

Apart from the LML types, the LML library provide the following APIs:

nodeType()

Return the type of an LML node:

function nodeType(content: LML): LmlNodeType {}

type LmlNodeType = "text" | "element" | "component" | "fragment" | "invalid";

// examples
expect(nodeType("Hello")).toBe("text");
expect(nodeType(["#span", "Hello"])).toBe("element");
expect(nodeType([["#span", "b"]])).toBe("fragment");
expect(nodeType(["*cpt", "Hello"])).toBe("component");
expect(nodeType(["!x", "Hello"])).toBe("invalid");

lml2jsx() / defaultSanitizationRules

Convert an LML structure to a JSX tree through a createElement function that must be passed as argument. The JSX tree is also sanitized.

function lml2jsx(v: LML,
    createElement: (type: any | Function, props: { [key: string]: any }, ...children: any) => JSX.Element,
    getComponent?: ((name: string, namespace: string) => Function | null) | null,
    error?: ((msg: string) => void) | null,
    sanitizationRules?: LmlSanitizationRules)
    : JsxContent {}

type JsxContent = JSX.Element | string | (JSX.Element | string)[];


// examples
import { defaultSanitizationRules, lml2jsx } from '../lml';
import { h } from 'preact';

let jsx1 = lml2jsx(v, h);

let jsx2 = lml2jsx(v, h, (name, ns) => {
    if (name === "MyCpt" && ns === "b") {
        return MyCpt2;
    }
    return null; // invalid component
});

const sanitizationRules: LmlSanitizationRules = {
    allowedElements: new Set(["input", "my-widget", ...defaultSanitizationRules.allowedElements]),
    forbiddenElementAttributes: defaultSanitizationRules.forbiddenElementAttributes,
    forbidEventHandlers: true,
    allowedUrlPrefixes: defaultSanitizationRules.allowedUrlPrefixes,
    urlAttributes: defaultSanitizationRules.urlAttributes
}

const errors:string[];
let jsx3 = lml2jsx(v, h, null, (msg) => {errors.push(msg)}, sanitizationRules) );

By default the following sanitization rules are applied:

const defaultSanitizationRules: LmlSanitizationRules = {
    /**
     * Allowed tags - img + tags from https://github.com/apostrophecms/sanitize-html
     * Note: form and input are not in the list
     */
    allowedElements: new Set([
        "address", "article", "aside", "footer", "header", "h1", "h2", "h3", "h4",
        "h5", "h6", "hgroup", "main", "nav", "section", "blockquote", "dd", "div",
        "dl", "dt", "figcaption", "figure", "hr", "li", "main", "ol", "p", "pre",
        "ul", "a", "abbr", "b", "bdi", "bdo", "br", "cite", "code", "data", "dfn",
        "em", "i", "kbd", "mark", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp",
        "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "caption",
        "col", "colgroup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "img"
    ]),

    /** Forbid style, srcset and event handler attributes */
    forbiddenElementAttributes: new Set(["style", "srcset"]),

    /** Tell if elemeent event handlers attributes must be discarded */
    forbidEventHandlers: true,

    /**
     * URL attributes used in allowedElements, will be checked against allowedUrlPrefixes
     * as per https://stackoverflow.com/questions/2725156/complete-list-of-html-tag-attributes-which-have-a-url-value
     */
    urlAttributes: new Set(["href", "src", "cite", "action", "profile", "longdesc", "usemap", "formaction", "icon",
        "poster", "background", "codebase", "data", "classid", "manifest"]),

    /** Allowed URLs - DO NOT PUT "data:text" -> data:text/html can contain malicious scripts */
    allowedUrlPrefixes: ["/", "./", "http://", "https://", "mailto://", "tel://", "data:image/"]
}

updateLML()

In-place update of an LML data structure with instructions provided as arguments. This function is particularly handy when LML is combined with a reactive state management solution as it allows to update an existing DOM with update instructions sent by the server. This behavior is demonstrated in the DPA demo application.

Return the new data structure (may be different if the original data is not a fragment)

function updateLML(data: LML, instructions: LmlUpdate[]): LML {}

type LmlUpdate = LmlNodeUpdate | LmlNodeListUpdate | LmlNodeDelete;

export interface LmlNodeUpdate {
    action: "insertBefore" | "insertAfter" | "replace";
    node: LmlNodeKey;
    path?: LmlNodePath;
    content: LML;
}
export interface LmlNodeListUpdate {
    action: "append" | "prepend" | "replace";
    /** target node: root node if not provided */
    node?: LmlNodeKey;
    /** target path: default = empty for root node, "children" for "append" or "prepend", empty for "replace" */
    path?: LmlNodePath;
    content: LML;
}

export interface LmlNodeDelete {
    action: "delete";
    /** target node: root node if not provided */
    node?: LmlNodeKey;
    path?: LmlNodePath;
}

// examples (cf. unit tests)
const r1 = updateLML(["#div", "Hello", ["#span.firstName!FN", "Bart"], ["#span.lastName!LN", "Simpson"]], [{
    action: "insertBefore",
    node: "FN",
    content: ["#span.title!TITLE", "Mr"]
}]);
expect(print(r1)).toMatchObject([
    '<div>',
    '  Hello',
    '  <span class="title">',
    '    Mr',
    '  </span>',
    '  <span class="firstName">',
    '    Bart',
    '  </span>',
    '  <span class="lastName">',
    '    Simpson',
    '  </span>',
    '</div>',
]);

const r2 = updateLML(["*mycpt!CPT", { "footer": { "sections": ["First", ["#span", "Second"]] } }, "Hello"], [{
    action: "append",
    node: "CPT",
    path: "footer/sections",
    content: "NEW-NODE"
}]);
expect(print(r2)).toMatchObject([
    '<mycpt() footer={"sections":["First",["#span","Second"],"NEW-NODE"]}>',
    '  Hello',
    '</mycpt>',
]);

const r3 = updateLML(["Hello"], [{
    action: "replace",
    content: ["AA", "BB"]
    // no node key = root node
}]);
expect(print(r3)).toMatchObject([
    "AA", "BB"
]);

processJSX()

Scan LML data and transform them to JSX thanks to the formatter passed as arguement. This function is used by lml2jsx() behind the scenes.

WARNING: This function doesn't perform any sanitization - use with caution!

function processJSX(v: LML, f: LmlFormatter): JsxContent

interface LmlFormatter {
    format(ndi: LmlNodeInfo, attributes?: LmlAttributeMap, children?: (JSX.Element | string)[]): JsxContent;
    error?(m: string): void;
}