jskelet 0.6.3 → 0.6.4
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 +136 -136
- package/CHANGELOG.md +628 -620
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/bin/jskelet.mjs +130 -130
- package/docs/01-baslangic.md +291 -291
- package/docs/02-mimari.md +310 -310
- package/docs/03-routing.md +515 -515
- package/docs/04-render-ve-sablonlar.md +667 -661
- package/docs/05-islands.md +486 -486
- package/docs/06-cache.md +1467 -1443
- package/docs/07-yapilandirma.md +1208 -1197
- package/docs/08-build.md +429 -429
- package/docs/09-dev-araclari.md +364 -364
- package/docs/10-dagitim.md +348 -338
- package/docs/12-panel-ve-oturum.md +479 -478
- package/docs/README.md +83 -83
- package/docs/en/01-getting-started.md +298 -298
- package/docs/en/02-architecture.md +329 -329
- package/docs/en/03-routing.md +531 -531
- package/docs/en/04-rendering.md +675 -669
- package/docs/en/05-islands.md +497 -497
- package/docs/en/06-caching.md +1476 -1453
- package/docs/en/07-configuration.md +1229 -1219
- package/docs/en/08-build.md +447 -447
- package/docs/en/09-dev-tools.md +373 -373
- package/docs/en/10-deployment.md +351 -340
- package/docs/en/11-migration.md +398 -398
- package/docs/en/12-dashboards-and-sessions.md +489 -488
- package/docs/en/README.md +87 -87
- package/package.json +137 -137
- package/src/build/ensure-build.mjs +19 -19
- package/src/build/paths.mjs +153 -153
- package/src/build/resolve-peer.mjs +36 -36
- package/src/build/tasks/client.mjs +349 -349
- package/src/build/tasks/css.mjs +235 -235
- package/src/build/tasks/fonts.mjs +146 -146
- package/src/build/tasks/icons.mjs +357 -357
- package/src/build/tasks/images.mjs +244 -244
- package/src/build/tasks/precompress.mjs +78 -78
- package/src/build/tasks/templates.mjs +20 -20
- package/src/client/admin/i18n.js +764 -764
- package/src/client/admin/login.html +74 -74
- package/src/client/admin/panel.css +809 -809
- package/src/client/admin/panel.html +495 -495
- package/src/client/admin/panel.js +1251 -1251
- package/src/client/devtools/report.html +185 -185
- package/src/client/devtools/report.js +745 -745
- package/src/client/devtools/seo.js +628 -628
- package/src/client/dom.js +95 -95
- package/src/client/form.js +192 -192
- package/src/client/index.js +45 -45
- package/src/client/registry.js +305 -305
- package/src/client/safe-image.js +91 -91
- package/src/client/shared-cookie.js +225 -225
- package/src/client/store.js +36 -36
- package/src/client/swap.js +188 -188
- package/src/compile/codegen.js +336 -336
- package/src/compile/compile-all.js +149 -149
- package/src/compile/errors.js +66 -66
- package/src/compile/expr.js +409 -409
- package/src/compile/index.js +17 -17
- package/src/compile/parse.js +541 -541
- package/src/compile/resolve.js +211 -211
- package/src/compile/scan-exports.js +51 -51
- package/src/config/defaults.js +541 -534
- package/src/config/index.js +1500 -1469
- package/src/config/pattern.js +107 -107
- package/src/generate.mjs +163 -163
- package/src/http/control-flow.js +71 -71
- package/src/http/cookies-entry.js +21 -21
- package/src/http/cookies.js +277 -277
- package/src/http/request-cache.js +46 -46
- package/src/http/request-context.js +165 -165
- package/src/http/shared-cookie.js +178 -178
- package/src/index.js +101 -101
- package/src/init.mjs +232 -230
- package/src/migrate/apply.mjs +262 -262
- package/src/migrate/babel.mjs +79 -79
- package/src/migrate/classify.mjs +155 -155
- package/src/migrate/config.mjs +126 -126
- package/src/migrate/fs-walk.mjs +191 -191
- package/src/migrate/parse.mjs +26 -26
- package/src/migrate/scan.mjs +177 -177
- package/src/migrate/transform/expr-source.mjs +168 -168
- package/src/migrate/transform/island.mjs +67 -67
- package/src/migrate/transform/jsx-to-component.mjs +302 -302
- package/src/migrate/transform/jsx-to-jsk.mjs +330 -330
- package/src/migrate/transform/page-split.mjs +435 -435
- package/src/migrate/write.mjs +81 -81
- package/src/migrate.mjs +171 -171
- package/src/runtime/alias-hooks.mjs +119 -119
- package/src/runtime/register.mjs +4 -4
- package/src/server/admin/actions.js +229 -229
- package/src/server/admin/auth.js +125 -125
- package/src/server/admin/event-log.js +151 -151
- package/src/server/admin/gate.js +209 -209
- package/src/server/admin/inventory.js +188 -188
- package/src/server/admin/mount.js +56 -56
- package/src/server/admin/router.js +216 -216
- package/src/server/admin/snapshot.js +241 -241
- package/src/server/assets.js +147 -147
- package/src/server/auth/handoff.js +309 -309
- package/src/server/cache-blob.js +70 -70
- package/src/server/cache-control.js +45 -0
- package/src/server/cache-deps.js +42 -42
- package/src/server/cache-vary.js +113 -113
- package/src/server/cloudflare.js +607 -607
- package/src/server/create-app.js +366 -366
- package/src/server/data-cache.js +553 -553
- package/src/server/dev/report.js +485 -485
- package/src/server/dev/socket.js +170 -170
- package/src/server/dev/version-check.mjs +139 -139
- package/src/server/disk-cache.js +233 -233
- package/src/server/ejs-adapter.js +59 -59
- package/src/server/html-cache.js +1196 -1196
- package/src/server/image-optimizer.js +500 -500
- package/src/server/logs/access-middleware.js +66 -66
- package/src/server/logs/file-sink.js +193 -193
- package/src/server/logs/pipeline.js +165 -165
- package/src/server/logs/s3-put.js +214 -214
- package/src/server/logs/s3-sink.js +112 -112
- package/src/server/metadata.js +102 -102
- package/src/server/middleware/compression.js +205 -205
- package/src/server/middleware/csrf.js +134 -134
- package/src/server/middleware/dev-gate.js +75 -75
- package/src/server/middleware/headers.js +37 -37
- package/src/server/middleware/redirects.js +32 -32
- package/src/server/middleware/robots-txt.js +341 -341
- package/src/server/middleware/static-precompressed.js +121 -121
- package/src/server/middleware/trailing-slash.js +53 -53
- package/src/server/middleware/upstream-proxy.js +141 -141
- package/src/server/og-image.js +369 -356
- package/src/server/port-guard.js +255 -255
- package/src/server/prewarm.js +1082 -1082
- package/src/server/redis.js +588 -588
- package/src/server/render.js +910 -910
- package/src/server/router.js +157 -157
- package/src/server/status-page.js +265 -265
- package/src/server/upstream-limiter.js +376 -376
- package/src/server/upstream-tracking.js +166 -166
- package/src/shared/cookie-domain.js +66 -66
- package/src/start.mjs +22 -22
- package/src/templates/layout.ejs +30 -30
- package/src/templates/layout.jsk +30 -30
- package/src/version.mjs +31 -31
- package/src/views/components/loader.js +101 -101
- package/src/views/helpers/html.js +102 -102
- package/src/views/helpers/tags.js +375 -375
- package/types/config/defaults.d.ts +6 -0
- package/types/config/index.d.ts +6 -0
- package/types/server/cache-control.d.ts +28 -0
- package/types/server/og-image.d.ts +5 -0
package/src/compile/parse.js
CHANGED
|
@@ -1,541 +1,541 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `.jsk` şablon ayrıştırıcı. HTML'e yakın sözdizimi + blok direktifleri.
|
|
3
|
-
* İstek anında çalışmaz; yalnızca build-time.
|
|
4
|
-
*/
|
|
5
|
-
import { CompileError } from "./errors.js";
|
|
6
|
-
import { parseExpr } from "./expr.js";
|
|
7
|
-
|
|
8
|
-
/** Self-closing HTML void elements. */
|
|
9
|
-
const VOID = new Set([
|
|
10
|
-
"area",
|
|
11
|
-
"base",
|
|
12
|
-
"br",
|
|
13
|
-
"col",
|
|
14
|
-
"embed",
|
|
15
|
-
"hr",
|
|
16
|
-
"img",
|
|
17
|
-
"input",
|
|
18
|
-
"link",
|
|
19
|
-
"meta",
|
|
20
|
-
"param",
|
|
21
|
-
"source",
|
|
22
|
-
"track",
|
|
23
|
-
"wbr",
|
|
24
|
-
]);
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @typedef {import('./expr.js').ExprNode} ExprNode
|
|
28
|
-
* @typedef {{ type: 'Text', value: string }} TextNode
|
|
29
|
-
* @typedef {{ type: 'Interp', expr: ExprNode, raw: boolean, index: number }} InterpNode
|
|
30
|
-
* @typedef {{ name: string, value: string | null, expr: ExprNode | null, bound: boolean, boolean: boolean, index: number }} AttrNode
|
|
31
|
-
* @typedef {{ type: 'Element', name: string, attrs: AttrNode[], children: AstNode[], selfClosing: boolean, index: number }} ElementNode
|
|
32
|
-
* @typedef {{ type: 'Component', name: string, attrs: AttrNode[], children: AstNode[], selfClosing: boolean, index: number }} ComponentNode
|
|
33
|
-
* @typedef {{ type: 'If', test: ExprNode, consequent: AstNode[], alternate: AstNode[], index: number }} IfNode
|
|
34
|
-
* @typedef {{ type: 'Each', list: ExprNode, item: string, indexName: string | null, children: AstNode[], index: number }} EachNode
|
|
35
|
-
* @typedef {{ type: 'Include', path: string, index: number }} IncludeNode
|
|
36
|
-
* @typedef {TextNode | InterpNode | ElementNode | ComponentNode | IfNode | EachNode | IncludeNode} AstNode
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* @param {string} source
|
|
41
|
-
* @param {{ file?: string }} [options]
|
|
42
|
-
* @returns {AstNode[]}
|
|
43
|
-
*/
|
|
44
|
-
export function parseTemplate(source, options = {}) {
|
|
45
|
-
const parser = new TemplateParser(source, options.file);
|
|
46
|
-
return parser.parseNodes();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* PascalCase bileşen mi?
|
|
51
|
-
* @param {string} name
|
|
52
|
-
* @returns {boolean}
|
|
53
|
-
*/
|
|
54
|
-
export function isComponentTag(name) {
|
|
55
|
-
return /^[A-Z]/.test(name);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
class TemplateParser {
|
|
59
|
-
/**
|
|
60
|
-
* @param {string} source
|
|
61
|
-
* @param {string} [file]
|
|
62
|
-
*/
|
|
63
|
-
constructor(source, file) {
|
|
64
|
-
this.source = source;
|
|
65
|
-
this.file = file;
|
|
66
|
-
this.i = 0;
|
|
67
|
-
/** @type {string[]} Açık {#if}/{#each} kapanışları — EOF'ta öncelikli hata. */
|
|
68
|
-
this.blockStack = [];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* @param {string} message
|
|
73
|
-
* @param {number} [index]
|
|
74
|
-
* @returns {CompileError}
|
|
75
|
-
*/
|
|
76
|
-
error(message, index = this.i) {
|
|
77
|
-
return new CompileError(message, {
|
|
78
|
-
file: this.file,
|
|
79
|
-
source: this.source,
|
|
80
|
-
index,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
eof() {
|
|
85
|
-
return this.i >= this.source.length;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
peek(n = 0) {
|
|
89
|
-
return this.source[this.i + n] ?? "";
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
startsWith(value) {
|
|
93
|
-
return this.source.startsWith(value, this.i);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* @param {string} [until]
|
|
98
|
-
* @returns {AstNode[]}
|
|
99
|
-
*/
|
|
100
|
-
parseNodes(until) {
|
|
101
|
-
/** @type {AstNode[]} */
|
|
102
|
-
const nodes = [];
|
|
103
|
-
while (!this.eof()) {
|
|
104
|
-
if (until && this.startsWith(until)) break;
|
|
105
|
-
|
|
106
|
-
if (this.startsWith("<!--")) {
|
|
107
|
-
this.skipComment();
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (this.startsWith("{#")) {
|
|
112
|
-
nodes.push(this.parseDirective());
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (this.startsWith("{{")) {
|
|
117
|
-
nodes.push(this.parseInterp());
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (this.startsWith("</")) {
|
|
122
|
-
// Parent closes — leave for caller.
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// `<!DOCTYPE …>` / `<![CDATA[…]]>` — etiket değil; metin olarak bas.
|
|
127
|
-
// İlerletmezsek `<` + `!` dalı parseText'te de takılıp sonsuz döngü olur.
|
|
128
|
-
if (this.startsWith("<!")) {
|
|
129
|
-
nodes.push(this.parseDeclaration());
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (this.peek() === "<" && /[A-Za-z]/.test(this.peek(1))) {
|
|
134
|
-
nodes.push(this.parseTag());
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
nodes.push(this.parseText(until));
|
|
139
|
-
}
|
|
140
|
-
return nodes;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* `<!DOCTYPE html>` gibi bildirimler. Kapanış `>` ilk tırnak dışı geçiş.
|
|
145
|
-
* @returns {TextNode}
|
|
146
|
-
*/
|
|
147
|
-
parseDeclaration() {
|
|
148
|
-
const start = this.i;
|
|
149
|
-
this.i += 2; // <!
|
|
150
|
-
let quote = null;
|
|
151
|
-
while (!this.eof()) {
|
|
152
|
-
const ch = this.peek();
|
|
153
|
-
if (quote) {
|
|
154
|
-
if (ch === quote) quote = null;
|
|
155
|
-
this.i += 1;
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (ch === '"' || ch === "'") {
|
|
159
|
-
quote = ch;
|
|
160
|
-
this.i += 1;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
if (ch === ">") {
|
|
164
|
-
this.i += 1;
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
this.i += 1;
|
|
168
|
-
}
|
|
169
|
-
return { type: "Text", value: this.source.slice(start, this.i) };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
skipComment() {
|
|
173
|
-
const end = this.source.indexOf("-->", this.i + 4);
|
|
174
|
-
if (end === -1) throw this.error("Unterminated HTML comment");
|
|
175
|
-
this.i = end + 3;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* @param {string} [until]
|
|
180
|
-
* @returns {TextNode}
|
|
181
|
-
*/
|
|
182
|
-
parseText(until) {
|
|
183
|
-
const start = this.i;
|
|
184
|
-
while (!this.eof()) {
|
|
185
|
-
if (this.startsWith("{{") || this.startsWith("{#") || this.startsWith("<!--")) break;
|
|
186
|
-
if (this.peek() === "<") break;
|
|
187
|
-
if (until && this.startsWith(until)) break;
|
|
188
|
-
this.i += 1;
|
|
189
|
-
}
|
|
190
|
-
return { type: "Text", value: this.source.slice(start, this.i) };
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** @returns {InterpNode} */
|
|
194
|
-
parseInterp() {
|
|
195
|
-
const index = this.i;
|
|
196
|
-
let raw = false;
|
|
197
|
-
if (this.startsWith("{{{")) {
|
|
198
|
-
raw = true;
|
|
199
|
-
this.i += 3;
|
|
200
|
-
} else {
|
|
201
|
-
this.i += 2;
|
|
202
|
-
}
|
|
203
|
-
const close = raw ? "}}}" : "}}";
|
|
204
|
-
const end = this.source.indexOf(close, this.i);
|
|
205
|
-
if (end === -1) throw this.error("Unterminated interpolation", index);
|
|
206
|
-
const exprSrc = this.source.slice(this.i, end).trim();
|
|
207
|
-
if (!exprSrc) throw this.error("Empty interpolation", index);
|
|
208
|
-
const expr = parseExpr(exprSrc, 0, exprSrc.length, {
|
|
209
|
-
file: this.file,
|
|
210
|
-
templateSource: this.source,
|
|
211
|
-
templateIndex: this.i,
|
|
212
|
-
});
|
|
213
|
-
this.i = end + close.length;
|
|
214
|
-
return { type: "Interp", expr, raw, index };
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** @returns {IfNode | EachNode | IncludeNode} */
|
|
218
|
-
parseDirective() {
|
|
219
|
-
const index = this.i;
|
|
220
|
-
if (this.startsWith("{#if")) {
|
|
221
|
-
return this.parseIf(index);
|
|
222
|
-
}
|
|
223
|
-
if (this.startsWith("{#each")) {
|
|
224
|
-
return this.parseEach(index);
|
|
225
|
-
}
|
|
226
|
-
if (this.startsWith("{#include")) {
|
|
227
|
-
return this.parseInclude(index);
|
|
228
|
-
}
|
|
229
|
-
// `{# comment #}` — kapanış `#}` zorunlu; yoksa bilinmeyen direktif.
|
|
230
|
-
if (this.startsWith("{#")) {
|
|
231
|
-
const end = this.source.indexOf("#}", this.i + 2);
|
|
232
|
-
if (end === -1) {
|
|
233
|
-
throw this.error("Unknown directive", index);
|
|
234
|
-
}
|
|
235
|
-
this.i = end + 2;
|
|
236
|
-
return { type: "Text", value: "" };
|
|
237
|
-
}
|
|
238
|
-
throw this.error("Unknown directive", index);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* @param {number} index
|
|
243
|
-
* @returns {IfNode}
|
|
244
|
-
*/
|
|
245
|
-
parseIf(index) {
|
|
246
|
-
this.i += 4; // {#if
|
|
247
|
-
const closeBrace = this.source.indexOf("}", this.i);
|
|
248
|
-
if (closeBrace === -1) throw this.error("Unterminated {#if}", index);
|
|
249
|
-
const testSrc = this.source.slice(this.i, closeBrace).trim();
|
|
250
|
-
if (!testSrc) throw this.error("Empty {#if} condition", index);
|
|
251
|
-
const test = parseExpr(testSrc, 0, testSrc.length, {
|
|
252
|
-
file: this.file,
|
|
253
|
-
templateSource: this.source,
|
|
254
|
-
templateIndex: this.i,
|
|
255
|
-
});
|
|
256
|
-
this.i = closeBrace + 1;
|
|
257
|
-
|
|
258
|
-
this.blockStack.push("{/if}");
|
|
259
|
-
/** @type {AstNode[]} */
|
|
260
|
-
let consequent;
|
|
261
|
-
/** @type {AstNode[]} */
|
|
262
|
-
let alternate = [];
|
|
263
|
-
try {
|
|
264
|
-
consequent = this.parseNodesUntil(["{#else}", "{/if}"]);
|
|
265
|
-
if (this.startsWith("{#else}")) {
|
|
266
|
-
this.i += "{#else}".length;
|
|
267
|
-
alternate = this.parseNodesUntil(["{/if}"]);
|
|
268
|
-
}
|
|
269
|
-
} finally {
|
|
270
|
-
this.blockStack.pop();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (!this.startsWith("{/if}")) {
|
|
274
|
-
throw this.error("Expected {/if}", this.i);
|
|
275
|
-
}
|
|
276
|
-
this.i += "{/if}".length;
|
|
277
|
-
|
|
278
|
-
return { type: "If", test, consequent, alternate, index };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* @param {string[]} markers
|
|
283
|
-
* @returns {AstNode[]}
|
|
284
|
-
*/
|
|
285
|
-
parseNodesUntil(markers) {
|
|
286
|
-
/** @type {AstNode[]} */
|
|
287
|
-
const nodes = [];
|
|
288
|
-
while (!this.eof()) {
|
|
289
|
-
if (markers.some((m) => this.startsWith(m))) break;
|
|
290
|
-
|
|
291
|
-
if (this.startsWith("<!--")) {
|
|
292
|
-
this.skipComment();
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
if (this.startsWith("{#if") || this.startsWith("{#each") || this.startsWith("{#include")) {
|
|
296
|
-
nodes.push(this.parseDirective());
|
|
297
|
-
continue;
|
|
298
|
-
}
|
|
299
|
-
if (this.startsWith("{#")) {
|
|
300
|
-
// comment or unknown — parseDirective handles
|
|
301
|
-
const node = this.parseDirective();
|
|
302
|
-
if (node.type !== "Text" || node.value) nodes.push(node);
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
if (this.startsWith("{{")) {
|
|
306
|
-
nodes.push(this.parseInterp());
|
|
307
|
-
continue;
|
|
308
|
-
}
|
|
309
|
-
if (this.startsWith("</")) break;
|
|
310
|
-
if (this.startsWith("<!")) {
|
|
311
|
-
nodes.push(this.parseDeclaration());
|
|
312
|
-
continue;
|
|
313
|
-
}
|
|
314
|
-
if (this.peek() === "<" && /[A-Za-z]/.test(this.peek(1))) {
|
|
315
|
-
nodes.push(this.parseTag());
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
// Text until next special or marker
|
|
319
|
-
const start = this.i;
|
|
320
|
-
while (!this.eof()) {
|
|
321
|
-
if (markers.some((m) => this.startsWith(m))) break;
|
|
322
|
-
if (this.startsWith("{{") || this.startsWith("{#") || this.startsWith("<!--")) break;
|
|
323
|
-
if (this.peek() === "<") break;
|
|
324
|
-
this.i += 1;
|
|
325
|
-
}
|
|
326
|
-
if (this.i > start) {
|
|
327
|
-
nodes.push({ type: "Text", value: this.source.slice(start, this.i) });
|
|
328
|
-
} else if (!this.eof() && !markers.some((m) => this.startsWith(m))) {
|
|
329
|
-
// Stuck on unexpected char
|
|
330
|
-
throw this.error(`Unexpected character '${this.peek()}'`);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return nodes;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* @param {number} index
|
|
338
|
-
* @returns {EachNode}
|
|
339
|
-
*/
|
|
340
|
-
parseEach(index) {
|
|
341
|
-
this.i += 6; // {#each
|
|
342
|
-
const closeBrace = this.source.indexOf("}", this.i);
|
|
343
|
-
if (closeBrace === -1) throw this.error("Unterminated {#each}", index);
|
|
344
|
-
const head = this.source.slice(this.i, closeBrace).trim();
|
|
345
|
-
// `items as item` or `items as item, i`
|
|
346
|
-
const asMatch = head.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s*$/);
|
|
347
|
-
if (!asMatch) {
|
|
348
|
-
throw this.error(
|
|
349
|
-
'Invalid {#each}: expected `{#each items as item}` or `{#each items as item, i}`',
|
|
350
|
-
index,
|
|
351
|
-
);
|
|
352
|
-
}
|
|
353
|
-
const list = parseExpr(asMatch[1].trim(), 0, asMatch[1].trim().length, {
|
|
354
|
-
file: this.file,
|
|
355
|
-
templateSource: this.source,
|
|
356
|
-
templateIndex: this.i,
|
|
357
|
-
});
|
|
358
|
-
this.i = closeBrace + 1;
|
|
359
|
-
this.blockStack.push("{/each}");
|
|
360
|
-
/** @type {AstNode[]} */
|
|
361
|
-
let children;
|
|
362
|
-
try {
|
|
363
|
-
children = this.parseNodesUntil(["{/each}"]);
|
|
364
|
-
} finally {
|
|
365
|
-
this.blockStack.pop();
|
|
366
|
-
}
|
|
367
|
-
if (!this.startsWith("{/each}")) {
|
|
368
|
-
throw this.error("Expected {/each}", this.i);
|
|
369
|
-
}
|
|
370
|
-
this.i += "{/each}".length;
|
|
371
|
-
return {
|
|
372
|
-
type: "Each",
|
|
373
|
-
list,
|
|
374
|
-
item: asMatch[2],
|
|
375
|
-
indexName: asMatch[3] ?? null,
|
|
376
|
-
children,
|
|
377
|
-
index,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* @param {number} index
|
|
383
|
-
* @returns {IncludeNode}
|
|
384
|
-
*/
|
|
385
|
-
parseInclude(index) {
|
|
386
|
-
this.i += 9; // {#include
|
|
387
|
-
const closeBrace = this.source.indexOf("}", this.i);
|
|
388
|
-
if (closeBrace === -1) throw this.error("Unterminated {#include}", index);
|
|
389
|
-
const raw = this.source.slice(this.i, closeBrace).trim();
|
|
390
|
-
const strMatch = raw.match(/^["']([^"']+)["']$/);
|
|
391
|
-
if (!strMatch) {
|
|
392
|
-
throw this.error('{#include} path must be a string literal', index);
|
|
393
|
-
}
|
|
394
|
-
this.i = closeBrace + 1;
|
|
395
|
-
return { type: "Include", path: strMatch[1], index };
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** @returns {ElementNode | ComponentNode} */
|
|
399
|
-
parseTag() {
|
|
400
|
-
const index = this.i;
|
|
401
|
-
this.i += 1; // <
|
|
402
|
-
const nameStart = this.i;
|
|
403
|
-
while (!this.eof() && /[A-Za-z0-9_-]/.test(this.peek())) this.i += 1;
|
|
404
|
-
const name = this.source.slice(nameStart, this.i);
|
|
405
|
-
if (!name) throw this.error("Expected tag name", index);
|
|
406
|
-
|
|
407
|
-
const attrs = this.parseAttrs();
|
|
408
|
-
this.skipWs();
|
|
409
|
-
|
|
410
|
-
let selfClosing = false;
|
|
411
|
-
if (this.startsWith("/>")) {
|
|
412
|
-
selfClosing = true;
|
|
413
|
-
this.i += 2;
|
|
414
|
-
} else if (this.peek() === ">") {
|
|
415
|
-
this.i += 1;
|
|
416
|
-
} else {
|
|
417
|
-
throw this.error("Expected '>' or '/>'", this.i);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const component = isComponentTag(name);
|
|
421
|
-
const voidEl = !component && VOID.has(name.toLowerCase());
|
|
422
|
-
|
|
423
|
-
/** @type {AstNode[]} */
|
|
424
|
-
let children = [];
|
|
425
|
-
if (!selfClosing && !voidEl) {
|
|
426
|
-
children = this.parseNodesUntil([`</${name}>`]);
|
|
427
|
-
if (!this.startsWith(`</${name}>`)) {
|
|
428
|
-
if (this.eof() && this.blockStack.length) {
|
|
429
|
-
throw this.error(`Expected ${this.blockStack.at(-1)}`, this.i);
|
|
430
|
-
}
|
|
431
|
-
throw this.error(`Expected </${name}>`, this.i);
|
|
432
|
-
}
|
|
433
|
-
this.i += `</${name}>`.length;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (component) {
|
|
437
|
-
return { type: "Component", name, attrs, children, selfClosing, index };
|
|
438
|
-
}
|
|
439
|
-
return {
|
|
440
|
-
type: "Element",
|
|
441
|
-
name,
|
|
442
|
-
attrs,
|
|
443
|
-
children,
|
|
444
|
-
selfClosing: selfClosing || voidEl,
|
|
445
|
-
index,
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
skipWs() {
|
|
450
|
-
while (!this.eof() && /\s/.test(this.peek())) this.i += 1;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/** @returns {AttrNode[]} */
|
|
454
|
-
parseAttrs() {
|
|
455
|
-
/** @type {AttrNode[]} */
|
|
456
|
-
const attrs = [];
|
|
457
|
-
for (;;) {
|
|
458
|
-
this.skipWs();
|
|
459
|
-
if (this.peek() === ">" || this.startsWith("/>") || this.eof()) break;
|
|
460
|
-
|
|
461
|
-
const index = this.i;
|
|
462
|
-
let bound = false;
|
|
463
|
-
if (this.peek() === ":") {
|
|
464
|
-
bound = true;
|
|
465
|
-
this.i += 1;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
if (!/[A-Za-z_]/.test(this.peek())) {
|
|
469
|
-
throw this.error("Expected attribute name", this.i);
|
|
470
|
-
}
|
|
471
|
-
const nameStart = this.i;
|
|
472
|
-
while (!this.eof() && /[A-Za-z0-9_:-]/.test(this.peek())) this.i += 1;
|
|
473
|
-
let name = this.source.slice(nameStart, this.i);
|
|
474
|
-
// Strip leading colon already handled; name may include data-*
|
|
475
|
-
if (bound && name.startsWith(":")) name = name.slice(1);
|
|
476
|
-
|
|
477
|
-
this.skipWs();
|
|
478
|
-
if (this.peek() !== "=") {
|
|
479
|
-
attrs.push({
|
|
480
|
-
name,
|
|
481
|
-
value: null,
|
|
482
|
-
expr: null,
|
|
483
|
-
bound: false,
|
|
484
|
-
boolean: true,
|
|
485
|
-
index,
|
|
486
|
-
});
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
this.i += 1;
|
|
490
|
-
this.skipWs();
|
|
491
|
-
|
|
492
|
-
if (bound) {
|
|
493
|
-
// :name="expr" or :name='expr'
|
|
494
|
-
const quote = this.peek();
|
|
495
|
-
if (quote !== '"' && quote !== "'") {
|
|
496
|
-
throw this.error("Bound attribute value must be quoted", this.i);
|
|
497
|
-
}
|
|
498
|
-
this.i += 1;
|
|
499
|
-
const end = this.source.indexOf(quote, this.i);
|
|
500
|
-
if (end === -1) throw this.error("Unterminated attribute value", index);
|
|
501
|
-
const exprSrc = this.source.slice(this.i, end).trim();
|
|
502
|
-
const expr = parseExpr(exprSrc, 0, exprSrc.length, {
|
|
503
|
-
file: this.file,
|
|
504
|
-
templateSource: this.source,
|
|
505
|
-
templateIndex: this.i,
|
|
506
|
-
});
|
|
507
|
-
this.i = end + 1;
|
|
508
|
-
attrs.push({ name, value: null, expr, bound: true, boolean: false, index });
|
|
509
|
-
continue;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Literal attribute
|
|
513
|
-
const quote = this.peek();
|
|
514
|
-
if (quote === '"' || quote === "'") {
|
|
515
|
-
this.i += 1;
|
|
516
|
-
const end = this.source.indexOf(quote, this.i);
|
|
517
|
-
if (end === -1) throw this.error("Unterminated attribute value", index);
|
|
518
|
-
const value = this.source.slice(this.i, end);
|
|
519
|
-
this.i = end + 1;
|
|
520
|
-
// Allow {{ }} inside attribute values? Skip for v1 — use :bind
|
|
521
|
-
if (value.includes("{{")) {
|
|
522
|
-
throw this.error(
|
|
523
|
-
`Interpolation inside attribute "${name}" is not supported; use :${name}="..."`,
|
|
524
|
-
index,
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
attrs.push({ name, value, expr: null, bound: false, boolean: false, index });
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Unquoted
|
|
532
|
-
const vStart = this.i;
|
|
533
|
-
while (!this.eof() && !/[\s>]/.test(this.peek()) && !this.startsWith("/>")) {
|
|
534
|
-
this.i += 1;
|
|
535
|
-
}
|
|
536
|
-
const value = this.source.slice(vStart, this.i);
|
|
537
|
-
attrs.push({ name, value, expr: null, bound: false, boolean: false, index });
|
|
538
|
-
}
|
|
539
|
-
return attrs;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* `.jsk` şablon ayrıştırıcı. HTML'e yakın sözdizimi + blok direktifleri.
|
|
3
|
+
* İstek anında çalışmaz; yalnızca build-time.
|
|
4
|
+
*/
|
|
5
|
+
import { CompileError } from "./errors.js";
|
|
6
|
+
import { parseExpr } from "./expr.js";
|
|
7
|
+
|
|
8
|
+
/** Self-closing HTML void elements. */
|
|
9
|
+
const VOID = new Set([
|
|
10
|
+
"area",
|
|
11
|
+
"base",
|
|
12
|
+
"br",
|
|
13
|
+
"col",
|
|
14
|
+
"embed",
|
|
15
|
+
"hr",
|
|
16
|
+
"img",
|
|
17
|
+
"input",
|
|
18
|
+
"link",
|
|
19
|
+
"meta",
|
|
20
|
+
"param",
|
|
21
|
+
"source",
|
|
22
|
+
"track",
|
|
23
|
+
"wbr",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {import('./expr.js').ExprNode} ExprNode
|
|
28
|
+
* @typedef {{ type: 'Text', value: string }} TextNode
|
|
29
|
+
* @typedef {{ type: 'Interp', expr: ExprNode, raw: boolean, index: number }} InterpNode
|
|
30
|
+
* @typedef {{ name: string, value: string | null, expr: ExprNode | null, bound: boolean, boolean: boolean, index: number }} AttrNode
|
|
31
|
+
* @typedef {{ type: 'Element', name: string, attrs: AttrNode[], children: AstNode[], selfClosing: boolean, index: number }} ElementNode
|
|
32
|
+
* @typedef {{ type: 'Component', name: string, attrs: AttrNode[], children: AstNode[], selfClosing: boolean, index: number }} ComponentNode
|
|
33
|
+
* @typedef {{ type: 'If', test: ExprNode, consequent: AstNode[], alternate: AstNode[], index: number }} IfNode
|
|
34
|
+
* @typedef {{ type: 'Each', list: ExprNode, item: string, indexName: string | null, children: AstNode[], index: number }} EachNode
|
|
35
|
+
* @typedef {{ type: 'Include', path: string, index: number }} IncludeNode
|
|
36
|
+
* @typedef {TextNode | InterpNode | ElementNode | ComponentNode | IfNode | EachNode | IncludeNode} AstNode
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} source
|
|
41
|
+
* @param {{ file?: string }} [options]
|
|
42
|
+
* @returns {AstNode[]}
|
|
43
|
+
*/
|
|
44
|
+
export function parseTemplate(source, options = {}) {
|
|
45
|
+
const parser = new TemplateParser(source, options.file);
|
|
46
|
+
return parser.parseNodes();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* PascalCase bileşen mi?
|
|
51
|
+
* @param {string} name
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
export function isComponentTag(name) {
|
|
55
|
+
return /^[A-Z]/.test(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class TemplateParser {
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} source
|
|
61
|
+
* @param {string} [file]
|
|
62
|
+
*/
|
|
63
|
+
constructor(source, file) {
|
|
64
|
+
this.source = source;
|
|
65
|
+
this.file = file;
|
|
66
|
+
this.i = 0;
|
|
67
|
+
/** @type {string[]} Açık {#if}/{#each} kapanışları — EOF'ta öncelikli hata. */
|
|
68
|
+
this.blockStack = [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} message
|
|
73
|
+
* @param {number} [index]
|
|
74
|
+
* @returns {CompileError}
|
|
75
|
+
*/
|
|
76
|
+
error(message, index = this.i) {
|
|
77
|
+
return new CompileError(message, {
|
|
78
|
+
file: this.file,
|
|
79
|
+
source: this.source,
|
|
80
|
+
index,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
eof() {
|
|
85
|
+
return this.i >= this.source.length;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
peek(n = 0) {
|
|
89
|
+
return this.source[this.i + n] ?? "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
startsWith(value) {
|
|
93
|
+
return this.source.startsWith(value, this.i);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} [until]
|
|
98
|
+
* @returns {AstNode[]}
|
|
99
|
+
*/
|
|
100
|
+
parseNodes(until) {
|
|
101
|
+
/** @type {AstNode[]} */
|
|
102
|
+
const nodes = [];
|
|
103
|
+
while (!this.eof()) {
|
|
104
|
+
if (until && this.startsWith(until)) break;
|
|
105
|
+
|
|
106
|
+
if (this.startsWith("<!--")) {
|
|
107
|
+
this.skipComment();
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (this.startsWith("{#")) {
|
|
112
|
+
nodes.push(this.parseDirective());
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (this.startsWith("{{")) {
|
|
117
|
+
nodes.push(this.parseInterp());
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (this.startsWith("</")) {
|
|
122
|
+
// Parent closes — leave for caller.
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// `<!DOCTYPE …>` / `<![CDATA[…]]>` — etiket değil; metin olarak bas.
|
|
127
|
+
// İlerletmezsek `<` + `!` dalı parseText'te de takılıp sonsuz döngü olur.
|
|
128
|
+
if (this.startsWith("<!")) {
|
|
129
|
+
nodes.push(this.parseDeclaration());
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (this.peek() === "<" && /[A-Za-z]/.test(this.peek(1))) {
|
|
134
|
+
nodes.push(this.parseTag());
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
nodes.push(this.parseText(until));
|
|
139
|
+
}
|
|
140
|
+
return nodes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `<!DOCTYPE html>` gibi bildirimler. Kapanış `>` ilk tırnak dışı geçiş.
|
|
145
|
+
* @returns {TextNode}
|
|
146
|
+
*/
|
|
147
|
+
parseDeclaration() {
|
|
148
|
+
const start = this.i;
|
|
149
|
+
this.i += 2; // <!
|
|
150
|
+
let quote = null;
|
|
151
|
+
while (!this.eof()) {
|
|
152
|
+
const ch = this.peek();
|
|
153
|
+
if (quote) {
|
|
154
|
+
if (ch === quote) quote = null;
|
|
155
|
+
this.i += 1;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (ch === '"' || ch === "'") {
|
|
159
|
+
quote = ch;
|
|
160
|
+
this.i += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ch === ">") {
|
|
164
|
+
this.i += 1;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
this.i += 1;
|
|
168
|
+
}
|
|
169
|
+
return { type: "Text", value: this.source.slice(start, this.i) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
skipComment() {
|
|
173
|
+
const end = this.source.indexOf("-->", this.i + 4);
|
|
174
|
+
if (end === -1) throw this.error("Unterminated HTML comment");
|
|
175
|
+
this.i = end + 3;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {string} [until]
|
|
180
|
+
* @returns {TextNode}
|
|
181
|
+
*/
|
|
182
|
+
parseText(until) {
|
|
183
|
+
const start = this.i;
|
|
184
|
+
while (!this.eof()) {
|
|
185
|
+
if (this.startsWith("{{") || this.startsWith("{#") || this.startsWith("<!--")) break;
|
|
186
|
+
if (this.peek() === "<") break;
|
|
187
|
+
if (until && this.startsWith(until)) break;
|
|
188
|
+
this.i += 1;
|
|
189
|
+
}
|
|
190
|
+
return { type: "Text", value: this.source.slice(start, this.i) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** @returns {InterpNode} */
|
|
194
|
+
parseInterp() {
|
|
195
|
+
const index = this.i;
|
|
196
|
+
let raw = false;
|
|
197
|
+
if (this.startsWith("{{{")) {
|
|
198
|
+
raw = true;
|
|
199
|
+
this.i += 3;
|
|
200
|
+
} else {
|
|
201
|
+
this.i += 2;
|
|
202
|
+
}
|
|
203
|
+
const close = raw ? "}}}" : "}}";
|
|
204
|
+
const end = this.source.indexOf(close, this.i);
|
|
205
|
+
if (end === -1) throw this.error("Unterminated interpolation", index);
|
|
206
|
+
const exprSrc = this.source.slice(this.i, end).trim();
|
|
207
|
+
if (!exprSrc) throw this.error("Empty interpolation", index);
|
|
208
|
+
const expr = parseExpr(exprSrc, 0, exprSrc.length, {
|
|
209
|
+
file: this.file,
|
|
210
|
+
templateSource: this.source,
|
|
211
|
+
templateIndex: this.i,
|
|
212
|
+
});
|
|
213
|
+
this.i = end + close.length;
|
|
214
|
+
return { type: "Interp", expr, raw, index };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @returns {IfNode | EachNode | IncludeNode} */
|
|
218
|
+
parseDirective() {
|
|
219
|
+
const index = this.i;
|
|
220
|
+
if (this.startsWith("{#if")) {
|
|
221
|
+
return this.parseIf(index);
|
|
222
|
+
}
|
|
223
|
+
if (this.startsWith("{#each")) {
|
|
224
|
+
return this.parseEach(index);
|
|
225
|
+
}
|
|
226
|
+
if (this.startsWith("{#include")) {
|
|
227
|
+
return this.parseInclude(index);
|
|
228
|
+
}
|
|
229
|
+
// `{# comment #}` — kapanış `#}` zorunlu; yoksa bilinmeyen direktif.
|
|
230
|
+
if (this.startsWith("{#")) {
|
|
231
|
+
const end = this.source.indexOf("#}", this.i + 2);
|
|
232
|
+
if (end === -1) {
|
|
233
|
+
throw this.error("Unknown directive", index);
|
|
234
|
+
}
|
|
235
|
+
this.i = end + 2;
|
|
236
|
+
return { type: "Text", value: "" };
|
|
237
|
+
}
|
|
238
|
+
throw this.error("Unknown directive", index);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {number} index
|
|
243
|
+
* @returns {IfNode}
|
|
244
|
+
*/
|
|
245
|
+
parseIf(index) {
|
|
246
|
+
this.i += 4; // {#if
|
|
247
|
+
const closeBrace = this.source.indexOf("}", this.i);
|
|
248
|
+
if (closeBrace === -1) throw this.error("Unterminated {#if}", index);
|
|
249
|
+
const testSrc = this.source.slice(this.i, closeBrace).trim();
|
|
250
|
+
if (!testSrc) throw this.error("Empty {#if} condition", index);
|
|
251
|
+
const test = parseExpr(testSrc, 0, testSrc.length, {
|
|
252
|
+
file: this.file,
|
|
253
|
+
templateSource: this.source,
|
|
254
|
+
templateIndex: this.i,
|
|
255
|
+
});
|
|
256
|
+
this.i = closeBrace + 1;
|
|
257
|
+
|
|
258
|
+
this.blockStack.push("{/if}");
|
|
259
|
+
/** @type {AstNode[]} */
|
|
260
|
+
let consequent;
|
|
261
|
+
/** @type {AstNode[]} */
|
|
262
|
+
let alternate = [];
|
|
263
|
+
try {
|
|
264
|
+
consequent = this.parseNodesUntil(["{#else}", "{/if}"]);
|
|
265
|
+
if (this.startsWith("{#else}")) {
|
|
266
|
+
this.i += "{#else}".length;
|
|
267
|
+
alternate = this.parseNodesUntil(["{/if}"]);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
this.blockStack.pop();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!this.startsWith("{/if}")) {
|
|
274
|
+
throw this.error("Expected {/if}", this.i);
|
|
275
|
+
}
|
|
276
|
+
this.i += "{/if}".length;
|
|
277
|
+
|
|
278
|
+
return { type: "If", test, consequent, alternate, index };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @param {string[]} markers
|
|
283
|
+
* @returns {AstNode[]}
|
|
284
|
+
*/
|
|
285
|
+
parseNodesUntil(markers) {
|
|
286
|
+
/** @type {AstNode[]} */
|
|
287
|
+
const nodes = [];
|
|
288
|
+
while (!this.eof()) {
|
|
289
|
+
if (markers.some((m) => this.startsWith(m))) break;
|
|
290
|
+
|
|
291
|
+
if (this.startsWith("<!--")) {
|
|
292
|
+
this.skipComment();
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (this.startsWith("{#if") || this.startsWith("{#each") || this.startsWith("{#include")) {
|
|
296
|
+
nodes.push(this.parseDirective());
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (this.startsWith("{#")) {
|
|
300
|
+
// comment or unknown — parseDirective handles
|
|
301
|
+
const node = this.parseDirective();
|
|
302
|
+
if (node.type !== "Text" || node.value) nodes.push(node);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (this.startsWith("{{")) {
|
|
306
|
+
nodes.push(this.parseInterp());
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (this.startsWith("</")) break;
|
|
310
|
+
if (this.startsWith("<!")) {
|
|
311
|
+
nodes.push(this.parseDeclaration());
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (this.peek() === "<" && /[A-Za-z]/.test(this.peek(1))) {
|
|
315
|
+
nodes.push(this.parseTag());
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// Text until next special or marker
|
|
319
|
+
const start = this.i;
|
|
320
|
+
while (!this.eof()) {
|
|
321
|
+
if (markers.some((m) => this.startsWith(m))) break;
|
|
322
|
+
if (this.startsWith("{{") || this.startsWith("{#") || this.startsWith("<!--")) break;
|
|
323
|
+
if (this.peek() === "<") break;
|
|
324
|
+
this.i += 1;
|
|
325
|
+
}
|
|
326
|
+
if (this.i > start) {
|
|
327
|
+
nodes.push({ type: "Text", value: this.source.slice(start, this.i) });
|
|
328
|
+
} else if (!this.eof() && !markers.some((m) => this.startsWith(m))) {
|
|
329
|
+
// Stuck on unexpected char
|
|
330
|
+
throw this.error(`Unexpected character '${this.peek()}'`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return nodes;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* @param {number} index
|
|
338
|
+
* @returns {EachNode}
|
|
339
|
+
*/
|
|
340
|
+
parseEach(index) {
|
|
341
|
+
this.i += 6; // {#each
|
|
342
|
+
const closeBrace = this.source.indexOf("}", this.i);
|
|
343
|
+
if (closeBrace === -1) throw this.error("Unterminated {#each}", index);
|
|
344
|
+
const head = this.source.slice(this.i, closeBrace).trim();
|
|
345
|
+
// `items as item` or `items as item, i`
|
|
346
|
+
const asMatch = head.match(/^(.+?)\s+as\s+([A-Za-z_$][\w$]*)\s*(?:,\s*([A-Za-z_$][\w$]*))?\s*$/);
|
|
347
|
+
if (!asMatch) {
|
|
348
|
+
throw this.error(
|
|
349
|
+
'Invalid {#each}: expected `{#each items as item}` or `{#each items as item, i}`',
|
|
350
|
+
index,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const list = parseExpr(asMatch[1].trim(), 0, asMatch[1].trim().length, {
|
|
354
|
+
file: this.file,
|
|
355
|
+
templateSource: this.source,
|
|
356
|
+
templateIndex: this.i,
|
|
357
|
+
});
|
|
358
|
+
this.i = closeBrace + 1;
|
|
359
|
+
this.blockStack.push("{/each}");
|
|
360
|
+
/** @type {AstNode[]} */
|
|
361
|
+
let children;
|
|
362
|
+
try {
|
|
363
|
+
children = this.parseNodesUntil(["{/each}"]);
|
|
364
|
+
} finally {
|
|
365
|
+
this.blockStack.pop();
|
|
366
|
+
}
|
|
367
|
+
if (!this.startsWith("{/each}")) {
|
|
368
|
+
throw this.error("Expected {/each}", this.i);
|
|
369
|
+
}
|
|
370
|
+
this.i += "{/each}".length;
|
|
371
|
+
return {
|
|
372
|
+
type: "Each",
|
|
373
|
+
list,
|
|
374
|
+
item: asMatch[2],
|
|
375
|
+
indexName: asMatch[3] ?? null,
|
|
376
|
+
children,
|
|
377
|
+
index,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* @param {number} index
|
|
383
|
+
* @returns {IncludeNode}
|
|
384
|
+
*/
|
|
385
|
+
parseInclude(index) {
|
|
386
|
+
this.i += 9; // {#include
|
|
387
|
+
const closeBrace = this.source.indexOf("}", this.i);
|
|
388
|
+
if (closeBrace === -1) throw this.error("Unterminated {#include}", index);
|
|
389
|
+
const raw = this.source.slice(this.i, closeBrace).trim();
|
|
390
|
+
const strMatch = raw.match(/^["']([^"']+)["']$/);
|
|
391
|
+
if (!strMatch) {
|
|
392
|
+
throw this.error('{#include} path must be a string literal', index);
|
|
393
|
+
}
|
|
394
|
+
this.i = closeBrace + 1;
|
|
395
|
+
return { type: "Include", path: strMatch[1], index };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** @returns {ElementNode | ComponentNode} */
|
|
399
|
+
parseTag() {
|
|
400
|
+
const index = this.i;
|
|
401
|
+
this.i += 1; // <
|
|
402
|
+
const nameStart = this.i;
|
|
403
|
+
while (!this.eof() && /[A-Za-z0-9_-]/.test(this.peek())) this.i += 1;
|
|
404
|
+
const name = this.source.slice(nameStart, this.i);
|
|
405
|
+
if (!name) throw this.error("Expected tag name", index);
|
|
406
|
+
|
|
407
|
+
const attrs = this.parseAttrs();
|
|
408
|
+
this.skipWs();
|
|
409
|
+
|
|
410
|
+
let selfClosing = false;
|
|
411
|
+
if (this.startsWith("/>")) {
|
|
412
|
+
selfClosing = true;
|
|
413
|
+
this.i += 2;
|
|
414
|
+
} else if (this.peek() === ">") {
|
|
415
|
+
this.i += 1;
|
|
416
|
+
} else {
|
|
417
|
+
throw this.error("Expected '>' or '/>'", this.i);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const component = isComponentTag(name);
|
|
421
|
+
const voidEl = !component && VOID.has(name.toLowerCase());
|
|
422
|
+
|
|
423
|
+
/** @type {AstNode[]} */
|
|
424
|
+
let children = [];
|
|
425
|
+
if (!selfClosing && !voidEl) {
|
|
426
|
+
children = this.parseNodesUntil([`</${name}>`]);
|
|
427
|
+
if (!this.startsWith(`</${name}>`)) {
|
|
428
|
+
if (this.eof() && this.blockStack.length) {
|
|
429
|
+
throw this.error(`Expected ${this.blockStack.at(-1)}`, this.i);
|
|
430
|
+
}
|
|
431
|
+
throw this.error(`Expected </${name}>`, this.i);
|
|
432
|
+
}
|
|
433
|
+
this.i += `</${name}>`.length;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (component) {
|
|
437
|
+
return { type: "Component", name, attrs, children, selfClosing, index };
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
type: "Element",
|
|
441
|
+
name,
|
|
442
|
+
attrs,
|
|
443
|
+
children,
|
|
444
|
+
selfClosing: selfClosing || voidEl,
|
|
445
|
+
index,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
skipWs() {
|
|
450
|
+
while (!this.eof() && /\s/.test(this.peek())) this.i += 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** @returns {AttrNode[]} */
|
|
454
|
+
parseAttrs() {
|
|
455
|
+
/** @type {AttrNode[]} */
|
|
456
|
+
const attrs = [];
|
|
457
|
+
for (;;) {
|
|
458
|
+
this.skipWs();
|
|
459
|
+
if (this.peek() === ">" || this.startsWith("/>") || this.eof()) break;
|
|
460
|
+
|
|
461
|
+
const index = this.i;
|
|
462
|
+
let bound = false;
|
|
463
|
+
if (this.peek() === ":") {
|
|
464
|
+
bound = true;
|
|
465
|
+
this.i += 1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!/[A-Za-z_]/.test(this.peek())) {
|
|
469
|
+
throw this.error("Expected attribute name", this.i);
|
|
470
|
+
}
|
|
471
|
+
const nameStart = this.i;
|
|
472
|
+
while (!this.eof() && /[A-Za-z0-9_:-]/.test(this.peek())) this.i += 1;
|
|
473
|
+
let name = this.source.slice(nameStart, this.i);
|
|
474
|
+
// Strip leading colon already handled; name may include data-*
|
|
475
|
+
if (bound && name.startsWith(":")) name = name.slice(1);
|
|
476
|
+
|
|
477
|
+
this.skipWs();
|
|
478
|
+
if (this.peek() !== "=") {
|
|
479
|
+
attrs.push({
|
|
480
|
+
name,
|
|
481
|
+
value: null,
|
|
482
|
+
expr: null,
|
|
483
|
+
bound: false,
|
|
484
|
+
boolean: true,
|
|
485
|
+
index,
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
this.i += 1;
|
|
490
|
+
this.skipWs();
|
|
491
|
+
|
|
492
|
+
if (bound) {
|
|
493
|
+
// :name="expr" or :name='expr'
|
|
494
|
+
const quote = this.peek();
|
|
495
|
+
if (quote !== '"' && quote !== "'") {
|
|
496
|
+
throw this.error("Bound attribute value must be quoted", this.i);
|
|
497
|
+
}
|
|
498
|
+
this.i += 1;
|
|
499
|
+
const end = this.source.indexOf(quote, this.i);
|
|
500
|
+
if (end === -1) throw this.error("Unterminated attribute value", index);
|
|
501
|
+
const exprSrc = this.source.slice(this.i, end).trim();
|
|
502
|
+
const expr = parseExpr(exprSrc, 0, exprSrc.length, {
|
|
503
|
+
file: this.file,
|
|
504
|
+
templateSource: this.source,
|
|
505
|
+
templateIndex: this.i,
|
|
506
|
+
});
|
|
507
|
+
this.i = end + 1;
|
|
508
|
+
attrs.push({ name, value: null, expr, bound: true, boolean: false, index });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Literal attribute
|
|
513
|
+
const quote = this.peek();
|
|
514
|
+
if (quote === '"' || quote === "'") {
|
|
515
|
+
this.i += 1;
|
|
516
|
+
const end = this.source.indexOf(quote, this.i);
|
|
517
|
+
if (end === -1) throw this.error("Unterminated attribute value", index);
|
|
518
|
+
const value = this.source.slice(this.i, end);
|
|
519
|
+
this.i = end + 1;
|
|
520
|
+
// Allow {{ }} inside attribute values? Skip for v1 — use :bind
|
|
521
|
+
if (value.includes("{{")) {
|
|
522
|
+
throw this.error(
|
|
523
|
+
`Interpolation inside attribute "${name}" is not supported; use :${name}="..."`,
|
|
524
|
+
index,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
attrs.push({ name, value, expr: null, bound: false, boolean: false, index });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Unquoted
|
|
532
|
+
const vStart = this.i;
|
|
533
|
+
while (!this.eof() && !/[\s>]/.test(this.peek()) && !this.startsWith("/>")) {
|
|
534
|
+
this.i += 1;
|
|
535
|
+
}
|
|
536
|
+
const value = this.source.slice(vStart, this.i);
|
|
537
|
+
attrs.push({ name, value, expr: null, bound: false, boolean: false, index });
|
|
538
|
+
}
|
|
539
|
+
return attrs;
|
|
540
|
+
}
|
|
541
|
+
}
|