04 — Render ve şablonlar
Bu sayfada
- Render hattı
- .jsk — build-time derlenmiş şablonlar
- Sözdizimi özeti
- Editör desteği
- EJS ile birlikte yaşam
- EJS motoru (legacy)
- Layout
- Layout dosyası nasıl bulunur
- Framework'ün varsayılan layout'u
- Layout local'leri
- Sayfa şablonları
- Bileşenler: views/components/**
- Yardımcılar: jskelet/html
- esc(value)
- attrs(object)
- cx(...inputs)
- cn(...inputs)
- jsonScript(value)
- Yardımcılar: jskelet/tags
- link(props)
- image(props)
- icon(props)
- preloadImage(props)
- Metadata → <head>
- Dinamik OG görselleri
- Hook'lar
- hooks.metadata(page)
- hooks.layoutContext({ pathname, metadata })
- hooks.notFound()
- Diğer hook'lar
- Overlay portal noktası
- Sırada ne var
Bu belge sunucu HTML'inin nasıl üretildiğini anlatır: EJS motorunun ayarları, layout dosyasının çözümü ve kullanabildiği local'ler, views/pages altındaki sayfa şablonları, views/components/** altındaki bileşenlerin otomatik kaydı, şablonlara hazır gelen html/tags yardımcıları, metadata nesnesinin <head> etiketlerine çevrilmesi ve üç render hook'u. Controller'ın bu katmana ne gönderdiği Routing'de, varlık URL'lerini üreten asset()/hasAsset() Build'de anlatılıyor.
Render hattı
route(controller)
└─ produce()
├─ controller(ctx) → sayfa tanımı
└─ renderPage(page)
├─ hooks.metadata(page) + page.metadata → metadata
├─ Promise.all([
│ renderView(page.view, { …data, metadata }), → body
│ hooks.layoutContext({ pathname, metadata }), → context
│ ])
└─ layout (.jsk derlenmiş veya .ejs) → tam HTML
Layout bağlamı ve gövde paralel üretilir. Sebebi ölçümden geliyor: navigasyon çoğu projede upstream'den geliyor ve gövde render'ıyla sırayla beklemek her sayfaya gereksiz gecikme ekliyor.
.jsk — build-time derlenmiş şablonlar
Yeni uygulamalarda varsayılan şablon biçimi .jsk'dir. Build sırasında (.jskelet/templates/*.mjs) normal ESM modüllerine çevrilir; istek anında parse / eval / new Function yoktur. Production yolu:
controller data → import edilmiş render(data, helpers) → HTML
Sözdizimi özeti
<section class="wrapper">
<h1>{{ title }}</h1>
<div>{{{ trustedHtml }}}</div>
{#if items.length}
<List :items="items" />
{#else}
<p>Boş</p>
{/if}
{#each items as item, i}
<li data-i="{{ i }}">{{ item }}</li>
{/each}
<Link href="/" text="Home" />
<div data-island="counter" data-island-props='{"start":0}'></div>
</section>
| Özellik | Yazım |
|---|---|
| Kaçışlı metin | {{ expr }} |
| Ham HTML | {{{ expr }}} |
| Koşul | {#if expr} … {#else} … {/if} |
| Döngü | {#each list as item} veya as item, i |
| Include | {#include "partials/header"} (derlenmiş .jsk) |
| Bileşen | PascalCase etiket; :prop="expr", prop="literal", boolean disabled |
| Yerleşikler | Link, Image, Icon, CsrfField, PreloadImage |
İfade dili kasıtlı olarak dardır (erişim, karşılaştırma, ternary, .length). Atama, object literal ve rastgele fonksiyon çağrısı yok — mantık controller veya JS bileşende kalır.
Şablon mu, bileşen mi?
EJS’den geçerken sınırı erken çizmek işe yarar:
Burada kalsın (.jsk) | JS bileşene taşı |
|---|---|
| Metin, koşul, liste, prop bağlama | Fonksiyon çağrısı, nesne üretimi, biçimlendirme |
Yerleşik etiketler (Link, Image, …) | Birden fazla yardımcıdan HTML birleştirme |
| Controller’dan gelen hazır veri | Upstream / hata ayırt eden UI (LoadErrorState) |
Şablonda format(x) veya { a: 1 } yazılamıyorsa bu bir eksik değil: o iş views/components/*.js veya controller’ındır. Karmaşık sayfalar bileşene kaçıyorsa ifade dilini genişletmek yerine bileşen sınırını net tutmak tercih edilir.
Editör desteği
Repo içinde extensions/vscode-jsk VS Code / Cursor uzantısı vardır: sözdizimi renklendirme, dil yapılandırması ve snippet'ler. Yerel kurulum:
code --install-extension extensions/vscode-jsk
Ayrıntılar uzantı README'sinde.
EJS ile birlikte yaşam
Aynı view id için derlenmiş .jsk varsa o kullanılır; yoksa .ejs dosyası EJS ile render edilir. Mevcut uygulamalar değişmeden çalışır. jskelet init yeni iskeleti .jsk ile kurar.
EJS motoru (legacy)
EJS hâlâ desteklenir. Motor ilk render'da bir kez kurulur; bileşen taraması dosya sistemine dokunduğu için her istekte yapılamaz ve config yüklenmeden hesaplanamaz.
Ayarlar:
| Ayar | Değer | Sebebi |
|---|---|---|
root, views | views dizini | include('partials/header') çağrıları views kökünden çözülür |
cache | dev'de false, prod'da true | dev'de şablon düzenlemesi anında görünsün |
rmWhitespace | true | çıktı boyutu |
async | true | şablon içinde await kullanılabilir |
Gömülü kullanımlar (test, script) için resetRenderEngine() dışa açık: bileşen dosyaları değişince kaydı yeniler. Dev sunucusu süreci yeniden başlattığı için normal akışta gerekmez.
Layout
Layout dosyası nasıl bulunur
jskelet.config.mjs→layoutverilmişse o kullanılır. Yol, views dizininin üst dizinine göre çözülür:viewsvarsayılansalayout: "views/ozel.ejs"→<root>/views/ozel.ejs.- Verilmemişse
views/layout.jsk(derlenmiş) varsa o kullanılır. - Yoksa
views/layout.ejsvarsa o kullanılır. - O da yoksa framework'ün kendi minimal layout'u kullanılır (
node_modules/jskelet/src/templates/layout.ejs, ayrıcajskelet/layoutbelirteciyle de erişilebilir).
Üçüncü seçenek yeni bir projenin tek route ile çalışabilmesi için var. Kendi layout'unuza geçmenin en pratik yolu o dosyayı views/layout.ejs olarak kopyalamaktır.
Framework'ün varsayılan layout'u
<!DOCTYPE html>
<html lang="<%= lang %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- extraHead %>
<% if (hasAsset('app.css')) { %>
<link rel="stylesheet" href="<%= asset('app.css') %>" data-jskelet-css="app.css">
<% } %>
<% styles.forEach(function (sheet) { %>
<% if (hasAsset(sheet)) { %>
<link rel="stylesheet" href="<%= asset(sheet) %>" data-jskelet-css="<%= sheet %>">
<% } %>
<% }); %>
<%- headMeta %>
<% structuredData.forEach(function (item) { %>
<script type="application/ld+json"><%- jsonScript(item) %></script>
<% }); %>
</head>
<body class="<%= bodyClass %>">
<%- body %>
<% if (hasAsset('main.js')) { %>
<script type="module" src="<%= asset('main.js') %>"></script>
<% } %>
<% entries.forEach(function (entry) { %>
<script type="module" src="<%= asset(entry) %>"></script>
<% }); %>
<% if (devtools) { %>
<script type="module" src="<%= devBasePath %>/overlay.js"></script>
<% } %>
</body>
</html>
Dikkat edilecek noktalar:
extraHeaden başta. Kaynak ipuçlarını (preconnect, LCPpreload) geciktirmek doğrudan LCP'ye yazılır.- Global
app.cssrender-blocking ve gerekçesi Mimari'de. Controllerstyles: [...]ile ek sayfa sheet'leri de aynı şekilde basılır. Build çalışmadıysahasAssetfalse olur ve etiket hiç basılmaz. hasAssetkontrolleri build eksikken sayfanın 404 veren dosyaları istememesini sağlar.- Devtools script'i yalnızca
NODE_ENV=developmentiken basılır; prod çıktısında hiç yoktur.
Layout local'leri
| Local | Tip | Kaynağı |
|---|---|---|
metadata | object | hooks.metadata() + controller metadata (controller kazanır) |
headMeta | string | metadatadan üretilmiş hazır <head> etiketleri |
extraHead | string | preconnect ipuçları + navigation ipuçları + controller head + context.extraHead |
structuredData | unknown[] | hooks.layoutContext() → structuredData; varsayılan [] |
body | string | Sayfa şablonunun render çıktısı |
bodyClass | string | controller bodyClass → context.bodyClass → "" |
entries | string[] | controller entries; varsayılan [] |
styles | string[] | controller styles; varsayılan [] |
pathname | string | req.path; varsayılan boş string |
lang | string | context.lang → brand.lang → "en" |
devtools | boolean | NODE_ENV === "development" |
devBasePath | string | brand.devBasePath, varsayılan /__jskelet/dev |
asset, hasAsset | fonksiyon | Manifest erişimi |
| html/tags yardımcıları | fonksiyon | esc, attrs, cx, cn, jsonScript, link, image, icon, preloadImage, toKebab |
views/components/** export'ları | fonksiyon | Otomatik kayıt |
hooks.layoutContext() çıktısındaki her alan | — | Doğrudan local olur |
pathname'in boş varsayılanı bilinçli: "/" yazmak her sayfayı ana sayfa sanıp logoyu <h1> olarak bastıran türde hatalara yol açıyor.
Sayfa şablonları
view alanı views/ altındaki yolu uzantısız verir: "pages/home" → views/pages/home.ejs. Şablona geçen local'ler data alanının içeriği artı metadatadır — layout local'leri değil. Sayfa şablonu yine tüm yardımcılara ve bileşenlere erişir.
<%# views/pages/home.ejs %>
<section class="wrapper">
<h1 class="text-3xl font-bold"><%= heading %></h1>
<%# `list` views/components/list.js içinde tanımlı; import gerekmiyor. %>
<%- list({ items }) %>
<div class="mt-8" data-island="counter" data-island-props='{"start":5}'></div>
</section>
EJS'te iki çıktı biçimini karıştırmayın:
<%= value %>— HTML kaçışlı. Kullanıcı/upstream verisi için daima bu.<%- html %>— ham. Yalnızca kendi ürettiğin, güvenli HTML string'leri için (bileşen çağrıları,headMeta,body).
async: true açık olduğu için şablon içinde await da kullanılabilir, ancak veri çekmeyi controller'da tutmak teşhisi kolaylaştırır.
Bileşenler: views/components/**
Bileşenler EJS partial'ı değil, HTML string döndüren fonksiyonlardır. views/components/** altındaki her .js dosyası taranır ve her named export şablon local'i olur. Elle bakılan bir barrel dosyası yok: yeni bir bileşen eklemek için dosyayı oluşturmak yeterli.
// views/components/list.js
import { esc } from "jskelet/html";
/**
* @param {{ items: string[] }} props
* @returns {string}
*/
export function list({ items }) {
if (!items?.length) return "";
const rows = items.map((item) => `<li class="py-1">${esc(item)}</li>`).join("");
return `<ul class="mt-6 list-disc pl-6">${rows}</ul>`;
}
Şablonda:
<%- list({ items }) %>
Kurallar:
- Tarama özyinelemelidir; alt dizinler de kapsanır.
defaultexport'lar yok sayılır — yalnızca named export'lar kaydedilir.- Compile-time bilinen bileşen listesi dosya adından değil, kaynak metindeki named export'lardan okunur.
ui.jsiçindekisectionHead→ şablonda<SectionHead />(runtime zaten camelCase export'a PascalCase alias ekler). Dosya adına göre stub re-export eklemeye gerek yoktur. loader.jsveindex.jsbileşen dosyası sayılmaz.views/components/index.jsvarsa barrel olarak, en düşük öncelikle en önce yüklenir. Tek amacılib/yeniden ihraçlarını şablon local'i yapmak; bileşenlerin kendi dosyaları sonradan gelip sessizce üzerine yazar.- Aynı ad (veya aynı PascalCase etiket) iki farklı bileşen dosyasında tanımlıysa uyarı değil hata: build ve sunucu açılışı
Component 'card' is defined twice: …ile durur. Barrel üzerine yazmak bilinçli istisnadır. views/componentsdizini yoksa bileşen kaydı boş kalır; bileşen kullanmayan bir proje de çalışır.
Yardımcılar: jskelet/html
Şablonlara otomatik geçer; bileşen dosyalarında import { … } from "jskelet/html" ile alınır.
esc(value)
Metin içeriği ve attribute değerleri için kaçış (&, <, >, ", '). null, undefined ve false boş string'e çevrilir — koşullu render'da false && "…" gibi ifadeler "false" basmaz.
esc('<b>"x"</b>'); // "<b>"x"</b>"
attrs(object)
Attribute nesnesini string'e çevirir. null/undefined/false atlanır, true boolean attribute olarak yazılır, geri kalan değerler kaçışlanır. Çıktı boş değilse başında bir boşluk ile döner, böylece <div${attrs(...)}> her zaman doğru biçimlenir.
`<input${attrs({ type: "text", required: true, value: null })}>`;
// '<input type="text" required>'
cx(...inputs)
clsx karşılığı: string, sayı, dizi ve { sınıf: koşul } nesnesi kabul eder, falsy değerleri atar. Tailwind çakışması çözmez.
cx("btn", isActive && "btn-active", { "btn-lg": size === "lg" });
cn(...inputs)
cx() ile birleştirir, sonra tailwind-merge ile Tailwind çakışmalarını çözer. Bir bileşenin varsayılan sınıflarının çağıran tarafından ezilmesi gerektiğinde bunu kullanın.
cn("px-4 py-2 bg-slate-100", className); // className "bg-white" ise bg-slate-100 düşer
tailwind-merge çalışma zamanı bağımlılığı olarak korunur çünkü sınıf hesabı yalnızca sunucuda yapılır; client bundle'a hiç girmez.
jsonScript(value)
<script type="application/ld+json"> gövdesi için güvenli JSON: <, >, & ve U+2028/U+2029 kaçırılır, böylece </script ya da <!-- dizileri gövdeyi kapatamaz.
<script type="application/ld+json"><%- jsonScript(article) %></script>
Yardımcılar: jskelet/tags
next/link, next/image ve @phosphor-icons/react karşılıkları. Hepsi HTML string döndürür ve EJS içinden <%- %> ile basılır.
link(props)
link({
href: "/hakkinda",
text: "Hakkında",
class: "font-semibold",
// opsiyonel: html, title, ariaLabel, target, rel, attrs
});
titleverilmezseariaLabel→text→hrefsırasıyla otomatik doldurulur.hrefhttp://ya dahttps://ile başlıyorsatarget="_blank"verel="noopener noreferrer"otomatik eklenir; açıkça verirsen senin değerin kullanılır.htmlverilirse içerik ham basılır;textverilirse kaçışlanır.attrsnesnesi ek attribute'ları geçirir ve öncekilerin üzerine biner.
image(props)
image({
src: "/hero.png",
alt: "Kapak",
priority: true,
// opsiyonel: width, height, class, sizes, srcset, fill, loading,
// unoptimized, attrs
});
Davranış:
public/altındaki yerel raster görseller için build'de üretilen webp varyantları (.jskelet/images.json) otomatik olaraksrcset+ intrinsicwidth/heightolarak eklenir. Manifest'te olmayan yerel yollar olduğu gibi basılır.images.remote.allowHostsaçıksa uzakhttp(s)URL'leri/_jskelet/image?url=&w=proxy'sine çevrilir (webp).widthvarsa 1x/2x + configwidthsilesrcsetüretilir.srcsetelle verilmişse ya daunoptimized: trueise ne manifest ne de remote proxy kullanılır.- Yalnızca tek varyant üretilmişse (kaynak zaten küçükse)
srcset/sizesyazılmaz; gürültüden ibaret olurdu. Remote'da tek genişlikte bilesrcyine optimize URL'dir. sizesverilmezse makul bir varsayılan üretilir: görsel kendi intrinsic genişliğinden büyütülmez, dar ekranlarda viewport'u kaplar ((max-width: Npx) 100vw, Npx).priority: true→loading="eager",decoding="sync",fetchpriority="high". LCP görseli için.priorityyoksa →loading="lazy",decoding="async".fill: true→width/heightyazılmaz veabsolute inset-0 h-full w-full object-coversınıflarıcn()ile birleştirilir.
icon(props)
Build zamanı üretilen SVG sprite'tan <use> çıkarır.
icon({ name: "ArrowRight", weight: "bold", size: 20, class: "text-slate-500" });
// <svg width="20" height="20" class="…" aria-hidden="true" focusable="false"
// fill="currentColor" viewBox="0 0 256 256"><use href="/assets/sprite.<hash>.svg#arrow-right-bold"></use></svg>
namePhosphor adıdır;ArrowRightIconveArrowRightbiçimleri de kabul edilir vearrow-right'a çevrilir (toKebab()).weightsprite id'sine dâhildir:thin,light,regular(varsayılan),bold,fill,duotone.sizevarsayılan 24;widthveheightolarak yazılır.- Development'ta sprite'ta olmayan bir sembol istendiğinde tek seferlik uyarı basılır. Sprite yalnızca kaynakta statik olarak görülen adları içerir; adı çalışma anında hesaplanan bir çağrı eksik sembole işaret ederse ekranda sessizce boşluk kalır (Build).
preloadImage(props)
preloadImage({ href: "/assets/img/hero-1280.abc.webp", imagesrcset, imagesizes });
// <link rel="preload" as="image" href="…" fetchpriority="high">
Pratikte doğrudan çağırmak yerine headHints() kullanılır:
import { headHints } from "jskelet";
return {
view: "pages/article",
head: headHints({ href: cover, imageSrcSet, imageSizes }),
};
headHints() href yoksa boş string döner, yani koşul yazmak gerekmez. Preconnect'leri layout zaten her sayfaya bastığı için burada tekrarlanmaz.
Metadata → <head>
Controller metadata döndürür, framework onu etiketlere çevirir (Next.js'in Metadata API'sinin karşılığı). Şema bilinçli olarak küçük; daha fazlası gerekirse extraTags ile ham HTML eklenir, böylece framework her yeni meta türü için sürüm çıkarmak zorunda kalmaz.
| Alan | Tip | Anlamı | |
|---|---|---|---|
title | string | <title> | |
titleTemplate | string | `"%s \ | Site" — title buna gömülür. Yalnızca title` da varsa uygulanır. |
description | string | <meta name="description"> | |
canonical | string | Mutlak ya da göreli URL | |
siteUrl | string | Göreli canonicalı mutlaklaştırmak için taban | |
robots | { index?: boolean, follow?: boolean } | Varsayılan index, follow | |
locale | string | og:locale | |
openGraph | { title, description, url, type, siteName, image, imageWidth, imageHeight } | og:* etiketleri | |
twitter | { card, site, creator, title, description, image } | twitter:* etiketleri | |
extraTags | string[] | Olduğu gibi basılacak ham etiketler |
Üretim kuralları:
- Robots varsayılanı indekslenebilir. Bir sayfayı gizlemek açık bir karar olmalı:
robots: { index: false }→noindex, follow. - OpenGraph
propertykullanır,namedeğil. Bazı kazıyıcılarnameile yazılmış og etiketlerini görmezden geliyor. - Devralma zinciri:
og:titleyoksatitle,og:descriptionyoksadescription,og:urlyoksa mutlaklaştırılmışcanonical,twitter:titleyoksaog:title→title,twitter:imageyoksaog:image. twitter:cardverilmezseog:imagevarsasummary_large_image, yoksasummary.- Boş değerler hiç basılmaz:
null,undefinedve""olan alanlar etiket üretmez. og:typeverilmezsewebsite.
Örnek:
return {
view: "pages/article",
metadata: {
title: article.title,
description: article.summary,
canonical: `/haber/${article.slug}`,
openGraph: {
type: "article",
image: article.cover,
imageWidth: 1200,
imageHeight: 630,
},
extraTags: [`<meta property="article:published_time" content="${article.date}">`],
},
};
titleTemplate ve siteUrl gibi her sayfada aynı olan alanları hooks.metadata() içine koyun; controller yalnızca sayfaya özel olanı verir.
renderHeadMeta(metadata) fonksiyonu dışa açıktır; layout dışında (ör. bir fragment ya da e-posta) aynı etiketleri üretmek gerekirse kullanılabilir.
Dinamik OG görselleri
Next.js ImageResponse / opengraph-image.tsx karşılığı. JSX yok: kart alanları (title, description, siteName, renkler) ya da ham svg verilir. sharp (opsiyonel peer) kuruluysa PNG, yoksa SVG döner. Sosyal kazıyıcıların çoğu PNG beklediği için prod'da sharp önerilir.
HTML değil görsel döndüğü için route() kullanılmaz — ogHandler düz bir Express handler üretir. notFound() ve null dönüşü 404 olur.
// routes/35-og.mjs
export default function register(app, { ogHandler, notFound }) {
app.get(
"/og/blog/:slug.png",
ogHandler(async ({ params }) => {
const post = getPost(params.slug);
if (!post) notFound();
return {
title: post.title,
description: post.excerpt,
siteName: "Blog",
};
}),
);
}
Sayfa metadata'sında mutlak URL ve boyut verin:
openGraph: {
type: "article",
image: `${SITE_URL}/og/blog/${post.slug}.png`,
imageWidth: 1200,
imageHeight: 630,
},
Ham SVG veya Next benzeri sınıf:
import { ImageResponse, sendOgImage, OG_SIZE } from "jskelet";
app.get("/og/custom.png", async (req, res) => {
const image = new ImageResponse(
`<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">…</svg>`,
OG_SIZE,
);
await image.send(res);
// veya: await sendOgImage(res, { title: "…", format: "svg" });
});
Varsayılan Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=604800. cacheControl seçeneğiyle ezilir. Çalışan örnek: examples/blog/routes/35-og.mjs.
Hook'lar
Hook'lar jskelet.config.mjs → hooks altında tanımlanır. Hepsi opsiyonel, hepsi async olabilir. Bir hook hata verirse sayfa düşmez: framework kendi varsayılanına döner ve uyarır.
hooks.metadata(page)
Her sayfanın metadata varsayılanı. Argüman olarak render edilen sayfa tanımını alır, bir metadata nesnesi döndürür. Controller'ın metadata alanı bunun üzerine biner (alan bazında, sığ birleştirme).
hooks: {
metadata() {
return {
titleTemplate: "%s | JSkelet",
description: "JSkelet ile kurulmuş bir site.",
siteUrl: "https://ornek.com",
};
},
}
hooks.layoutContext({ pathname, metadata })
Layout'a her render'da eklenen local'ler. Döndürülen nesnenin her alanı layout local'i olur; ayrıca üç alan özel olarak yorumlanır:
lang→<html lang>structuredData→ JSON-LD script'leri (dizi)extraHead→<head>e eklenir (controllerheadinden sonra)bodyClass→ controllerbodyClassvermemişse kullanılır
hooks: {
async layoutContext({ pathname }) {
return {
bodyClass: "min-h-full",
navigation: await getNavigation(),
isHome: pathname === "/",
};
},
}
Bu hook gövde render'ıyla paralel çalışır; içinde upstream çağırmak sayfaya sıralı gecikme eklemez.
hooks.notFound()
404 sayfası tanımı. Döndürdüğü nesne renderPage'e pathname: "/404" ile verilir. Ayrıntı: Routing.
Diğer hook'lar
hooks.prewarmPaths() render katmanına değil ısıtmaya aittir; bkz. Cache.
Overlay portal noktası
jskelet/client → getOverlayRoot() modal ve drawer içeriğini taşıyacağı hedefi verir: layout'ta <div id="jskelet-overlays"></div> varsa oraya, yoksa bodyye. Portal, overflow ya da transform taşıyan bir ata elementin position: fixed overlay'i kırpmasını engeller. Modal kullanacaksanız bu div'i layout'un <body> sonuna eklemek yeterli (Island'lar).
Sırada ne var
- Island'lar ve
entries: Island'lar asset(), manifest ve Tailwind taraması: Build- Hook'ların config içindeki yeri: Yapılandırma