jskelet 0.3.4 → 0.4.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/CHANGELOG.md +22 -6
- package/bin/jskelet.mjs +20 -7
- package/docs/02-mimari.md +3 -0
- package/docs/03-routing.md +19 -3
- package/docs/04-render-ve-sablonlar.md +60 -6
- package/docs/07-yapilandirma.md +17 -13
- package/docs/08-build.md +5 -0
- package/docs/en/02-architecture.md +3 -0
- package/docs/en/03-routing.md +19 -4
- package/docs/en/04-rendering.md +61 -9
- package/docs/en/07-configuration.md +14 -10
- package/docs/en/08-build.md +5 -0
- package/package.json +2 -2
- package/src/build/build.mjs +6 -0
- package/src/build/ensure-build.mjs +5 -1
- package/src/build/tasks/icons.mjs +2 -2
- package/src/build/tasks/templates.mjs +20 -0
- package/src/compile/codegen.js +332 -0
- package/src/compile/compile-all.js +158 -0
- package/src/compile/errors.js +66 -0
- package/src/compile/expr.js +404 -0
- package/src/compile/index.js +15 -0
- package/src/compile/parse.js +485 -0
- package/src/compile/resolve.js +159 -0
- package/src/config/defaults.js +7 -3
- package/src/config/index.js +71 -13
- package/src/dev-server.mjs +4 -2
- package/src/generate.mjs +163 -0
- package/src/init.mjs +8 -6
- package/src/server/logs/pipeline.js +6 -22
- package/src/server/render.js +153 -40
- package/src/server/router.js +26 -3
- package/src/views/components/loader.js +26 -13
package/src/config/index.js
CHANGED
|
@@ -430,6 +430,48 @@ export function splitS3BucketPath(value) {
|
|
|
430
430
|
};
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* @param {unknown} value
|
|
435
|
+
* @returns {string | null}
|
|
436
|
+
*/
|
|
437
|
+
function envText(value) {
|
|
438
|
+
return typeof value === "string" && value ? value : null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* @returns {{ accessKeyId: string, secretAccessKey: string,
|
|
443
|
+
* sessionToken: string | null } | null}
|
|
444
|
+
*/
|
|
445
|
+
export function readS3CredentialsFromEnv() {
|
|
446
|
+
const accessKeyId = envText(process.env.JSKELET_S3_ACCESS_KEY_ID);
|
|
447
|
+
const secretAccessKey =
|
|
448
|
+
envText(process.env.JSKELET_S3_SECRET_ACCESS_KEY) ??
|
|
449
|
+
envText(process.env.JSKELET_S3_ACCESS_SECRET);
|
|
450
|
+
if (!accessKeyId || !secretAccessKey) return null;
|
|
451
|
+
return {
|
|
452
|
+
accessKeyId,
|
|
453
|
+
secretAccessKey,
|
|
454
|
+
sessionToken: envText(process.env.JSKELET_S3_SESSION_TOKEN),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Log hedefi. Öncelik: `JSKELET_LOG_BUCKET` → `JSKELET_S3_BUCKET`
|
|
460
|
+
* (+ isteğe bağlı `JSKELET_S3_KEY_PREFIX`).
|
|
461
|
+
*
|
|
462
|
+
* @returns {string | null}
|
|
463
|
+
*/
|
|
464
|
+
function resolveLogBucketEnv() {
|
|
465
|
+
const logPath = envText(process.env.JSKELET_LOG_BUCKET);
|
|
466
|
+
if (logPath) return logPath;
|
|
467
|
+
|
|
468
|
+
const bucket = envText(process.env.JSKELET_S3_BUCKET);
|
|
469
|
+
if (!bucket) return null;
|
|
470
|
+
|
|
471
|
+
const prefix = envText(process.env.JSKELET_S3_KEY_PREFIX);
|
|
472
|
+
return prefix ? `${bucket}/${prefix}` : bucket;
|
|
473
|
+
}
|
|
474
|
+
|
|
433
475
|
/**
|
|
434
476
|
* Kalıcı log sink'leri. Bozuk bir `kinds` listesi siteyi düşürmemeli —
|
|
435
477
|
* bilinmeyen girdiler atılır; hiç geçerli tür kalmazsa varsayılana dönülür.
|
|
@@ -461,13 +503,8 @@ export function normalizeLogs(raw) {
|
|
|
461
503
|
const flush = Number(s3Raw.flushIntervalMs);
|
|
462
504
|
const batch = Number(s3Raw.maxBatch);
|
|
463
505
|
|
|
464
|
-
/** @param {unknown} value */
|
|
465
|
-
const text = (value) => (typeof value === "string" && value ? value : null);
|
|
466
|
-
|
|
467
506
|
const bucketPath = splitS3BucketPath(
|
|
468
|
-
|
|
469
|
-
text(process.env.JSKELET_S3_BUCKET) ??
|
|
470
|
-
text(s3Raw.bucket),
|
|
507
|
+
resolveLogBucketEnv() ?? envText(s3Raw.bucket),
|
|
471
508
|
);
|
|
472
509
|
|
|
473
510
|
const configPrefix =
|
|
@@ -478,14 +515,28 @@ export function normalizeLogs(raw) {
|
|
|
478
515
|
: DEFAULT_LOGS.s3.prefix;
|
|
479
516
|
|
|
480
517
|
const endpoint =
|
|
481
|
-
|
|
518
|
+
envText(process.env.JSKELET_S3_API_URL) ?? envText(s3Raw.endpoint);
|
|
482
519
|
|
|
483
|
-
//
|
|
484
|
-
//
|
|
520
|
+
// Uyumlu API'lerde (Cloudflare R2 vb.) imza bölgesi çoğu zaman `auto`.
|
|
521
|
+
// Region hiçbir kurulumda zorunlu değil.
|
|
485
522
|
const region =
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
523
|
+
envText(s3Raw.region) ??
|
|
524
|
+
envText(process.env.JSKELET_S3_REGION) ??
|
|
525
|
+
"auto";
|
|
526
|
+
|
|
527
|
+
const credentials = readS3CredentialsFromEnv();
|
|
528
|
+
// `JSKELET_LOG_BUCKET` (veya S3 bucket) + credential varsa config'te
|
|
529
|
+
// `enabled: true` unutulmuş olsa bile aç. Açık `enabled: false` ezer.
|
|
530
|
+
const envWantsLogs = Boolean(
|
|
531
|
+
envText(process.env.JSKELET_LOG_BUCKET) ||
|
|
532
|
+
envText(process.env.JSKELET_S3_BUCKET),
|
|
533
|
+
);
|
|
534
|
+
const enabled =
|
|
535
|
+
s3Raw.enabled === true ||
|
|
536
|
+
(s3Raw.enabled !== false &&
|
|
537
|
+
envWantsLogs &&
|
|
538
|
+
Boolean(credentials) &&
|
|
539
|
+
Boolean(bucketPath.bucket));
|
|
489
540
|
|
|
490
541
|
return {
|
|
491
542
|
console: source.console !== false,
|
|
@@ -499,7 +550,7 @@ export function normalizeLogs(raw) {
|
|
|
499
550
|
rotate: "daily",
|
|
500
551
|
},
|
|
501
552
|
s3: {
|
|
502
|
-
enabled
|
|
553
|
+
enabled,
|
|
503
554
|
bucket: bucketPath.bucket,
|
|
504
555
|
// Yoldaki önek tek env ile klasör vermeyi mümkün kılar; yoksa config.
|
|
505
556
|
prefix: bucketPath.prefix ?? configPrefix,
|
|
@@ -750,6 +801,10 @@ function resolveDirs(root, overrides) {
|
|
|
750
801
|
* Uygulamanın layout'u yoksa framework'ün minimal layout'u kullanılır. Bu
|
|
751
802
|
* sayede yeni bir proje tek bir route ile çalışır hâle gelir.
|
|
752
803
|
*
|
|
804
|
+
* Öncelik: config `layout` → `layout.jsk` (derlenmiş) → `layout.ejs` →
|
|
805
|
+
* framework varsayılanı. Dönüş değeri kaynak dosya yoludur; `.jsk` için
|
|
806
|
+
* render katmanı derlenmiş modülü kullanır.
|
|
807
|
+
*
|
|
753
808
|
* @param {Record<string, string>} dirs
|
|
754
809
|
* @param {string} [override]
|
|
755
810
|
* @returns {string}
|
|
@@ -757,6 +812,9 @@ function resolveDirs(root, overrides) {
|
|
|
757
812
|
function resolveLayout(dirs, override) {
|
|
758
813
|
if (override) return path.resolve(dirs.views, "..", override);
|
|
759
814
|
|
|
815
|
+
const jskLayout = path.join(dirs.views, "layout.jsk");
|
|
816
|
+
if (fs.existsSync(jskLayout)) return jskLayout;
|
|
817
|
+
|
|
760
818
|
const appLayout = path.join(dirs.views, "layout.ejs");
|
|
761
819
|
if (fs.existsSync(appLayout)) return appLayout;
|
|
762
820
|
|
package/src/dev-server.mjs
CHANGED
|
@@ -236,11 +236,13 @@ function run(args, label, passthrough) {
|
|
|
236
236
|
const WATCH_DIRS = [
|
|
237
237
|
config.dirs.routes,
|
|
238
238
|
config.dirs.views,
|
|
239
|
+
config.dirs.features,
|
|
240
|
+
config.dirs.shared,
|
|
239
241
|
path.join(ROOT, "lib"),
|
|
240
242
|
...(config.watch ?? []).map((dir) => path.resolve(ROOT, dir)),
|
|
241
|
-
];
|
|
243
|
+
].filter(Boolean);
|
|
242
244
|
|
|
243
|
-
const WATCH_EXTENSIONS = /\.(?:js|mjs|json|ejs)$/;
|
|
245
|
+
const WATCH_EXTENSIONS = /\.(?:js|mjs|json|ejs|jsk)$/;
|
|
244
246
|
|
|
245
247
|
/** @type {import('node:child_process').ChildProcess | null} */
|
|
246
248
|
let server = null;
|
package/src/generate.mjs
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `jskelet generate` — feature / page / island iskeleti.
|
|
3
|
+
*
|
|
4
|
+
* Var olan dosyaların üzerine yazmaz. Route kaydı hâlâ açık `register`
|
|
5
|
+
* sözleşmesine bağlıdır; filesystem URL türetme yoktur.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import process from "node:process";
|
|
10
|
+
import * as log from "./log.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} name
|
|
14
|
+
* @returns {string}
|
|
15
|
+
*/
|
|
16
|
+
function toPascal(name) {
|
|
17
|
+
return name
|
|
18
|
+
.split(/[-_/]+/)
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
21
|
+
.join("");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} file
|
|
26
|
+
* @param {string} contents
|
|
27
|
+
* @returns {boolean} yazıldı mı
|
|
28
|
+
*/
|
|
29
|
+
function writeIfMissing(file, contents) {
|
|
30
|
+
if (fs.existsSync(file)) {
|
|
31
|
+
log.line(`skip ${path.relative(process.cwd(), file)} (exists)`);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
35
|
+
fs.writeFileSync(file, contents, "utf8");
|
|
36
|
+
log.line(`create ${path.relative(process.cwd(), file)}`);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} root
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
export async function generate(root, args) {
|
|
46
|
+
const [kind, target] = args;
|
|
47
|
+
if (!kind || !target) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
"usage: jskelet generate <feature|page|island> <name>\n" +
|
|
50
|
+
" feature features/<name>/{index.js,server,views,client}\n" +
|
|
51
|
+
" page features/<feature>/views/pages/<name>.jsk (name: feature/page)\n" +
|
|
52
|
+
" island features/<feature>/client/<name>.js (name: feature/island)",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (kind === "feature") {
|
|
57
|
+
await generateFeature(root, target);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (kind === "page") {
|
|
61
|
+
const [feature, page] = splitTarget(target);
|
|
62
|
+
await generatePage(root, feature, page);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (kind === "island") {
|
|
66
|
+
const [feature, island] = splitTarget(target);
|
|
67
|
+
await generateIsland(root, feature, island);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
throw new Error(`unknown generate kind: ${kind}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {string} target
|
|
76
|
+
* @returns {[string, string]}
|
|
77
|
+
*/
|
|
78
|
+
function splitTarget(target) {
|
|
79
|
+
const parts = target.split("/").filter(Boolean);
|
|
80
|
+
if (parts.length < 2) {
|
|
81
|
+
throw new Error(`expected <feature>/<name>, got "${target}"`);
|
|
82
|
+
}
|
|
83
|
+
return [parts[0], parts.slice(1).join("/")];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string} root
|
|
88
|
+
* @param {string} name
|
|
89
|
+
*/
|
|
90
|
+
async function generateFeature(root, name) {
|
|
91
|
+
const base = path.join(root, "features", name);
|
|
92
|
+
writeIfMissing(
|
|
93
|
+
path.join(base, "index.js"),
|
|
94
|
+
`/**
|
|
95
|
+
* Feature route registration. Explicit paths only — no filesystem URL routing.
|
|
96
|
+
* @param {import('express').Express} app
|
|
97
|
+
* @param {{ route: Function }} api
|
|
98
|
+
*/
|
|
99
|
+
export default function register(app, { route }) {
|
|
100
|
+
app.get(
|
|
101
|
+
"/${name}",
|
|
102
|
+
route(async () => ({
|
|
103
|
+
view: "pages/${name}",
|
|
104
|
+
metadata: { title: "${toPascal(name)}" },
|
|
105
|
+
data: {},
|
|
106
|
+
})),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
`,
|
|
110
|
+
);
|
|
111
|
+
writeIfMissing(path.join(base, "server", ".gitkeep"), "");
|
|
112
|
+
writeIfMissing(
|
|
113
|
+
path.join(base, "views", "pages", `${name}.jsk`),
|
|
114
|
+
`<section class="wrapper">
|
|
115
|
+
<h1>{{ metadata.title }}</h1>
|
|
116
|
+
</section>
|
|
117
|
+
`,
|
|
118
|
+
);
|
|
119
|
+
writeIfMissing(path.join(base, "client", ".gitkeep"), "");
|
|
120
|
+
log.ready({ label: `Feature ${name}` });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} root
|
|
125
|
+
* @param {string} feature
|
|
126
|
+
* @param {string} page
|
|
127
|
+
*/
|
|
128
|
+
async function generatePage(root, feature, page) {
|
|
129
|
+
const file = path.join(root, "features", feature, "views", "pages", `${page}.jsk`);
|
|
130
|
+
writeIfMissing(
|
|
131
|
+
file,
|
|
132
|
+
`<section class="wrapper">
|
|
133
|
+
<h1>{{ metadata.title }}</h1>
|
|
134
|
+
</section>
|
|
135
|
+
`,
|
|
136
|
+
);
|
|
137
|
+
log.ready({ label: `Page ${feature}/${page}` });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {string} root
|
|
142
|
+
* @param {string} feature
|
|
143
|
+
* @param {string} island
|
|
144
|
+
*/
|
|
145
|
+
async function generateIsland(root, feature, island) {
|
|
146
|
+
const file = path.join(root, "features", feature, "client", `${island}.js`);
|
|
147
|
+
writeIfMissing(
|
|
148
|
+
file,
|
|
149
|
+
`/**
|
|
150
|
+
* @param {HTMLElement} element
|
|
151
|
+
* @param {Record<string, unknown>} props
|
|
152
|
+
* @returns {void | (() => void)}
|
|
153
|
+
*/
|
|
154
|
+
export function mount(element, props) {
|
|
155
|
+
element.textContent = String(props.label ?? "${island}");
|
|
156
|
+
}
|
|
157
|
+
`,
|
|
158
|
+
);
|
|
159
|
+
log.line(
|
|
160
|
+
`Register in client/entries/main.js: "${island}": () => import("../../features/${feature}/client/${island}.js")`,
|
|
161
|
+
);
|
|
162
|
+
log.ready({ label: `Island ${feature}/${island}` });
|
|
163
|
+
}
|
package/src/init.mjs
CHANGED
|
@@ -78,17 +78,17 @@ export default function register(app, { route }) {
|
|
|
78
78
|
}
|
|
79
79
|
`,
|
|
80
80
|
|
|
81
|
-
"views/pages/home.
|
|
82
|
-
<h1
|
|
83
|
-
<p
|
|
81
|
+
"views/pages/home.jsk": `<section class="wrapper">
|
|
82
|
+
<h1>{{ metadata.title }}</h1>
|
|
83
|
+
<p>{{ message }}</p>
|
|
84
84
|
<div data-island="counter" data-island-props='{"start":0}'></div>
|
|
85
85
|
</section>
|
|
86
86
|
`,
|
|
87
87
|
|
|
88
|
-
"views/pages/not-found.
|
|
88
|
+
"views/pages/not-found.jsk": `<section class="wrapper">
|
|
89
89
|
<h1>404</h1>
|
|
90
90
|
<p>The page you are looking for was not found.</p>
|
|
91
|
-
<p><
|
|
91
|
+
<p><Link href="/" text="Back to home" /></p>
|
|
92
92
|
</section>
|
|
93
93
|
`,
|
|
94
94
|
|
|
@@ -96,7 +96,7 @@ export default function register(app, { route }) {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Every named export under \`views/components/**\` is usable directly in
|
|
99
|
-
* templates: \`<%- button({ text
|
|
99
|
+
* templates: \`<Button text="Save" />\` in \`.jsk\` or \`<%- button({ text }) %>\` in EJS.
|
|
100
100
|
*
|
|
101
101
|
* @param {{ text: string, href?: string, class?: string }} props
|
|
102
102
|
* @returns {string}
|
|
@@ -157,6 +157,8 @@ export function mount(element, props) {
|
|
|
157
157
|
@source "../views";
|
|
158
158
|
@source "../client";
|
|
159
159
|
@source "../routes";
|
|
160
|
+
@source "../features";
|
|
161
|
+
@source "../shared";
|
|
160
162
|
|
|
161
163
|
body {
|
|
162
164
|
margin: 0;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import process from "node:process";
|
|
8
8
|
import * as log from "../../log.mjs";
|
|
9
|
+
import { readS3CredentialsFromEnv } from "../../config/index.js";
|
|
9
10
|
import { createFileSink } from "./file-sink.js";
|
|
10
11
|
import { createS3Sink } from "./s3-sink.js";
|
|
11
12
|
|
|
@@ -23,20 +24,6 @@ let sinks = [];
|
|
|
23
24
|
let kindSet = new Set();
|
|
24
25
|
let accessMounted = false;
|
|
25
26
|
|
|
26
|
-
/**
|
|
27
|
-
* @returns {import('./s3-put.js').AwsCredentials | null}
|
|
28
|
-
*/
|
|
29
|
-
function readCredentials() {
|
|
30
|
-
const accessKeyId = process.env.JSKELET_S3_ACCESS_KEY_ID;
|
|
31
|
-
const secretAccessKey = process.env.JSKELET_S3_SECRET_ACCESS_KEY;
|
|
32
|
-
if (!accessKeyId || !secretAccessKey) return null;
|
|
33
|
-
return {
|
|
34
|
-
accessKeyId,
|
|
35
|
-
secretAccessKey,
|
|
36
|
-
sessionToken: process.env.JSKELET_S3_SESSION_TOKEN || null,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
27
|
/**
|
|
41
28
|
* Access middleware mount edilmeli mi?
|
|
42
29
|
*
|
|
@@ -79,25 +66,22 @@ export async function configureLogs(config) {
|
|
|
79
66
|
}
|
|
80
67
|
|
|
81
68
|
if (logs.s3.enabled) {
|
|
82
|
-
const credentials =
|
|
69
|
+
const credentials = readS3CredentialsFromEnv();
|
|
83
70
|
if (!logs.s3.bucket) {
|
|
84
71
|
console.warn(
|
|
85
|
-
"[logs] s3.enabled but no bucket (set JSKELET_LOG_BUCKET or logs.s3.bucket)",
|
|
86
|
-
);
|
|
87
|
-
} else if (!logs.s3.region) {
|
|
88
|
-
console.warn(
|
|
89
|
-
"[logs] s3.enabled but no region (set JSKELET_S3_REGION or logs.s3.region)",
|
|
72
|
+
"[logs] s3.enabled but no bucket (set JSKELET_LOG_BUCKET, JSKELET_S3_BUCKET or logs.s3.bucket)",
|
|
90
73
|
);
|
|
91
74
|
} else if (!credentials) {
|
|
92
75
|
console.warn(
|
|
93
|
-
"[logs] s3.enabled but
|
|
76
|
+
"[logs] s3.enabled but credentials missing — set JSKELET_S3_ACCESS_KEY_ID and JSKELET_S3_SECRET_ACCESS_KEY",
|
|
94
77
|
);
|
|
95
78
|
} else {
|
|
79
|
+
const region = logs.s3.region || "auto";
|
|
96
80
|
next.push(
|
|
97
81
|
createS3Sink({
|
|
98
82
|
bucket: logs.s3.bucket,
|
|
99
83
|
prefix: logs.s3.prefix,
|
|
100
|
-
region
|
|
84
|
+
region,
|
|
101
85
|
endpoint: logs.s3.endpoint,
|
|
102
86
|
credentials,
|
|
103
87
|
flushIntervalMs: logs.s3.flushIntervalMs,
|
package/src/server/render.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import process from "node:process";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
17
19
|
import ejs from "ejs";
|
|
18
20
|
import { withHtmlCache } from "./html-cache.js";
|
|
19
21
|
import { getConfig, hook } from "../config/index.js";
|
|
@@ -40,6 +42,11 @@ import * as tags from "../views/helpers/tags.js";
|
|
|
40
42
|
import { loadComponents } from "../views/components/loader.js";
|
|
41
43
|
import { renderStatusPage } from "./status-page.js";
|
|
42
44
|
import { suppressForPrewarm } from "./prewarm.js";
|
|
45
|
+
import {
|
|
46
|
+
ensureTemplatesCompiled,
|
|
47
|
+
getComponentDirs,
|
|
48
|
+
getViewRoots,
|
|
49
|
+
} from "../compile/index.js";
|
|
43
50
|
|
|
44
51
|
const isDev = process.env.NODE_ENV === "development";
|
|
45
52
|
|
|
@@ -49,7 +56,10 @@ const isDev = process.env.NODE_ENV === "development";
|
|
|
49
56
|
* hesaplanamaz, bu yüzden ilk render'da kurulur.
|
|
50
57
|
*
|
|
51
58
|
* @type {{ helpers: Record<string, unknown>, options: ejs.Options,
|
|
52
|
-
* viewsDir: string,
|
|
59
|
+
* viewsDir: string, viewRoots: string[], layout: string,
|
|
60
|
+
* compiled: Map<string, (data: object, helpers: object) => string>,
|
|
61
|
+
* layoutRender: ((data: object, helpers: object) => string) | null
|
|
62
|
+
* } | null}
|
|
53
63
|
*/
|
|
54
64
|
let engine = null;
|
|
55
65
|
|
|
@@ -61,21 +71,46 @@ async function getEngine() {
|
|
|
61
71
|
|
|
62
72
|
const config = getConfig();
|
|
63
73
|
const viewsDir = config.dirs.views;
|
|
74
|
+
const viewRoots = getViewRoots(config);
|
|
75
|
+
|
|
76
|
+
await ensureTemplatesCompiled(config);
|
|
77
|
+
|
|
78
|
+
/** @type {Record<string, unknown>} */
|
|
79
|
+
const helpers = {
|
|
80
|
+
...html,
|
|
81
|
+
...tags,
|
|
82
|
+
...(await loadComponents(getComponentDirs(config))),
|
|
83
|
+
asset,
|
|
84
|
+
hasAsset,
|
|
85
|
+
// Yerleşik PascalCase etiketler (.jsk bileşen sözdizimi).
|
|
86
|
+
Link: (props) => tags.link(props),
|
|
87
|
+
Image: (props) => tags.image(props),
|
|
88
|
+
Icon: (props) => tags.icon(props),
|
|
89
|
+
CsrfField: () => tags.csrfField(),
|
|
90
|
+
PreloadImage: (props) => tags.preloadImage(props),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// camelCase JS bileşenlerini PascalCase etiket adıyla da erişilebilir yap.
|
|
94
|
+
for (const [name, value] of Object.entries(helpers)) {
|
|
95
|
+
if (typeof value !== "function") continue;
|
|
96
|
+
if (/^[A-Z]/.test(name)) continue;
|
|
97
|
+
const pascal = name.charAt(0).toUpperCase() + name.slice(1);
|
|
98
|
+
if (!(pascal in helpers)) helpers[pascal] = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { compiled, layoutRender } = await loadCompiledTemplates(config, helpers);
|
|
64
102
|
|
|
65
103
|
engine = {
|
|
66
104
|
viewsDir,
|
|
105
|
+
viewRoots,
|
|
67
106
|
layout: config.layout,
|
|
68
|
-
helpers
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
...(await loadComponents(path.join(viewsDir, "components"))),
|
|
72
|
-
asset,
|
|
73
|
-
hasAsset,
|
|
74
|
-
},
|
|
107
|
+
helpers,
|
|
108
|
+
compiled,
|
|
109
|
+
layoutRender,
|
|
75
110
|
options: {
|
|
76
111
|
// `include('partials/header')` gibi çağrılar views kökünden çözülür.
|
|
77
112
|
root: viewsDir,
|
|
78
|
-
views: [viewsDir],
|
|
113
|
+
views: viewRoots.length ? viewRoots : [viewsDir],
|
|
79
114
|
cache: !isDev,
|
|
80
115
|
rmWhitespace: true,
|
|
81
116
|
async: true,
|
|
@@ -85,6 +120,66 @@ async function getEngine() {
|
|
|
85
120
|
return engine;
|
|
86
121
|
}
|
|
87
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Derlenmiş `.jsk` modüllerini yükler. İstek anında parse/compile yok.
|
|
125
|
+
*
|
|
126
|
+
* @param {import('../config/index.js').ResolvedConfig} config
|
|
127
|
+
* @param {Record<string, unknown>} helpers
|
|
128
|
+
* @returns {Promise<{ compiled: Map<string, Function>, layoutRender: Function | null }>}
|
|
129
|
+
*/
|
|
130
|
+
async function loadCompiledTemplates(config, helpers) {
|
|
131
|
+
/** @type {Map<string, (data: object, helpers: object) => string>} */
|
|
132
|
+
const compiled = new Map();
|
|
133
|
+
const templatesDir = path.join(config.dirs.generated, "templates");
|
|
134
|
+
const manifestPath = path.join(templatesDir, "manifest.json");
|
|
135
|
+
|
|
136
|
+
if (!fs.existsSync(manifestPath)) {
|
|
137
|
+
return { compiled, layoutRender: null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** @type {Record<string, string>} */
|
|
141
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
142
|
+
const bust = isDev ? `?t=${Date.now()}` : "";
|
|
143
|
+
|
|
144
|
+
for (const [viewId, rel] of Object.entries(manifest)) {
|
|
145
|
+
const file = path.join(templatesDir, rel);
|
|
146
|
+
if (!fs.existsSync(file)) continue;
|
|
147
|
+
const mod = await import(pathToFileURL(file).href + bust);
|
|
148
|
+
if (typeof mod.render !== "function") continue;
|
|
149
|
+
compiled.set(viewId, mod.render);
|
|
150
|
+
|
|
151
|
+
// `components/*.jsk` → helpers.PascalName
|
|
152
|
+
if (viewId.startsWith("components/")) {
|
|
153
|
+
const base = viewId.slice("components/".length).split("/").pop() ?? "";
|
|
154
|
+
const pascal = base
|
|
155
|
+
.split(/[-_/]+/)
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.map((p) => p.charAt(0).toUpperCase() + p.slice(1))
|
|
158
|
+
.join("");
|
|
159
|
+
if (pascal) {
|
|
160
|
+
helpers[pascal] = (props) => mod.render(props ?? {}, helpers);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const layoutRender = compiled.get("layout") ?? null;
|
|
166
|
+
return { compiled, layoutRender };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* View id için kaynak `.ejs` dosyasını çoklu kökte ara.
|
|
171
|
+
* @param {string[]} viewRoots
|
|
172
|
+
* @param {string} view
|
|
173
|
+
* @returns {string | null}
|
|
174
|
+
*/
|
|
175
|
+
function findEjsView(viewRoots, view) {
|
|
176
|
+
for (const root of viewRoots) {
|
|
177
|
+
const file = path.join(root, `${view}.ejs`);
|
|
178
|
+
if (fs.existsSync(file)) return file;
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
88
183
|
/**
|
|
89
184
|
* Uygulama geliştirirken bileşen dosyaları değişince kayıt yenilenmeli.
|
|
90
185
|
* Dev sunucusu süreci yeniden başlattığı için normalde gerekmez; gömülü
|
|
@@ -100,13 +195,22 @@ export function resetRenderEngine() {
|
|
|
100
195
|
* Layout kullanmadan tek bir şablon render eder. Fragment/partial uçları
|
|
101
196
|
* ve e-posta şablonları bunu kullanır.
|
|
102
197
|
*
|
|
198
|
+
* Öncelik: derlenmiş `.jsk` → `.ejs`. İstek anında şablon derlenmez.
|
|
199
|
+
*
|
|
103
200
|
* @param {string} view `views/` altındaki yol, uzantısız (örn. "pages/home")
|
|
104
201
|
* @param {object} [data]
|
|
105
202
|
* @returns {Promise<string>}
|
|
106
203
|
*/
|
|
107
204
|
export async function renderView(view, data = {}) {
|
|
108
|
-
const {
|
|
109
|
-
const
|
|
205
|
+
const { helpers, options, compiled, viewRoots, viewsDir } = await getEngine();
|
|
206
|
+
const renderFn = compiled.get(view);
|
|
207
|
+
if (renderFn) {
|
|
208
|
+
return renderFn({ ...data }, helpers);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const file =
|
|
212
|
+
findEjsView(viewRoots.length ? viewRoots : [viewsDir], view) ??
|
|
213
|
+
path.join(viewsDir, `${view}.ejs`);
|
|
110
214
|
return ejs.renderFile(file, { ...helpers, ...data }, options);
|
|
111
215
|
}
|
|
112
216
|
|
|
@@ -118,7 +222,7 @@ export async function renderView(view, data = {}) {
|
|
|
118
222
|
* @returns {Promise<string>}
|
|
119
223
|
*/
|
|
120
224
|
export async function renderPage(page) {
|
|
121
|
-
const { helpers, options, layout } = await getEngine();
|
|
225
|
+
const { helpers, options, layout, layoutRender } = await getEngine();
|
|
122
226
|
const config = getConfig();
|
|
123
227
|
|
|
124
228
|
const metadata = {
|
|
@@ -134,34 +238,43 @@ export async function renderPage(page) {
|
|
|
134
238
|
hook("layoutContext", {}, { pathname: page.pathname ?? "", metadata }),
|
|
135
239
|
]);
|
|
136
240
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
241
|
+
const locals = {
|
|
242
|
+
...helpers,
|
|
243
|
+
...context,
|
|
244
|
+
metadata,
|
|
245
|
+
// Boş varsayılan bilinçli: "/" yazmak her sayfayı ana sayfa sanıp
|
|
246
|
+
// logoyu <h1> olarak bastıran türde hatalara yol açıyor.
|
|
247
|
+
pathname: page.pathname ?? "",
|
|
248
|
+
lang: context.lang ?? config.brand.lang ?? "en",
|
|
249
|
+
headMeta: renderHeadMeta(metadata),
|
|
250
|
+
structuredData: context.structuredData ?? [],
|
|
251
|
+
// Preconnect her sayfada aynı; LCP preload'ını sayfa kendisi ekler.
|
|
252
|
+
// Gezinme ipuçları preconnect'ten sonra: spekülasyon bir sonraki sayfayı
|
|
253
|
+
// ilgilendiriyor, bu sayfanın LCP'sinin önüne geçmemeli.
|
|
254
|
+
extraHead:
|
|
255
|
+
preconnectHints() +
|
|
256
|
+
navigationHints() +
|
|
257
|
+
(page.head ?? "") +
|
|
258
|
+
(context.extraHead ?? ""),
|
|
259
|
+
bodyClass: page.bodyClass ?? context.bodyClass ?? "",
|
|
260
|
+
entries: page.entries ?? [],
|
|
261
|
+
devtools: isDev,
|
|
262
|
+
devBasePath: config.brand.devBasePath,
|
|
263
|
+
body,
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (layoutRender) {
|
|
267
|
+
return layoutRender(locals, helpers);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (layout.endsWith(".jsk")) {
|
|
271
|
+
// Kaynak var ama derlenmemiş — ensureTemplates sonrası olmamalı.
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Layout ${path.relative(config.root, layout)} is not compiled — run jskelet build`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return ejs.renderFile(layout, locals, options);
|
|
165
278
|
}
|
|
166
279
|
|
|
167
280
|
/**
|