@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/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
+ }
@@ -0,0 +1,3 @@
1
+ /** The package's version — what the banner of the single file and
2
+ `Mapled.version` say. Kept equal to package.json by version.test.ts. */
3
+ export declare const VERSION = "0.1.0";
@@ -0,0 +1,3 @@
1
+ /** The package's version — what the banner of the single file and
2
+ `Mapled.version` say. Kept equal to package.json by version.test.ts. */
3
+ export const VERSION = "0.1.0";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mapled/vanilla",
3
- "version": "0.0.1",
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. 0.0.1 holds no code yet; 0.1.0 is the first release.",
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
- "README.md"
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
  }