@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- package/types/statics.d.ts +25 -0
package/src/compiler/markdown.js
CHANGED
|
@@ -7,16 +7,23 @@
|
|
|
7
7
|
|
|
8
8
|
import MarkdownIt from "markdown-it";
|
|
9
9
|
import { highlightCode } from "../highlight.js";
|
|
10
|
-
import { LOOP_META, recordRead, wdError } from "./context.js";
|
|
10
|
+
import { LOOP_META, lineOf, recordRead, wdError } from "./context.js";
|
|
11
11
|
import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
|
|
12
12
|
import {
|
|
13
13
|
escapeHtml,
|
|
14
14
|
getPath,
|
|
15
|
+
interpolateBound,
|
|
15
16
|
interpolateLeaf,
|
|
16
17
|
lookupVar,
|
|
17
|
-
resolveStateKey
|
|
18
|
+
resolveStateKey,
|
|
19
|
+
unsafeUrlValue
|
|
18
20
|
} from "./interpolation.js";
|
|
19
21
|
|
|
22
|
+
// The two halves of the destination contract, re-exported together: a caller
|
|
23
|
+
// painting an href itself (a loop row's build-time initial) has to encode the
|
|
24
|
+
// value the same way this module does and answer the same scheme question.
|
|
25
|
+
export { unsafeUrlValue };
|
|
26
|
+
|
|
20
27
|
/**
|
|
21
28
|
* @typedef {import("./context.js").Meta} Meta
|
|
22
29
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
@@ -29,6 +36,8 @@ import {
|
|
|
29
36
|
const md = new MarkdownIt({ html: false, highlight: highlightCode });
|
|
30
37
|
md.use(bindingPlugin);
|
|
31
38
|
md.use(attrsPlugin);
|
|
39
|
+
md.use(attrBindPlugin);
|
|
40
|
+
md.use(rawHtmlPlugin);
|
|
32
41
|
md.use(anchorPlugin);
|
|
33
42
|
|
|
34
43
|
// Raw HTML in markdown is escaped by default: a stray `<script>` or `onerror=`
|
|
@@ -50,63 +59,413 @@ export function selectMd(meta) {
|
|
|
50
59
|
mdRawHtml = new MarkdownIt({ html: true, highlight: highlightCode });
|
|
51
60
|
mdRawHtml.use(bindingPlugin);
|
|
52
61
|
mdRawHtml.use(attrsPlugin);
|
|
62
|
+
mdRawHtml.use(attrBindPlugin);
|
|
63
|
+
mdRawHtml.use(rawHtmlPlugin);
|
|
53
64
|
mdRawHtml.use(anchorPlugin);
|
|
54
65
|
}
|
|
55
66
|
return mdRawHtml;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
/**
|
|
70
|
+
* Render one prose chunk.
|
|
59
71
|
* @param {string} text
|
|
60
72
|
* @param {Ctx} ctx
|
|
73
|
+
* @param {number} [index] 0-based line index the chunk starts at in the current
|
|
74
|
+
* `compileBody` slice, so a warning about a binding inside it reports the true
|
|
75
|
+
* `file:line`. Defaults to the top of the slice.
|
|
61
76
|
* @returns {string}
|
|
62
77
|
*/
|
|
63
|
-
export function renderProse(text, ctx) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
export function renderProse(text, ctx, index = 0) {
|
|
79
|
+
/** @type {PendingAttr[]} */
|
|
80
|
+
const attrs = [];
|
|
81
|
+
text = resolveDestinationBindings(text, ctx, attrs, index);
|
|
82
|
+
const html = (ctx.md ?? md).render(text, {
|
|
83
|
+
resolveBinding: (/** @type {string} */ expr) => resolveBinding(expr, ctx),
|
|
84
|
+
// Raw-HTML interpolation needs the compile context AND the chunk's offset,
|
|
85
|
+
// and markdown-it's env is the only channel into a core rule. `attrs` is the
|
|
86
|
+
// destination-binding registry `attrBindPlugin` reads back (see below).
|
|
87
|
+
wd: { ctx, index, attrs },
|
|
67
88
|
headingSlugs: ctx.comp.headingSlugs
|
|
68
89
|
});
|
|
90
|
+
if (!attrs.length) return html;
|
|
91
|
+
// A `](…)` that markdown did NOT turn into a link — the commonest being an
|
|
92
|
+
// inline code span documenting the syntax — keeps its placeholder. Put the
|
|
93
|
+
// author's own braces back rather than shipping the marker.
|
|
94
|
+
return html.replace(ATTR_MARK_RE, (mark, n) =>
|
|
95
|
+
attrs[Number(n)] ? escapeHtml(attrs[Number(n)].raw) : mark
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// THE SILENT-FAILURE CLASS: interpolation that markdown never sees.
|
|
101
|
+
//
|
|
102
|
+
// `{ name }` is an INLINE rule, so it only fires where markdown-it is parsing
|
|
103
|
+
// inline content. Three positions are not inline content, and in all three the
|
|
104
|
+
// framework used to emit the author's braces verbatim with no error and no
|
|
105
|
+
// warning:
|
|
106
|
+
//
|
|
107
|
+
// 1. A link/image destination. `[a]({ x })` worked (one special case), but
|
|
108
|
+
// `[a](/p/{ x }/)` did not: `{ … }` contains spaces, a markdown destination
|
|
109
|
+
// may not, so the link never forms and the WHOLE construct degrades to the
|
|
110
|
+
// literal text `[a](/p/value/)`.
|
|
111
|
+
// 2. An attribute inside raw HTML (`<a href="{ x }">`, `html: true`). The tag
|
|
112
|
+
// is one opaque `html_inline` token; nothing inside it is ever parsed.
|
|
113
|
+
// 3. The body of a raw `html_block` (`<div>{ x }</div>`).
|
|
114
|
+
//
|
|
115
|
+
// Every build-time value resolves in all three. A REACTIVE value in a
|
|
116
|
+
// destination now BINDS: the destination pass plants a placeholder, and
|
|
117
|
+
// `attrBindPlugin` turns the assembled href/src into a `data-wd-attr` template
|
|
118
|
+
// the runtime repaints (see there). Raw HTML still cannot bind — a tag is one
|
|
119
|
+
// opaque token with no element to mark — so that position keeps the warning.
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Warn (non-fatal) that a reactive value landed in raw HTML, which has no
|
|
124
|
+
* element for the runtime to mark.
|
|
125
|
+
* @param {Ctx} ctx
|
|
126
|
+
* @param {number} index 0-based line index of the offending line in the slice.
|
|
127
|
+
* @param {string} expr The binding source text, without braces.
|
|
128
|
+
* @param {boolean} painted Whether the initial value was written into the output.
|
|
129
|
+
* @returns {void}
|
|
130
|
+
*/
|
|
131
|
+
function warnUnbindable(ctx, index, expr, painted) {
|
|
132
|
+
const outcome = painted
|
|
133
|
+
? "its build-time value is painted once and then never updates"
|
|
134
|
+
: "it stays literal text";
|
|
135
|
+
ctx.comp.warnings.push(
|
|
136
|
+
`${ctx.file}:${lineOf(ctx, index)}: "{ ${expr} }" is reactive, but raw HTML (an attribute or an html block) cannot bind, so ${outcome}. ` +
|
|
137
|
+
`Move the value into ordinary text or a markdown link/image destination (where { ${expr} } does bind), or update it from a colocated .js with wd.subscribe.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// REACTIVE ATTRIBUTE BINDING (a link/image destination).
|
|
143
|
+
//
|
|
144
|
+
// A destination is not inline content, so there is no token for the inline rule
|
|
145
|
+
// to emit and nothing to hang `data-wd-bind` on. The pass below therefore runs
|
|
146
|
+
// in two halves:
|
|
147
|
+
//
|
|
148
|
+
// 1. `resolveDestinationBindings` (pre-parse) substitutes a build-time value
|
|
149
|
+
// directly, exactly as before, and replaces a REACTIVE one with the
|
|
150
|
+
// placeholder `~wd-attr-<n>~`, recording what it stands for. The
|
|
151
|
+
// placeholder is deliberately shaped out of characters markdown-it's URL
|
|
152
|
+
// normalizer leaves untouched, so it survives into the token's href/src.
|
|
153
|
+
// 2. `attrBindPlugin` (core rule) finds the placeholder in the assembled
|
|
154
|
+
// href/src, splits the value into literal chunks + bindings, writes the
|
|
155
|
+
// initial paint, and stamps the template onto the element.
|
|
156
|
+
//
|
|
157
|
+
// The template rides on `data-wd-attr` (state/`:computed`, repainted in the
|
|
158
|
+
// runtime's text-bind pass) or `data-wd-each-attr` (anything per-row, filled by
|
|
159
|
+
// `fillItem`) — the same split `data-wd-bind` / `data-wd-each` already use for
|
|
160
|
+
// text. One template serves every row, so a per-row destination paints EMPTY at
|
|
161
|
+
// build time and gets its first real value on hydrate.
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One reactive binding found in a destination: the serialized template part the
|
|
166
|
+
* runtime evaluates, plus the build-time text to paint for it.
|
|
167
|
+
* @typedef {object} PendingAttr
|
|
168
|
+
* @property {["s", string, string] | ["i", string] | ["m", string]} part
|
|
169
|
+
* `s` = state key + sub-path, `i` = loop-row sub-path, `m` = row meta variable.
|
|
170
|
+
* @property {string} text Build-time paint ("" for anything per-row).
|
|
171
|
+
* @property {string} raw The author's own `{ … }` source, restored when the
|
|
172
|
+
* `](…)` turned out not to be a link after all.
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
/** @param {number} n @returns {string} */
|
|
176
|
+
const attrMark = (n) => `~wd-attr-${n}~`;
|
|
177
|
+
const ATTR_MARK_RE = /~wd-attr-(\d+)~/g;
|
|
178
|
+
|
|
179
|
+
// A value going into a markdown link destination is URL text, not markdown, and
|
|
180
|
+
// the destination has no quoting: an unencoded `)` CLOSES it and hands whatever
|
|
181
|
+
// follows back to the parser (raw HTML on an `html: true` page), and a space
|
|
182
|
+
// silently kills the link. Encode the ASCII characters that carry meaning there
|
|
183
|
+
// by hand, because encodeURIComponent deliberately leaves `(`, `)` and `'`
|
|
184
|
+
// alone. Non-ASCII is left to markdown-it's own normalizeLink.
|
|
185
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — a control character in a destination has to be encoded, not passed through.
|
|
186
|
+
const DEST_UNSAFE = /[\u0000-\u0020()<>"'`\\\u007F]/g;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `unknown` rather than `string`: a caller painting a row's own initial href
|
|
190
|
+
* holds whatever `getPath` returned, and the coercion is right here.
|
|
191
|
+
* @param {unknown} value
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export const encodeDest = (value) =>
|
|
195
|
+
String(value).replace(
|
|
196
|
+
DEST_UNSAFE,
|
|
197
|
+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Byte ranges of the inline code spans on one line, so a destination being SHOWN
|
|
202
|
+
* rather than used stays literal. `` `[x](/p/{ p.slug }/)` `` in a docs
|
|
203
|
+
* paragraph documents the syntax, exactly like the fenced-block skip.
|
|
204
|
+
* @param {string} line
|
|
205
|
+
* @returns {[number, number][]}
|
|
206
|
+
*/
|
|
207
|
+
function codeSpansOf(line) {
|
|
208
|
+
/** @type {[number, number][]} */
|
|
209
|
+
const spans = [];
|
|
210
|
+
const runs = [...line.matchAll(/`+/g)];
|
|
211
|
+
for (let i = 0; i < runs.length; i++) {
|
|
212
|
+
for (let j = i + 1; j < runs.length; j++) {
|
|
213
|
+
// CommonMark closes a run of N backticks with a run of exactly N.
|
|
214
|
+
if (runs[j][0].length !== runs[i][0].length) continue;
|
|
215
|
+
spans.push([runs[i].index, runs[j].index + runs[j][0].length]);
|
|
216
|
+
i = j;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return spans;
|
|
69
221
|
}
|
|
70
222
|
|
|
71
223
|
/**
|
|
72
224
|
* Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
|
|
73
225
|
* destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
|
|
74
|
-
* can drive an href/src
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
226
|
+
* can drive an href/src, and a reactive value can be bound by the core rule.
|
|
227
|
+
*
|
|
228
|
+
* A markdown destination cannot contain the spaces inside `{ … }`, so markdown-it
|
|
229
|
+
* would never form the link; substituting here first lets the normal link/image
|
|
230
|
+
* parser run. Every `{ … }` in the destination is substituted, not just one at
|
|
231
|
+
* the very start — `/products/{ p.slug }/` is the shape a collection listing
|
|
232
|
+
* actually writes.
|
|
233
|
+
*
|
|
234
|
+
* Fenced code is skipped: a docs page showing `[{ p.name }](/p/{ p.slug }/)`
|
|
235
|
+
* inside a ```` ```wd ```` block is DOCUMENTING the syntax, not using it.
|
|
80
236
|
* @param {string} text
|
|
81
237
|
* @param {Ctx} ctx
|
|
238
|
+
* @param {PendingAttr[]} attrs Registry the core rule reads placeholders back from.
|
|
239
|
+
* @param {number} index 0-based line index of the slice, for `file:line`.
|
|
82
240
|
* @returns {string}
|
|
83
241
|
*/
|
|
84
|
-
function resolveDestinationBindings(text, ctx) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
242
|
+
function resolveDestinationBindings(text, ctx, attrs, index) {
|
|
243
|
+
const lines = text.split("\n");
|
|
244
|
+
/** @type {string | null} */
|
|
245
|
+
let fence = null;
|
|
246
|
+
for (let i = 0; i < lines.length; i++) {
|
|
247
|
+
const fenceMatch = lines[i].match(/^(```+|~~~+)/);
|
|
248
|
+
if (fence) {
|
|
249
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
250
|
+
fence = null;
|
|
251
|
+
continue;
|
|
90
252
|
}
|
|
91
|
-
|
|
253
|
+
if (fenceMatch) {
|
|
254
|
+
fence = fenceMatch[1];
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const spans = codeSpansOf(lines[i]);
|
|
258
|
+
lines[i] = lines[i].replace(/\]\(([^)\n]*)\)/g, (whole, dest, offset) => {
|
|
259
|
+
if (spans.some(([a, b]) => offset >= a && offset < b)) return whole;
|
|
260
|
+
const filled = dest.replace(
|
|
261
|
+
/\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
|
|
262
|
+
(/** @type {string} */ brace, /** @type {string} */ expr) => {
|
|
263
|
+
const found = resolveBinding(expr, ctx);
|
|
264
|
+
if (!found) return brace; // not in scope — leave the author's text alone
|
|
265
|
+
if (found.kind === "static") {
|
|
266
|
+
// The scheme test runs on the RAW value, before encoding, because
|
|
267
|
+
// that is the string the runtime's guard sees. Encoding first would
|
|
268
|
+
// turn " javascript:x" into "%20javascript:x" and quietly ship it.
|
|
269
|
+
if (unsafeUrlValue(found.text)) {
|
|
270
|
+
warnUnsafeUrl(ctx, index + i, expr);
|
|
271
|
+
return "";
|
|
272
|
+
}
|
|
273
|
+
return encodeDest(found.text);
|
|
274
|
+
}
|
|
275
|
+
const part = /** @type {PendingAttr["part"]} */ (
|
|
276
|
+
found.kind === "state"
|
|
277
|
+
? ["s", found.key ?? "", found.path ?? ""]
|
|
278
|
+
: found.meta
|
|
279
|
+
? ["m", found.meta]
|
|
280
|
+
: ["i", found.path ?? ""]
|
|
281
|
+
);
|
|
282
|
+
attrs.push({ part, text: found.text, raw: brace });
|
|
283
|
+
return attrMark(attrs.length - 1);
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
return filled === dest ? whole : `](${filled})`;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return lines.join("\n");
|
|
92
290
|
}
|
|
93
291
|
|
|
94
292
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
293
|
+
* markdown-it plugin (core rule): turn a destination placeholder back into a
|
|
294
|
+
* bound attribute. Reads the `~wd-attr-<n>~` markers out of a `link_open`'s
|
|
295
|
+
* `href` / an `image`'s `src`, writes the build-time paint, and stamps the
|
|
296
|
+
* template the runtime re-evaluates on every render.
|
|
297
|
+
*
|
|
298
|
+
* The build-time paint runs through the same scheme guard the runtime uses, on
|
|
299
|
+
* the same unencoded string: markdown-it's own `validateLink` vetted the
|
|
300
|
+
* placeholder, not the value that replaced it, so an unsafe seed would
|
|
301
|
+
* otherwise ship in the HTML.
|
|
302
|
+
* @param {MarkdownIt} mdInstance
|
|
303
|
+
* @returns {void}
|
|
304
|
+
*/
|
|
305
|
+
function attrBindPlugin(mdInstance) {
|
|
306
|
+
mdInstance.core.ruler.push("wd_attr_bind", (state) => {
|
|
307
|
+
/** @type {PendingAttr[] | undefined} */
|
|
308
|
+
const registry = state.env?.wd?.attrs;
|
|
309
|
+
if (!registry || !registry.length) return false;
|
|
310
|
+
for (const block of state.tokens) {
|
|
311
|
+
for (const token of block.children ?? []) {
|
|
312
|
+
const name = token.type === "link_open" ? "href" : token.type === "image" ? "src" : "";
|
|
313
|
+
if (!name) continue;
|
|
314
|
+
const raw = token.attrGet(name);
|
|
315
|
+
if (!raw || !raw.includes("~wd-attr-")) continue;
|
|
316
|
+
/** @type {(string | PendingAttr)[]} */
|
|
317
|
+
const template = [];
|
|
318
|
+
let initial = "";
|
|
319
|
+
let plain = ""; // the same value UNencoded: what the runtime will hold
|
|
320
|
+
let perRow = false;
|
|
321
|
+
let end = 0;
|
|
322
|
+
ATTR_MARK_RE.lastIndex = 0;
|
|
323
|
+
/** @type {RegExpExecArray | null} */
|
|
324
|
+
let hit;
|
|
325
|
+
while ((hit = ATTR_MARK_RE.exec(raw))) {
|
|
326
|
+
const pending = registry[Number(hit[1])];
|
|
327
|
+
if (!pending) continue; // not one of ours — leave the author's text
|
|
328
|
+
if (hit.index > end) {
|
|
329
|
+
template.push(raw.slice(end, hit.index));
|
|
330
|
+
initial += raw.slice(end, hit.index);
|
|
331
|
+
plain += raw.slice(end, hit.index);
|
|
332
|
+
}
|
|
333
|
+
template.push(pending);
|
|
334
|
+
initial += encodeDest(pending.text);
|
|
335
|
+
plain += pending.text;
|
|
336
|
+
if (pending.part[0] !== "s") perRow = true;
|
|
337
|
+
end = hit.index + hit[0].length;
|
|
338
|
+
}
|
|
339
|
+
if (!template.length) continue;
|
|
340
|
+
if (end < raw.length) {
|
|
341
|
+
template.push(raw.slice(end));
|
|
342
|
+
initial += raw.slice(end);
|
|
343
|
+
plain += raw.slice(end);
|
|
344
|
+
}
|
|
345
|
+
// Tested on the UNencoded assembly, exactly as `applyAttr` will test it
|
|
346
|
+
// in the browser: encoding first hides a leading space or control
|
|
347
|
+
// character from a guard that is meant to strip it.
|
|
348
|
+
token.attrSet(name, unsafeUrlValue(plain) ? "" : initial);
|
|
349
|
+
token.attrSet(
|
|
350
|
+
perRow ? "data-wd-each-attr" : "data-wd-attr",
|
|
351
|
+
JSON.stringify([name, ...template.map((t) => (typeof t === "string" ? t : t.part))])
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* markdown-it plugin (core rule): resolve `{ expr }` inside RAW HTML — the
|
|
361
|
+
* opaque `html_inline` tokens a tag becomes and the `html_block` tokens a
|
|
362
|
+
* block-level element becomes, both of which only exist on an `html: true` page.
|
|
363
|
+
* Values are HTML-escaped, which is correct in both positions a brace can sit
|
|
364
|
+
* in (an attribute value and element text), so no tag-position analysis is
|
|
365
|
+
* needed.
|
|
366
|
+
*
|
|
367
|
+
* `.md` files never reach this: `compileFile` renders them with no
|
|
368
|
+
* `resolveBinding` in the env, and this rule is a no-op without it — the
|
|
369
|
+
* extension stays the feature gate.
|
|
370
|
+
* @param {MarkdownIt} mdInstance
|
|
371
|
+
* @returns {void}
|
|
372
|
+
*/
|
|
373
|
+
function rawHtmlPlugin(mdInstance) {
|
|
374
|
+
mdInstance.core.ruler.push("wd_raw_html", (state) => {
|
|
375
|
+
const wd = state.env?.wd;
|
|
376
|
+
if (!wd) return false;
|
|
377
|
+
for (const block of state.tokens) {
|
|
378
|
+
const line = wd.index + (block.map ? block.map[0] : 0);
|
|
379
|
+
if (block.type === "html_block") {
|
|
380
|
+
block.content = fillRawHtml(block.content, wd.ctx, line);
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
// Inline children carry no line map, so count the line breaks walked past:
|
|
384
|
+
// one softbreak/hardbreak per source newline inside the block. Keeps the
|
|
385
|
+
// warning on the author's actual line, not the top of the paragraph.
|
|
386
|
+
let offset = 0;
|
|
387
|
+
for (const child of block.children ?? []) {
|
|
388
|
+
if (child.type === "softbreak" || child.type === "hardbreak") offset++;
|
|
389
|
+
// `child.meta.wdText` marks a token THIS layer already produced (a
|
|
390
|
+
// resolved binding). Its content is DATA, not template: without the skip,
|
|
391
|
+
// a state value that happens to read `{ meta.title }` would be resolved a
|
|
392
|
+
// second time and leak whatever `title` holds into the page.
|
|
393
|
+
else if (child.type === "html_inline" && child.meta?.wdText === undefined)
|
|
394
|
+
child.content = fillRawHtml(child.content, wd.ctx, line + offset);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// The value of a URL-bearing attribute, as far as a fragment has been written.
|
|
402
|
+
// The capture is the value SO FAR: `<a href="java` yields "java", so the guard
|
|
403
|
+
// can test what the attribute will actually hold rather than what one brace
|
|
404
|
+
// happened to resolve to. Escaping is no defense here: `href` and friends
|
|
405
|
+
// resolve `javascript:` without any quote breakout, and unlike a markdown
|
|
406
|
+
// destination nothing upstream vets an author's own raw `<a href="…">`.
|
|
407
|
+
const URL_ATTR_TAIL =
|
|
408
|
+
/\b(?:href|src|action|formaction|xlink:href)\s*=\s*(?:"([^"]*)|'([^']*)|([^\s>"']*))$/i;
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Warn that a value in URL position carries a scheme the browser would execute.
|
|
412
|
+
* @param {Ctx} ctx
|
|
413
|
+
* @param {number} index 0-based line index, for `file:line`.
|
|
99
414
|
* @param {string} expr
|
|
415
|
+
* @returns {void}
|
|
416
|
+
*/
|
|
417
|
+
function warnUnsafeUrl(ctx, index, expr) {
|
|
418
|
+
ctx.comp.warnings.push(
|
|
419
|
+
`${ctx.file}:${lineOf(ctx, index)}: "{ ${expr} }" resolves to a javascript:, data: or vbscript: URL, so the attribute is emitted empty. ` +
|
|
420
|
+
`Use an https:, mailto: or site-relative value, or render it as text rather than a link target.`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Substitute every `{ expr }` in a raw-HTML fragment with its escaped value.
|
|
426
|
+
*
|
|
427
|
+
* The output is assembled by hand rather than through `String.replace` because
|
|
428
|
+
* the URL guard has to see the attribute value BEING BUILT: `<a href="java{ x }">`
|
|
429
|
+
* with x = "script:alert(1)" is a live javascript: URL that no single brace
|
|
430
|
+
* value would ever look unsafe on its own. When the assembled value trips the
|
|
431
|
+
* guard, the literal head of that value is dropped from the output too, so the
|
|
432
|
+
* attribute lands empty instead of half-written.
|
|
433
|
+
* @param {string} html
|
|
100
434
|
* @param {Ctx} ctx
|
|
101
|
-
* @
|
|
435
|
+
* @param {number} index 0-based line index of the fragment, for warnings.
|
|
436
|
+
* @returns {string}
|
|
102
437
|
*/
|
|
103
|
-
function
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
438
|
+
function fillRawHtml(html, ctx, index) {
|
|
439
|
+
const brace = /\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g;
|
|
440
|
+
let out = "";
|
|
441
|
+
let last = 0;
|
|
442
|
+
/** @type {RegExpExecArray | null} */
|
|
443
|
+
let hit;
|
|
444
|
+
while ((hit = brace.exec(html))) {
|
|
445
|
+
out += html.slice(last, hit.index);
|
|
446
|
+
last = hit.index + hit[0].length;
|
|
447
|
+
const expr = hit[1];
|
|
448
|
+
const found = resolveBinding(expr, ctx);
|
|
449
|
+
if (!found) {
|
|
450
|
+
out += hit[0];
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (found.kind === "row") {
|
|
454
|
+
warnUnbindable(ctx, index, expr, false);
|
|
455
|
+
out += hit[0];
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (found.kind === "state") warnUnbindable(ctx, index, expr, true);
|
|
459
|
+
const open = URL_ATTR_TAIL.exec(out);
|
|
460
|
+
const head = open ? (open[1] ?? open[2] ?? open[3]) : "";
|
|
461
|
+
if (open && unsafeUrlValue(head + found.text)) {
|
|
462
|
+
warnUnsafeUrl(ctx, index, expr);
|
|
463
|
+
out = out.slice(0, out.length - head.length); // the head belongs to the refused value
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
out += escapeHtml(found.text);
|
|
467
|
+
}
|
|
468
|
+
return out + html.slice(last);
|
|
110
469
|
}
|
|
111
470
|
|
|
112
471
|
/**
|
|
@@ -126,11 +485,16 @@ function bindingPlugin(mdInstance) {
|
|
|
126
485
|
);
|
|
127
486
|
if (!match) return false;
|
|
128
487
|
const resolve = state.env?.resolveBinding;
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
488
|
+
const found = resolve ? resolve(match[1].trim()) : null;
|
|
489
|
+
if (!found) return false;
|
|
131
490
|
if (!silent) {
|
|
132
491
|
const token = state.push("html_inline", "", 0);
|
|
133
|
-
token.content = html;
|
|
492
|
+
token.content = found.html;
|
|
493
|
+
// The heading-anchor rule reads this back: an id must be a slug of what the
|
|
494
|
+
// reader SEES, and what the reader sees here is `found.text`, not the
|
|
495
|
+
// markup carrying it. Marking the token also keeps genuine author HTML
|
|
496
|
+
// (`## Hi <b>x</b>`) contributing nothing, exactly as before.
|
|
497
|
+
token.meta = { wdText: found.text };
|
|
134
498
|
}
|
|
135
499
|
state.pos += match[0].length;
|
|
136
500
|
return true;
|
|
@@ -223,8 +587,9 @@ export function slugify(text) {
|
|
|
223
587
|
* so long pages are linkable without any JS. Duplicate slugs dedupe with
|
|
224
588
|
* `-1`/`-2` suffixes; the counters live in `env.headingSlugs` so a page whose
|
|
225
589
|
* prose renders in chunks (directives between headings) still dedupes across
|
|
226
|
-
* the whole document.
|
|
227
|
-
*
|
|
590
|
+
* the whole document. A `{ name }` binding contributes its resolved text (its
|
|
591
|
+
* initial value when reactive); author-written inline HTML still contributes
|
|
592
|
+
* none, so only text the reader actually reads shapes the id.
|
|
228
593
|
* @param {MarkdownIt} mdInstance
|
|
229
594
|
* @returns {void}
|
|
230
595
|
*/
|
|
@@ -237,6 +602,12 @@ function anchorPlugin(mdInstance) {
|
|
|
237
602
|
let text = "";
|
|
238
603
|
for (const child of tokens[i + 1].children ?? []) {
|
|
239
604
|
if (child.type === "text" || child.type === "code_inline") text += child.content;
|
|
605
|
+
// A `{ name }` binding is real heading text — without it every
|
|
606
|
+
// interpolated heading on a site slugged to the same `section`,
|
|
607
|
+
// `section-1`, `section-2`, which is what darkmown.com/blog/ shipped.
|
|
608
|
+
// Reactive bindings contribute their INITIAL value: the id has to be
|
|
609
|
+
// stable and crawlable, so it is minted once at build time.
|
|
610
|
+
else if (child.meta?.wdText) text += child.meta.wdText;
|
|
240
611
|
}
|
|
241
612
|
const base = slugify(text);
|
|
242
613
|
const seen = counts.get(base) ?? 0;
|
|
@@ -248,13 +619,32 @@ function anchorPlugin(mdInstance) {
|
|
|
248
619
|
}
|
|
249
620
|
|
|
250
621
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
622
|
+
* A resolved `{ name.path }` binding.
|
|
623
|
+
* @typedef {object} Binding
|
|
624
|
+
* @property {string} html What the inline rule emits: a `data-wd-each` /
|
|
625
|
+
* `data-wd-bind` span for a reactive value, the escaped value for a static one.
|
|
626
|
+
* @property {string} text The PLAIN resolved text (unescaped, no markup) — a
|
|
627
|
+
* reactive value's initial paint. This is what a link destination, a raw-HTML
|
|
628
|
+
* attribute, and a heading slug need; `html` is unusable in all three.
|
|
629
|
+
* @property {"static" | "state" | "row"} kind Where the value came from, and
|
|
630
|
+
* therefore what the non-inline positions may do with it: `static` is a
|
|
631
|
+
* build-time value and always safe to substitute; `state` is `:state`/`:store`
|
|
632
|
+
* and paints its seed then binds; `row` is a reactive `@loop` row, whose ONE
|
|
633
|
+
* template serves every row, so it paints nothing and binds per row.
|
|
634
|
+
* @property {string} [key] For `state`: the fully-qualified state key.
|
|
635
|
+
* @property {string} [path] For `state`/`row`: the dotted sub-path under it ("" for the whole value).
|
|
636
|
+
* @property {string} [meta] For `row`: the per-row meta variable (`index`, `number`, …).
|
|
637
|
+
*/
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Resolve a `{ name.path }` binding: loop item span, static value, or a reactive
|
|
641
|
+
* bind span. Returns null when nothing in scope matches, which every caller
|
|
642
|
+
* treats as "leave the author's braces exactly as written".
|
|
253
643
|
* @param {string} expr
|
|
254
644
|
* @param {Ctx} ctx
|
|
255
|
-
* @returns {
|
|
645
|
+
* @returns {Binding | null}
|
|
256
646
|
*/
|
|
257
|
-
function
|
|
647
|
+
function resolveBinding(expr, ctx) {
|
|
258
648
|
const { path, stages } = validatePipes(expr, ctx);
|
|
259
649
|
const segs = path.split(".");
|
|
260
650
|
const head = segs[0];
|
|
@@ -270,19 +660,32 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
270
660
|
`"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`,
|
|
271
661
|
{ code: "WD005", file: ctx.file }
|
|
272
662
|
);
|
|
273
|
-
if (ctx.loopItem)
|
|
663
|
+
if (ctx.loopItem)
|
|
664
|
+
// reactive: filled per row, so there is no single build-time text.
|
|
665
|
+
return {
|
|
666
|
+
html: `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`,
|
|
667
|
+
text: "",
|
|
668
|
+
kind: "row",
|
|
669
|
+
meta: LOOP_META[head]
|
|
670
|
+
};
|
|
274
671
|
// static: the value is in scope (injected by staticUnroll); fall through.
|
|
275
672
|
}
|
|
276
673
|
|
|
277
674
|
if (ctx.loopItem && head === ctx.loopItem) {
|
|
278
675
|
const rest = segs.slice(1).join(".");
|
|
279
|
-
return
|
|
676
|
+
return {
|
|
677
|
+
html: `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}${fmt}></span>`,
|
|
678
|
+
text: "",
|
|
679
|
+
kind: "row",
|
|
680
|
+
path: rest
|
|
681
|
+
};
|
|
280
682
|
}
|
|
281
683
|
|
|
282
684
|
const staticValue = lookupVar(ctx.scope, head);
|
|
283
685
|
if (staticValue.found) {
|
|
284
686
|
const resolved = getPath(staticValue.value, segs.slice(1));
|
|
285
|
-
|
|
687
|
+
const text = interpolateLeaf(fold(resolved), expr, ctx);
|
|
688
|
+
return { html: escapeHtml(text), text, kind: "static" };
|
|
286
689
|
}
|
|
287
690
|
|
|
288
691
|
const key = resolveStateKey(head, ctx);
|
|
@@ -292,7 +695,17 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
292
695
|
const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
|
|
293
696
|
const rest = segs.slice(1).join(".");
|
|
294
697
|
const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
|
|
295
|
-
|
|
698
|
+
// interpolateBound, not interpolateLeaf: the runtime repaints this node with
|
|
699
|
+
// textContent, so the initial paint has to use the runtime's own coercion or
|
|
700
|
+
// an array bind silently changes from "a, b" to "a,b" on first render.
|
|
701
|
+
const text = interpolateBound(fold(initial), expr, ctx);
|
|
702
|
+
return {
|
|
703
|
+
html: `<span data-wd-bind="${escapeHtml(key)}"${pathAttr}${fmt}>${escapeHtml(text)}</span>`,
|
|
704
|
+
text,
|
|
705
|
+
kind: "state",
|
|
706
|
+
key,
|
|
707
|
+
path: rest
|
|
708
|
+
};
|
|
296
709
|
}
|
|
297
710
|
|
|
298
711
|
return null;
|
package/src/compiler/page.js
CHANGED
|
@@ -293,8 +293,11 @@ export function enhanceImages(html, paths, reader) {
|
|
|
293
293
|
/** @type {string[]} */
|
|
294
294
|
const add = [];
|
|
295
295
|
// Dimensions only when the author hasn't sized it — adding one axis to a
|
|
296
|
-
// manually-sized image would distort its aspect ratio.
|
|
297
|
-
|
|
296
|
+
// manually-sized image would distort its aspect ratio. A REACTIVE src
|
|
297
|
+
// (``, marked `data-wd-attr`/`data-wd-each-attr`) is sized
|
|
298
|
+
// from whatever the seed happened to be, so baking width/height would
|
|
299
|
+
// stretch every later image to the first one's aspect ratio.
|
|
300
|
+
if (!has("width") && !has("height") && !/\bdata-wd-(?:each-)?attr\s*=/.test(tag)) {
|
|
298
301
|
const dim = measureImage(srcOf(tag), paths, reader);
|
|
299
302
|
if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
|
|
300
303
|
}
|
|
@@ -427,7 +430,9 @@ export function compileFile(
|
|
|
427
430
|
const real = comp.reader.realpath(file);
|
|
428
431
|
if (stack.includes(real)) {
|
|
429
432
|
throw wdError(
|
|
430
|
-
`[WD612]
|
|
433
|
+
// No literal `[WD612]` here: wdError prefixes the code itself, and the
|
|
434
|
+
// hardcoded copy printed it twice (`[WD612] [WD612] Include cycle …`).
|
|
435
|
+
`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`,
|
|
431
436
|
{ code: "WD612", file }
|
|
432
437
|
);
|
|
433
438
|
}
|