@mapled/vanilla 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -5
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/mapled.js +18 -0
- package/dist/mapled.js.sha384 +1 -0
- package/dist/markdown.d.ts +15 -0
- package/dist/markdown.js +572 -0
- package/dist/paths.d.ts +21 -0
- package/dist/paths.js +60 -0
- package/dist/runtime.d.ts +84 -0
- package/dist/runtime.js +628 -0
- package/dist/sinks.d.ts +41 -0
- package/dist/sinks.js +142 -0
- package/dist/url.d.ts +30 -0
- package/dist/url.js +58 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +3 -0
- package/package.json +40 -5
package/dist/sinks.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** The sinks: the only places where a value from Mapled touches the
|
|
2
|
+
page. Each one is named by the page's author (an attribute of the
|
|
3
|
+
page), never by the value; each holds the value to its own rule of
|
|
4
|
+
DOM_SECURITY.md and, when the value doesn't fit, leaves the page as it
|
|
5
|
+
was and says why. */
|
|
6
|
+
import { assetUrl } from "@mapled/client";
|
|
7
|
+
import { renderMarkdown } from "./markdown.js";
|
|
8
|
+
import { asText } from "./paths.js";
|
|
9
|
+
import { isAssetId, safeUrl } from "./url.js";
|
|
10
|
+
const HTML = "http://www.w3.org/1999/xhtml";
|
|
11
|
+
/** Never written into, by local name and in any namespace (rule 2): their
|
|
12
|
+
text or attributes are code, policy or the base of every address. */
|
|
13
|
+
const REFUSED = new Set(["script", "style", "iframe", "frame", "frameset", "object", "embed", "applet", "base", "link", "meta", "template", "noscript"]);
|
|
14
|
+
/** The attributes content may fill (rule 3): the name that is written,
|
|
15
|
+
the elements it belongs to (null — any HTML element), what the value
|
|
16
|
+
is held to. */
|
|
17
|
+
export const ATTRIBUTES = {
|
|
18
|
+
href: { on: ["a", "area"], holds: "link" },
|
|
19
|
+
src: { on: ["img", "video", "audio", "source"], holds: "media" },
|
|
20
|
+
alt: { on: ["img", "area"], holds: "text" },
|
|
21
|
+
title: { on: null, holds: "text" },
|
|
22
|
+
"aria-label": { on: null, holds: "text" },
|
|
23
|
+
placeholder: { on: ["input", "textarea"], holds: "text" },
|
|
24
|
+
datetime: { on: ["time", "del", "ins"], holds: "text" },
|
|
25
|
+
};
|
|
26
|
+
const isHtml = (el) => el.namespaceURI === HTML;
|
|
27
|
+
/** Whether the element may be written into at all. */
|
|
28
|
+
export const writable = (el) => !REFUSED.has(el.localName.toLowerCase());
|
|
29
|
+
/** The element's text. Markup in the value stays text: this is the one
|
|
30
|
+
assignment content reaches, and it parses nothing. */
|
|
31
|
+
export function writeText(el, value) {
|
|
32
|
+
if (!writable(el))
|
|
33
|
+
return "refused-element";
|
|
34
|
+
const text = asText(value);
|
|
35
|
+
if (text === null)
|
|
36
|
+
return "type";
|
|
37
|
+
el.textContent = text;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** The element's content from Markdown. */
|
|
41
|
+
export function writeMarkdown(el, value, ctx) {
|
|
42
|
+
if (!writable(el) || !isHtml(el))
|
|
43
|
+
return "refused-element";
|
|
44
|
+
if (typeof value !== "string")
|
|
45
|
+
return "type";
|
|
46
|
+
el.replaceChildren(renderMarkdown(value, el.ownerDocument, ctx.base));
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
/** An image or a file: an asset id becomes its URL under the API's
|
|
50
|
+
/files (resized when the page asks for a width), anything else is an
|
|
51
|
+
address held to the media policy. */
|
|
52
|
+
function mediaUrl(value, ctx, width) {
|
|
53
|
+
if (typeof value !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
if (isAssetId(value))
|
|
56
|
+
return assetUrl(value, { ...(ctx.apiUrl ? { apiUrl: ctx.apiUrl } : {}), ...(width ? { width } : {}) });
|
|
57
|
+
return safeUrl(value, ctx.base, "media");
|
|
58
|
+
}
|
|
59
|
+
/** `<img data-mapled-bind>`: the picture. */
|
|
60
|
+
export function writeImage(el, value, ctx, width) {
|
|
61
|
+
if (!isHtml(el) || el.localName !== "img")
|
|
62
|
+
return "refused-element";
|
|
63
|
+
if (typeof value !== "string")
|
|
64
|
+
return "type";
|
|
65
|
+
const url = mediaUrl(value, ctx, width);
|
|
66
|
+
if (url === null)
|
|
67
|
+
return "unsafe-url";
|
|
68
|
+
// the author's srcset would keep showing the fallback picture
|
|
69
|
+
el.removeAttribute("srcset");
|
|
70
|
+
el.setAttribute("src", url);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
/** One attribute of the table. `name` is the page's spelling: it is
|
|
74
|
+
looked up, never written as it came. */
|
|
75
|
+
export function writeAttribute(el, name, value, ctx, width) {
|
|
76
|
+
const key = name.toLowerCase();
|
|
77
|
+
const rule = Object.prototype.hasOwnProperty.call(ATTRIBUTES, key) ? ATTRIBUTES[key] : undefined;
|
|
78
|
+
if (!rule)
|
|
79
|
+
return "unsupported";
|
|
80
|
+
if (!writable(el) || !isHtml(el))
|
|
81
|
+
return "refused-element";
|
|
82
|
+
if (rule.on !== null && !rule.on.includes(el.localName))
|
|
83
|
+
return "unsupported";
|
|
84
|
+
if (rule.holds === "text") {
|
|
85
|
+
const text = asText(value);
|
|
86
|
+
if (text === null)
|
|
87
|
+
return "type";
|
|
88
|
+
el.setAttribute(key, text);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (typeof value !== "string")
|
|
92
|
+
return "type";
|
|
93
|
+
const url = rule.holds === "media" ? mediaUrl(value, ctx, el.localName === "img" ? width : undefined) : safeUrl(value, ctx.base, "link");
|
|
94
|
+
if (url === null)
|
|
95
|
+
return "unsafe-url";
|
|
96
|
+
el.setAttribute(key, url);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/** A link built from the page's own template (url.ts › fillTemplate). */
|
|
100
|
+
export function writeLink(el, url) {
|
|
101
|
+
if (!isHtml(el) || (el.localName !== "a" && el.localName !== "area"))
|
|
102
|
+
return "unsupported";
|
|
103
|
+
el.setAttribute("href", url);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
/** Shown or not — the `hidden` attribute and nothing else. */
|
|
107
|
+
export function setVisible(el, visible) {
|
|
108
|
+
if (visible)
|
|
109
|
+
el.removeAttribute("hidden");
|
|
110
|
+
else
|
|
111
|
+
el.setAttribute("hidden", "");
|
|
112
|
+
}
|
|
113
|
+
export const FORMATS = ["text", "markdown", "date", "datetime", "number"];
|
|
114
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
115
|
+
/** What Mapled's date and date-time fields hold. `Date` alone would read a year out of almost any text. */
|
|
116
|
+
const ISO = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
117
|
+
/** A date or a number in the words of the page's language. Null when
|
|
118
|
+
the value isn't one. */
|
|
119
|
+
export function formatted(value, format, lang) {
|
|
120
|
+
const locale = lang && /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(lang) ? lang : undefined;
|
|
121
|
+
try {
|
|
122
|
+
if (format === "number") {
|
|
123
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
124
|
+
return Number.isFinite(n) ? new Intl.NumberFormat(locale).format(n) : null;
|
|
125
|
+
}
|
|
126
|
+
if (typeof value !== "string" || !ISO.test(value))
|
|
127
|
+
return null;
|
|
128
|
+
const at = new Date(value);
|
|
129
|
+
if (Number.isNaN(at.getTime()))
|
|
130
|
+
return null;
|
|
131
|
+
// a date without a time is a calendar day, the same day everywhere
|
|
132
|
+
const day = DATE_ONLY.test(value);
|
|
133
|
+
return new Intl.DateTimeFormat(locale, {
|
|
134
|
+
dateStyle: "long",
|
|
135
|
+
...(format === "datetime" && !day ? { timeStyle: "short" } : {}),
|
|
136
|
+
...(day ? { timeZone: "UTC" } : {}),
|
|
137
|
+
}).format(at);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/dist/url.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** The one policy every address from content passes before it reaches
|
|
2
|
+
the page (DOM_SECURITY.md, rule 4). The browser's own URL parser does
|
|
3
|
+
the reading — the parser navigation uses — so a scheme hidden behind
|
|
4
|
+
tabs, newlines, control characters or mixed case is judged the way
|
|
5
|
+
the browser would read it, and what is written is the parser's
|
|
6
|
+
`href`, never the text that came in. */
|
|
7
|
+
/** `link` — where a person goes (`href`); `media` — what the page loads
|
|
8
|
+
(`src`, images of rich text). */
|
|
9
|
+
export type AddressKind = "link" | "media";
|
|
10
|
+
/** The address as the browser reads it against `base`, or null when it
|
|
11
|
+
may not be written: a scheme outside the list, credentials in it
|
|
12
|
+
(`https://bank.example@evil.example/` reads as the bank), not an
|
|
13
|
+
address at all. */
|
|
14
|
+
export declare function safeUrl(value: string, base: string, kind: AddressKind): string | null;
|
|
15
|
+
/** An image or file value is an asset id: Mapled serves it under /files. */
|
|
16
|
+
export declare const isAssetId: (value: unknown) => value is string;
|
|
17
|
+
export type Filled = {
|
|
18
|
+
ok: true;
|
|
19
|
+
url: string;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: "bad-path" | "empty" | "unsafe-url";
|
|
23
|
+
path: string;
|
|
24
|
+
};
|
|
25
|
+
/** A link template — `/post.html?slug={slug}`: the text is the page
|
|
26
|
+
author's, every `{path}` is a value of the record in scope, and each
|
|
27
|
+
value is percent-encoded before it goes in — it can fill a segment or
|
|
28
|
+
a parameter, never add a scheme, a host, a slash or a second
|
|
29
|
+
parameter. The result still passes `safeUrl`. */
|
|
30
|
+
export declare function fillTemplate(template: string, base: string, lookup: (segments: string[]) => unknown): Filled;
|
package/dist/url.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** The one policy every address from content passes before it reaches
|
|
2
|
+
the page (DOM_SECURITY.md, rule 4). The browser's own URL parser does
|
|
3
|
+
the reading — the parser navigation uses — so a scheme hidden behind
|
|
4
|
+
tabs, newlines, control characters or mixed case is judged the way
|
|
5
|
+
the browser would read it, and what is written is the parser's
|
|
6
|
+
`href`, never the text that came in. */
|
|
7
|
+
import { parsePath } from "./paths.js";
|
|
8
|
+
const LINK = new Set(["http:", "https:", "mailto:", "tel:"]);
|
|
9
|
+
const MEDIA = new Set(["http:", "https:"]);
|
|
10
|
+
const MAX_LENGTH = 2048;
|
|
11
|
+
/** The address as the browser reads it against `base`, or null when it
|
|
12
|
+
may not be written: a scheme outside the list, credentials in it
|
|
13
|
+
(`https://bank.example@evil.example/` reads as the bank), not an
|
|
14
|
+
address at all. */
|
|
15
|
+
export function safeUrl(value, base, kind) {
|
|
16
|
+
if (typeof value !== "string" || value.length > MAX_LENGTH)
|
|
17
|
+
return null;
|
|
18
|
+
let url;
|
|
19
|
+
try {
|
|
20
|
+
url = new URL(value, base);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (!(kind === "link" ? LINK : MEDIA).has(url.protocol))
|
|
26
|
+
return null;
|
|
27
|
+
if (url.username !== "" || url.password !== "")
|
|
28
|
+
return null;
|
|
29
|
+
return url.href;
|
|
30
|
+
}
|
|
31
|
+
const ASSET_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
32
|
+
/** An image or file value is an asset id: Mapled serves it under /files. */
|
|
33
|
+
export const isAssetId = (value) => typeof value === "string" && ASSET_ID.test(value);
|
|
34
|
+
/** A link template — `/post.html?slug={slug}`: the text is the page
|
|
35
|
+
author's, every `{path}` is a value of the record in scope, and each
|
|
36
|
+
value is percent-encoded before it goes in — it can fill a segment or
|
|
37
|
+
a parameter, never add a scheme, a host, a slash or a second
|
|
38
|
+
parameter. The result still passes `safeUrl`. */
|
|
39
|
+
export function fillTemplate(template, base, lookup) {
|
|
40
|
+
const failures = [];
|
|
41
|
+
const text = template.replace(/\{([^{}]*)\}/g, (_, name) => {
|
|
42
|
+
const segments = parsePath(name);
|
|
43
|
+
if (!segments) {
|
|
44
|
+
failures.push({ ok: false, reason: "bad-path", path: name.trim().slice(0, 80) });
|
|
45
|
+
return "";
|
|
46
|
+
}
|
|
47
|
+
const value = lookup(segments);
|
|
48
|
+
if ((typeof value !== "string" && typeof value !== "number") || value === "") {
|
|
49
|
+
failures.push({ ok: false, reason: "empty", path: segments.join(".") });
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
return encodeURIComponent(String(value));
|
|
53
|
+
});
|
|
54
|
+
if (failures[0])
|
|
55
|
+
return failures[0];
|
|
56
|
+
const url = safeUrl(text, base, "link");
|
|
57
|
+
return url === null ? { ok: false, reason: "unsafe-url", path: template.slice(0, 80) } : { ok: true, url };
|
|
58
|
+
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mapled/vanilla",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "The browser runtime of Mapled, the hosted headless CMS for sites built with AI — published content in a plain HTML site, no framework and no build step.
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The browser runtime of Mapled, the hosted headless CMS for sites built with AI — published content in a plain HTML site: one script tag and data-mapled attributes, no framework and no build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://mapled.io",
|
|
7
7
|
"keywords": [
|
|
@@ -9,9 +9,44 @@
|
|
|
9
9
|
"cms",
|
|
10
10
|
"headless-cms",
|
|
11
11
|
"vanilla-js",
|
|
12
|
-
"html"
|
|
12
|
+
"html",
|
|
13
|
+
"static-site",
|
|
14
|
+
"script-tag"
|
|
13
15
|
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": [
|
|
20
|
+
"./dist/mapled.js"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"unpkg": "./dist/mapled.js",
|
|
26
|
+
"jsdelivr": "./dist/mapled.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./dist/mapled.js": "./dist/mapled.js"
|
|
33
|
+
},
|
|
14
34
|
"files": [
|
|
15
|
-
"
|
|
16
|
-
]
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc -p tsconfig.build.json && node build.mjs",
|
|
39
|
+
"prepack": "npm run build",
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@mapled/client": "^0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"esbuild": "^0.28.2",
|
|
48
|
+
"jsdom": "^26.0.0",
|
|
49
|
+
"typescript": "^5.9.0",
|
|
50
|
+
"vitest": "^4.1.11"
|
|
51
|
+
}
|
|
17
52
|
}
|