You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

651 lines
30 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Static site generator for artnouveau.in — quiet-luxury editorial design.
// Usage: npm run build → regenerates dist/ from src/ + data.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const SRC = path.join(ROOT, 'src');
const DIST = path.join(ROOT, 'dist');
const site = JSON.parse(fs.readFileSync(path.join(SRC, 'data', 'site.json'), 'utf8'));
const projects = JSON.parse(fs.readFileSync(path.join(SRC, 'data', 'projects.json'), 'utf8'));
const manifest = JSON.parse(fs.readFileSync(path.join(SRC, 'data', 'images.json'), 'utf8'));
const home = JSON.parse(fs.readFileSync(path.join(SRC, 'data', 'home.json'), 'utf8'));
const extra = fs.existsSync(path.join(SRC, 'data', 'images-extra.json'))
? JSON.parse(fs.readFileSync(path.join(SRC, 'data', 'images-extra.json'), 'utf8')) : {};
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/* ---------- image helpers ---------- */
function entryFor(slug, idx) {
const list = manifest[slug] || [];
const e = list[idx];
return e && e.variants ? e : null;
}
function validIndices(slug) {
const hidden = (projects.find(p => p.slug === slug) || {}).hiddenIndices || [];
return (manifest[slug] || []).map((e, i) => (e && e.variants ? i : -1))
.filter(i => i >= 0 && !hidden.includes(i));
}
function picture(slug, idx, o) {
const e = entryFor(slug, idx);
if (!e) return '';
o = o || {};
const webps = [], seen = new Set();
for (const v of e.variants) if (v.format === 'webp' && !seen.has(v.width)) { seen.add(v.width); webps.push(v); }
const jpg = e.variants.find(v => v.format === 'jpeg');
const srcset = webps.map(v => `/${v.file} ${v.width}w`).join(', ');
const attrs = [
`src="/${jpg.file}"`, `width="${jpg.width}"`, `height="${jpg.height}"`,
`alt="${esc(o.alt || '')}"`, `decoding="async"`,
o.eager ? 'fetchpriority="high"' : 'loading="lazy"',
o.dataAttrs || ''
].filter(Boolean).join(' ');
return `<picture><source type="image/webp" srcset="${srcset}" sizes="${o.sizes || '100vw'}"><img ${attrs}></picture>`;
}
function biggest(slug, idx) {
const e = entryFor(slug, idx);
if (!e) return '';
const w = e.variants.filter(v => v.format === 'webp').sort((a, b) => b.width - a.width)[0];
return '/' + w.file;
}
function extraPic(key, o) {
const e = extra[key];
if (!e || !e.variants) return '';
o = o || {};
const webps = [], seen = new Set();
for (const v of e.variants) if (v.format === 'webp' && !seen.has(v.width)) { seen.add(v.width); webps.push(v); }
const jpg = e.variants.find(v => v.format === 'jpeg') || webps[0];
const srcset = webps.map(v => `/${v.file} ${v.width}w`).join(', ');
return `<picture><source type="image/webp" srcset="${srcset}" sizes="${o.sizes || '100vw'}"><img src="/${jpg.file}" width="${jpg.width}" height="${jpg.height}" alt="${esc(o.alt || '')}" loading="lazy" decoding="async"${o.dataAttrs ? ' ' + o.dataAttrs : ''}></picture>`;
}
/* ---------- shared shell ---------- */
const NAV = [
['/projects/', 'projects'], ['/studio/', 'studio'], ['/process/', 'process'],
[site.tenderUrl, 'tender'], ['/contact/', 'contact'],
];
function navList(current) {
return NAV.map(([href, label]) => {
const ext = href.startsWith('http');
const cur = !ext && href === current ? ' aria-current="page"' : '';
const rel = ext ? ' rel="noopener"' : '';
return `<li><a href="${href}"${cur}${rel}>${label}</a></li>`;
}).join('\n ');
}
const FAVICON = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23F4F1EA'/%3E%3Cpath d='M8 24 16 8l8 16' fill='none' stroke='%231C1A16' stroke-width='1.5'/%3E%3Ccircle cx='24' cy='24' r='2.5' fill='%237A4A32'/%3E%3C/svg%3E`;
function layout(o) {
// o: {path, title, desc, body, current, scripts:[], styles:[], jsonld, ogImage}
const url = site.domain + o.path;
const og = o.ogImage || (extra.og && '/' + extra.og.variants.find(v => v.format === 'jpeg').file) || '';
const vendor = ['/assets/vendor/gsap.min.js', '/assets/vendor/ScrollTrigger.min.js', '/assets/vendor/lenis.min.js']
.concat(o.scripts || []);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${esc(o.title)}</title>
<meta name="description" content="${esc(o.desc)}">
<link rel="canonical" href="${url}">
<meta name="theme-color" content="#F4F1EA">
<meta property="og:type" content="website">
<meta property="og:site_name" content="${esc(site.name)}">
<meta property="og:title" content="${esc(o.title)}">
<meta property="og:description" content="${esc(o.desc)}">
<meta property="og:url" content="${url}">
${og ? `<meta property="og:image" content="${site.domain}${og}">` : ''}
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="${FAVICON}">
<link rel="preload" href="/assets/fonts/cormorant-normal-300-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/assets/fonts/archivo-normal-300-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/assets/css/site.css">
${(o.styles || []).map(h => `<link rel="stylesheet" href="${h}">`).join('\n')}
${o.jsonld ? `<script type="application/ld+json">${o.jsonld}</script>` : ''}
</head>
<body>
<a class="skip" href="#main">Skip to content</a>
<div class="veil-page" aria-hidden="true"></div>
<header class="site" id="hdr">
<nav class="nav" aria-label="Main">
<a class="wordmark" href="/">Art<span>.</span>n Architects</a>
<ul>
${navList(o.current)}
</ul>
<button class="menu-label" aria-expanded="false" aria-controls="site-menu">menu</button>
</nav>
</header>
<div class="menu-overlay" id="site-menu" hidden>
<button class="menu-close" aria-label="Close menu">close</button>
<nav aria-label="Mobile">
<a href="/">home</a>
${NAV.map(([href, label]) => `<a href="${href}"${href.startsWith('http') ? ' rel="noopener"' : ''}>${label}</a>`).join('\n ')}
</nav>
</div>
${o.body}
<footer class="site">
<div class="wrap">
<div class="foot-top">
<p class="wordmark">Art<span>.</span>n Architects</p>
<p class="eyebrow">Architecture · Interiors · Project Management</p>
</div>
<div class="foot-cols">
<div>
<span class="eyebrow">Changanacherry</span>
<p>${site.offices[0].address.map(esc).join('<br>')}<br><a href="${site.offices[0].phoneHref}">${esc(site.offices[0].phone)}</a></p>
</div>
<div>
<span class="eyebrow">Kochi</span>
<p>${site.offices[1].address.map(esc).join('<br>')}<br><a href="${site.offices[1].phoneHref}">${esc(site.offices[1].phone)}</a></p>
</div>
<div>
<span class="eyebrow">Write</span>
<p><a href="mailto:${site.email}">${esc(site.email)}</a><br>
<a href="${site.whatsapp}" rel="noopener">WhatsApp</a></p>
</div>
<div>
<span class="eyebrow">Elsewhere</span>
<p><a href="${site.instagram}" rel="noopener">Instagram</a><br>
<a href="${site.facebook}" rel="noopener">Facebook</a><br>
<a href="${site.tenderUrl}" rel="noopener">Tender portal</a></p>
</div>
</div>
<div class="foot-base">
<p>© ${esc(site.name)}, est. ${site.established}. All rights reserved.</p>
<p>Changanacherry · Kochi — Kerala, India</p>
</div>
</div>
</footer>
${vendor.map(s => `<script src="${s}" defer></script>`).join('\n')}
<script src="/assets/js/site.js" defer></script>
</body>
</html>`;
}
const ctaBand = (h) => `
<section class="cta">
<div class="wrap inner">
<h2 class="reveal">${h || 'A site, a brief, or just a first idea — <em>bring it to the table.</em>'}</h2>
<div class="actions reveal">
<a class="btn" href="mailto:${site.email}">write to us</a>
<a class="btn" href="${site.whatsapp}" rel="noopener">whatsapp</a>
</div>
</div>
</section>`;
const bySlug = Object.fromEntries(projects.map(p => [p.slug, p]));
const heroIdxOf = p => {
const valid = validIndices(p.slug);
if (!valid.length) return -1;
return valid.includes(p.heroIndex) ? p.heroIndex : valid[0];
};
const projUrl = p => `/projects/${p.slug}/`;
/* editorial flow tile; slot-position → sizes attr for the 6-slot cycle */
const SLOT_SIZES = ['66vw', '33vw', '42vw', '42vw', '66vw', '42vw'];
const sizesFor = i => `(max-width:860px) 100vw, ${SLOT_SIZES[i % 6]}`;
function piece(p, i, heroOverride) {
const hi = heroOverride != null ? heroOverride : heroIdxOf(p);
if (hi < 0) return '';
return `
<a class="piece reveal" href="${projUrl(p)}">
<figure>
<div class="ph">${picture(p.slug, hi, { sizes: sizesFor(i), alt: p.heroAlt || p.title })}</div>
<figcaption><span class="name">${esc(p.tileLabel || p.title)}</span>${p.place ? `<span class="where">${esc(p.place)}</span>` : `<span class="where">${esc(p.category || '')}</span>`}</figcaption>
</figure>
</a>`;
}
/* ---------- HOME ---------- */
function homePage() {
const heroP = bySlug[home.hero.slug];
const featured = [...home.reel.filter(r => r.slug !== home.hero.slug), ...home.grid]
.filter((v, i, a) => a.findIndex(x => x.slug === v.slug) === i)
.slice(0, 6);
const flow = featured.map((g, i) => piece(bySlug[g.slug], i, g.index)).join('');
const jsonld = JSON.stringify({
'@context': 'https://schema.org', '@type': 'Organization',
name: site.name, url: site.domain, email: site.email,
foundingDate: String(site.established),
sameAs: [site.instagram, site.facebook],
address: site.offices.map(of => ({
'@type': 'PostalAddress', streetAddress: of.address[0],
addressLocality: of.address[1].split(',')[0], addressRegion: 'Kerala', addressCountry: 'IN'
}))
});
const body = `
<main id="main">
<section class="hero-quiet wrap" aria-label="Art.n Architects">
<h1>
<span class="line"><span>Buildings with</span></span>
<span class="line"><span>a quiet sense</span></span>
<span class="line"><span>of <em>belonging.</em></span></span>
</h1>
<div class="meta">
<span class="eyebrow">Architecture · Interiors · Project Management</span>
<span class="eyebrow">Changanacherry &amp; Kochi — since ${site.established}</span>
</div>
<p class="hero-scroll eyebrow" aria-hidden="true">scroll</p>
</section>
<section class="panel-full" aria-label="${esc(heroP.title)}">
<div class="photo">${picture(home.hero.slug, home.hero.index, { sizes: '100vw', eager: true, alt: heroP.heroAlt || heroP.title })}</div>
<p class="cap">${esc(heroP.tileLabel || heroP.title)}${heroP.place ? ' — ' + esc(heroP.place) : ''}</p>
</section>
<section class="statement wrap">
<span class="eyebrow reveal">The practice</span>
<p class="big reveal">Every project begins as a simple intention — a place that is right for its people, its climate and its time. <em>We design it, detail it, and stay until it is delivered.</em></p>
<div class="sub reveal">
<p>Art.n Architects is an architecture, interior design and project management
studio working from Changanacherry and Kochi since ${site.established} — residences,
hospitality, healthcare and public buildings across Kerala.</p>
<p><a href="/studio/"><span class="u">Meet the studio</span></a></p>
</div>
</section>
<section class="wrap" aria-label="Selected work">
<div class="flow">${flow}
</div>
<p class="view-all reveal"><a href="/projects/"><span class="u">See all ${projects.length} projects</span></a></p>
</section>
<section class="threed wrap" id="threed">
<div class="threed-row">
<div class="reveal">
<model-viewer
src="/assets/3d/massing.glb"
alt="Conceptual massing model of a Kerala residence"
camera-controls auto-rotate rotation-per-second="10deg"
shadow-intensity="1" exposure="1.05"
data-orbit-scrub
loading="lazy" reveal="auto">
</model-viewer>
</div>
<div class="reveal">
<span class="eyebrow" style="display:block;margin-bottom:20px">From the board</span>
<h3>Walk around the drawing.</h3>
<p>Drag the model — or simply keep scrolling and watch it turn. This is the
massing study of a Kerala residence, the stage where every project begins
before a single wall goes up.</p>
<p class="note">Signature projects receive their own models here, exported
straight from the studio's working files.</p>
</div>
</div>
</section>
<section class="studio-row wrap">
<a class="ph reveal" href="/studio/">${picture(home.studio.slug, home.studio.index, { sizes: '(max-width:860px) 100vw, 58vw', alt: 'Inside the Art.n Architects studio', dataAttrs: 'data-inner' })}</a>
<div class="reveal">
<span class="eyebrow" style="display:block;margin-bottom:20px">The studio</span>
<h3>One studio, drawing to handover.</h3>
<p>Architecture, interiors and project management under one roof — so what is
designed is what gets built. <a href="/process/"><span class="u">How we work</span></a></p>
<p class="addr">
${site.offices[0].address.map(esc).join(', ')}<br>
${site.offices[1].address.map(esc).join(', ')}
</p>
</div>
</section>
${ctaBand()}
</main>`;
return layout({
path: '/', current: '/', body, jsonld,
title: 'Art.n Architects — Architecture, Interiors & Project Management, Kerala',
desc: `Architecture, interior design and project management studio in Changanacherry and Kochi, Kerala, since ${site.established}.`,
ogImage: (() => { const e = entryFor(home.hero.slug, home.hero.index); return e ? '/' + e.variants.find(v => v.format === 'jpeg').file : null; })()
});
}
/* ---------- LISTINGS ---------- */
const FILTERS = [
['/projects/', 'all work'],
['/projects/architecture/', 'architecture'],
['/projects/interiors/', 'interiors'],
['/projects/pmc/', 'project management'],
];
function listingPage(list, o) {
const filters = FILTERS.map(([href, label]) =>
`<a href="${href}"${href === o.path ? ' aria-current="page"' : ''}>${label}</a>`).join('\n ');
const flow = list.map((p, i) => piece(p, i)).join('');
const body = `
<main id="main">
<section class="page-head wrap">
<span class="eyebrow reveal">${String(list.length).padStart(2, '0')} projects</span>
<h1 class="reveal">${o.h1}</h1>
<p class="lede reveal">${o.lede}</p>
<div class="filters reveal">
${filters}
</div>
</section>
<section class="wrap">
<div class="flow">${flow}
</div>
</section>
${ctaBand()}
</main>`;
return layout({
path: o.path, current: '/projects/', body,
title: o.title, desc: o.desc
});
}
/* ---------- PROJECT PAGE ---------- */
function projectPage(p, prev, next) {
const hi = heroIdxOf(p);
const valid = validIndices(p.slug).filter(i => i !== hi);
const galleryItems = valid.map((idx, n) => `
<a href="${biggest(p.slug, idx)}" class="glightbox" data-gallery="proj" data-glightbox="title: ${esc(p.title)}${String(n + 2).padStart(2, '0')}">
${picture(p.slug, idx, { sizes: '(max-width:860px) 100vw, 46vw', alt: `${p.title} — photograph ${n + 2}` })}
</a>`).join('');
const spec = [
['Category', p.category],
['Client', p.client],
['Year', p.year],
['Place', p.place],
['Scope', p.discipline.map(d => ({ architecture: 'Architecture', interior: 'Interiors', pmc: 'Project Management' }[d] || d)).join(' · ')],
].filter(([, v]) => v).map(([k, v]) => `<li><span class="k">${k}</span><span class="v">${esc(v)}</span></li>`).join('\n ');
const blurb = p.blurb || `${p.title} — a ${(p.category || 'building').toLowerCase()} project by ${site.name}${p.place ? ' in ' + p.place : ''}.`;
const jsonld = JSON.stringify({
'@context': 'https://schema.org', '@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Projects', item: site.domain + '/projects/' },
{ '@type': 'ListItem', position: 2, name: p.title, item: site.domain + projUrl(p) },
]
});
const body = `
<main id="main">
<section class="proj-hero wrap">
<div class="ph">${picture(p.slug, hi, { sizes: '92vw', eager: true, alt: p.heroAlt || p.title })}</div>
</section>
<section class="proj-title wrap">
<span class="eyebrow">${esc([p.category, p.place].filter(Boolean).join(' — '))}</span>
<h1>${esc(p.title)}</h1>
</section>
<div class="wrap">
<div class="proj-cols">
<ul class="spec reveal">
${spec}
</ul>
<div class="proj-blurb reveal">
<p>${esc(blurb)}</p>
</div>
</div>
${valid.length ? `<div class="gallery">${galleryItems}
</div>` : ''}
<nav class="proj-nav" aria-label="More projects">
<a href="${projUrl(prev)}"><span class="eyebrow">previous</span><span class="name u">${esc(prev.tileLabel || prev.title)}</span></a>
<a class="next" href="${projUrl(next)}"><span class="eyebrow">next</span><span class="name u">${esc(next.tileLabel || next.title)}</span></a>
</nav>
</div>
${ctaBand('Planning something like this? <em>Let us draw it with you.</em>')}
</main>`;
return layout({
path: projUrl(p), current: '/projects/', body, jsonld,
title: `${p.title}${[p.category, p.place].filter(Boolean).join(', ')}${site.name}`,
desc: blurb.length > 155 ? blurb.slice(0, 152) + '…' : blurb,
scripts: ['/assets/vendor/glightbox.min.js'],
styles: ['/assets/vendor/glightbox.min.css'],
ogImage: (() => { const e = entryFor(p.slug, hi); return e ? '/' + e.variants.find(v => v.format === 'jpeg').file : null; })()
});
}
/* ---------- STUDIO ---------- */
function studioPage() {
const team = site.team.map(t => {
const key = 'team:' + t.name;
return `
<figure class="reveal">
<div class="ph">${extraPic(key, { sizes: '(max-width:860px) 45vw, 12vw', alt: t.name })}</div>
<figcaption><span class="nm">${esc(t.name)}</span><span class="rl">${esc(t.role)}</span></figcaption>
</figure>`;
}).join('');
const body = `
<main id="main">
<section class="page-head wrap">
<span class="eyebrow reveal">The studio — est. ${site.established}</span>
<h1 class="reveal">A practice built on <em>staying with the work.</em></h1>
<p class="lede reveal">Architecture, interiors and project management under one roof
in Changanacherry and Kochi — so what is designed is what gets built.</p>
</section>
<section class="wrap story">
<div class="ph reveal">${extraPic('team-group', { sizes: '(max-width:860px) 100vw, 42vw', alt: 'The Art.n Architects team', dataAttrs: 'data-inner' })}</div>
<div class="reveal">
<span class="eyebrow" style="display:block;margin-bottom:20px">Our story</span>
<h2>Drawing for Kerala since ${site.established}.</h2>
<p>The practice opened its board in <b>Changanacherry in ${site.established}</b>, working first on
residences and renovations, and grew project by project into hospitality, healthcare,
commercial and institutional work. In 2007 a second studio opened in <b>Kochi</b>.</p>
<p>The name on the door is a position: architecture rooted in Kerala's building
tradition — climate, courtyard, veranda, roof — drawn with a contemporary hand.
Vastu informs the plan when clients ask for it; the drawing board decides the rest.</p>
<p>Today the studio takes projects from the first sketch to the handover key:
design, detailing, tendering and site supervision by one team.</p>
</div>
</section>
<section class="wrap people">
<div class="person">
<div class="reveal">
<span class="eyebrow" style="display:block;margin-bottom:20px">Principal</span>
<h3>${esc(site.principal.name)}</h3>
<span class="eyebrow role">${esc(site.principal.role)}</span>
${site.principal.bio.map(b => `<p>${esc(b)}</p>`).join('\n ')}
</div>
<div class="ph reveal">${extraPic('principal', { sizes: '(max-width:860px) 70vw, 25vw', alt: site.principal.name })}</div>
</div>
</section>
<section class="team wrap">
<span class="eyebrow reveal" style="display:block;margin-bottom:40px">The team — ${String(site.team.length + 1).padStart(2, '0')} people</span>
<div class="team-grid">${team}
</div>
</section>
${ctaBand('Want this team on your project? <em>Start the conversation.</em>')}
</main>`;
return layout({
path: '/studio/', current: '/studio/', body,
title: `Studio — ${site.name}`,
desc: `Art.n Architects: an architecture, interiors and project management studio in Changanacherry and Kochi, Kerala, led by principal architect ${site.principal.name} since ${site.established}.`
});
}
/* ---------- PROCESS ---------- */
function processPage() {
const steps = [
['Brief & site', 'We begin where the project does: walking the land, reading its light, slope and neighbours, and listening to how you intend to live or work in it. The brief is written together — needs, budget and time, stated plainly.'],
['Concept', 'The first drawings test the big moves: how the building sits on its site, how rooms gather around light and air, how Keralas climate — sun, monsoon, breeze — shapes roof and verandah. We iterate until the plan feels inevitable.'],
['Detail & permits', 'The approved concept is drawn through to working detail: structure, materials, joinery, services. Statutory drawings and approvals are handled by the studio, so the project stays on one board.'],
['Tender', 'Contractors bid through our own online tender portal — transparent pricing, comparable bids, and a paper trail the client can read. We recommend; you decide.'],
['Site & supervision', 'Our engineers and project managers stay on the work: quality checks, bill certification, cost control and the weekly rhythm of site meetings, from foundation to finishes.'],
['Handover', 'The building is delivered as it was drawn — snagged, documented and photographed. Most of our work returns as the next project: an interior, an extension, a new site.'],
].map(([h, t], i) => `
<div class="step reveal">
<span class="no">0${i + 1}</span>
<h3>${h}</h3>
<p>${t}</p>
</div>`).join('');
const body = `
<main id="main">
<section class="page-head wrap">
<span class="eyebrow reveal">How we work</span>
<h1 class="reveal">From the first line <em>to the last key.</em></h1>
<p class="lede reveal">One studio carries the project the whole way — design,
detailing, tendering and site supervision. Six stages, one hand on the drawing.</p>
</section>
<section class="wrap steps">${steps}
</section>
${ctaBand('Curious where your project would begin? <em>Ask us.</em>')}
</main>`;
return layout({
path: '/process/', current: '/process/', body,
title: `Process — ${site.name}`,
desc: 'How Art.n Architects works: brief and site, concept, detail and permits, tendering through our online portal, site supervision, and handover — one studio the whole way.'
});
}
/* ---------- CONTACT ---------- */
function contactPage() {
const offices = site.offices.map(of => `
<div class="office reveal">
<h2>${esc(of.name)}</h2>
<p>${of.address.map(esc).join('<br>')}</p>
<div class="links">
<a href="${of.phoneHref}">${esc(of.phone)}</a>
<a href="${of.maps}" rel="noopener" target="_blank"><span class="u">Open in Google Maps</span></a>
</div>
</div>`).join('');
const jsonld = JSON.stringify({
'@context': 'https://schema.org', '@type': 'ProfessionalService',
name: site.name, url: site.domain, email: site.email,
telephone: '+91 ' + site.offices[0].phone,
address: site.offices.map(of => ({
'@type': 'PostalAddress', streetAddress: of.address[0],
addressLocality: of.address[1].split(',')[0], addressRegion: 'Kerala', addressCountry: 'IN'
}))
});
const body = `
<main id="main">
<section class="page-head wrap">
<span class="eyebrow reveal">Contact</span>
<h1 class="reveal">A first conversation <em>costs nothing.</em></h1>
<p class="lede reveal">Bring a site, a brief, or just an idea — it usually starts with a sketch.</p>
</section>
<section class="wrap offices">${offices}
</section>
<section class="wrap reach">
<a class="reveal" href="mailto:${site.email}">
<span class="k">email</span>
<span class="v">${esc(site.email)}</span>
</a>
<a class="reveal" href="${site.whatsapp}" rel="noopener">
<span class="k">whatsapp</span>
<span class="v">+91 94471 12848</span>
</a>
<a class="reveal" href="${site.instagram}" rel="noopener">
<span class="k">instagram</span>
<span class="v">@art.n.architects</span>
</a>
<a class="reveal" href="${site.tenderUrl}" rel="noopener">
<span class="k">contractors</span>
<span class="v">Tender portal</span>
</a>
</section>
</main>`;
return layout({
path: '/contact/', current: '/contact/', body, jsonld,
title: `Contact — ${site.name}`,
desc: 'Contact Art.n Architects — Changanacherry 0481 241 2848 · Kochi 0484 234 1067 · artnove@gmail.com · WhatsApp +91 94471 12848.'
});
}
/* ---------- 404 ---------- */
function notFoundPage() {
const body = `
<main id="main">
<section class="wrap lost">
<span class="eyebrow">page not found</span>
<h1>404</h1>
<p>This page was never drawn — or it has been filed elsewhere.</p>
<p><a class="btn" href="/">back to the start</a></p>
</section>
</main>`;
return layout({
path: '/404.html', current: '', body,
title: `Page not found — ${site.name}`, desc: 'Page not found.'
});
}
/* ---------- writing ---------- */
function write(rel, html) {
const f = path.join(DIST, rel);
fs.mkdirSync(path.dirname(f), { recursive: true });
fs.writeFileSync(f, html);
}
function copy(srcRel, destRel) {
const s = path.isAbsolute(srcRel) ? srcRel : path.join(ROOT, srcRel);
const d = path.join(DIST, destRel);
fs.mkdirSync(path.dirname(d), { recursive: true });
fs.copyFileSync(s, d);
}
write('index.html', homePage());
write('projects/index.html', listingPage(projects, {
path: '/projects/', h1: 'Work that <em>stays.</em>', lede:
'Every project the studio has drawn, detailed and delivered — residences, hospitality, healthcare and public buildings across Kerala.',
title: `Projects — ${site.name}`,
desc: `${projects.length} built and ongoing projects by Art.n Architects across Kerala: residences, resorts, hospitals, institutions and offices.`
}));
const disc = (d) => projects.filter(p => p.discipline.includes(d));
write('projects/architecture/index.html', listingPage(disc('architecture'), {
path: '/projects/architecture/', h1: 'Architecture', lede:
"Buildings designed from the ground up — concept, statutory drawings and detail for Kerala's climate and way of living.",
title: `Architecture projects — ${site.name}`,
desc: 'Architecture portfolio of Art.n Architects: residences, resorts, hospitals and institutional buildings across Kerala.'
}));
write('projects/interiors/index.html', listingPage(disc('interior'), {
path: '/projects/interiors/', h1: 'Interiors', lede:
'Interior architecture that carries the building through — homes, restaurants, offices and institutions.',
title: `Interior design projects — ${site.name}`,
desc: 'Interior design portfolio of Art.n Architects: homes, restaurants, offices and institutional interiors across Kerala.'
}));
write('projects/pmc/index.html', listingPage(disc('pmc'), {
path: '/projects/pmc/', h1: 'Project management', lede:
'Projects where the studio also ran the site — tendering, supervision and cost control from first drawing to handover.',
title: `Project management — ${site.name}`,
desc: 'Project management consultancy by Art.n Architects: tendering, site supervision and delivery across Kerala.'
}));
projects.forEach((p, i) => {
const prev = projects[(i - 1 + projects.length) % projects.length];
const next = projects[(i + 1) % projects.length];
write(path.join('projects', p.slug, 'index.html'), projectPage(p, prev, next));
});
write('studio/index.html', studioPage());
write('process/index.html', processPage());
write('contact/index.html', contactPage());
write('404.html', notFoundPage());
/* legacy-URL redirect stubs */
const redirect = (to) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${to}"><link rel="canonical" href="${site.domain}${to}"><title>Moved</title></head><body><p>Moved to <a href="${to}">${to}</a>.</p></body></html>`;
const legacy = { 'works.html': '/projects/', 'work architecture.html': '/projects/architecture/', 'work interior.html': '/projects/interiors/', 'work pmc.html': '/projects/pmc/', 'about.html': '/studio/', 'contact.html': '/contact/' };
for (const p of projects) legacy[p.sourcePage] = projUrl(p);
for (const [oldName, to] of Object.entries(legacy)) write(oldName, redirect(to));
/* sitemap + robots */
const urls = ['/', '/projects/', '/projects/architecture/', '/projects/interiors/', '/projects/pmc/', '/studio/', '/process/', '/contact/']
.concat(projects.map(projUrl));
write('sitemap.xml', `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.map(u => ` <url><loc>${site.domain}${u}</loc></url>`).join('\n')}
</urlset>
`);
write('robots.txt', `User-agent: *\nAllow: /\nSitemap: ${site.domain}/sitemap.xml\n`);
/* assets */
copy('src/css/site.css', 'assets/css/site.css');
copy('src/js/site.js', 'assets/js/site.js');
for (const f of fs.readdirSync(path.join(SRC, 'fonts'))) if (f.endsWith('.woff2')) copy(path.join('src', 'fonts', f), path.join('assets', 'fonts', f));
if (fs.existsSync(path.join(SRC, '3d', 'massing.glb'))) copy('src/3d/massing.glb', 'assets/3d/massing.glb');
const V = 'node_modules';
copy(`${V}/gsap/dist/gsap.min.js`, 'assets/vendor/gsap.min.js');
copy(`${V}/gsap/dist/ScrollTrigger.min.js`, 'assets/vendor/ScrollTrigger.min.js');
copy(`${V}/lenis/dist/lenis.min.js`, 'assets/vendor/lenis.min.js');
copy(`${V}/@google/model-viewer/dist/model-viewer.min.js`, 'assets/vendor/model-viewer.min.js');
copy(`${V}/glightbox/dist/js/glightbox.min.js`, 'assets/vendor/glightbox.min.js');
copy(`${V}/glightbox/dist/css/glightbox.min.css`, 'assets/vendor/glightbox.min.css');
console.log(`BUILD OK — ${urls.length} pages + ${Object.keys(legacy).length} redirects (quiet edition)`);