Initial static site implementation
- Node.js build script with gray-matter and marked - Self-hosted fonts (DM Serif Display, Karla) - Swedish badge system for origin transparency - Filtering by category, region, tags, and search - URL-based filter state for shareable links - Individual entry pages - About and badge info pages - Privacy-focused: no cookies, no tracking, no external requests - Hosted in Lerum, Sweden
This commit is contained in:
365
build.js
Normal file
365
build.js
Normal file
@@ -0,0 +1,365 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const CONTENT_DIR = './content';
|
||||
const TEMPLATE_DIR = './templates';
|
||||
const PUBLIC_DIR = './public';
|
||||
const STATIC_DIR = './static';
|
||||
const DIST_DIR = './dist';
|
||||
|
||||
// Swedish translations for categories
|
||||
const CATEGORY_LABELS = {
|
||||
manufacturers: 'Tillverkare',
|
||||
products: 'Produkter',
|
||||
services: 'Tjänster',
|
||||
experiences: 'Upplevelser'
|
||||
};
|
||||
|
||||
// Badge tiers (highest to lowest)
|
||||
const BADGE_TIERS = {
|
||||
// Tier 1 - Gold (universal)
|
||||
'akta-svenskt': { label: 'Äkta Svenskt', tier: 1, description: 'Helt svenskt: företag, produktion/drift och leverantörer' },
|
||||
|
||||
// Tier 2 - Silver (physical or digital)
|
||||
'tillverkat-i-sverige': { label: 'Tillverkat i Sverige', tier: 2, description: 'Svenskt företag, tillverkat i Sverige' },
|
||||
'svenskt-moln': { label: 'Svenskt Moln', tier: 2, description: 'Svenskt företag med svenska servrar och hosting' },
|
||||
|
||||
// Tier 3 - Bronze (physical or digital)
|
||||
'designat-i-sverige': { label: 'Designat i Sverige', tier: 3, description: 'Svensk design, produktion kan ske utomlands' },
|
||||
'utvecklat-i-sverige': { label: 'Utvecklat i Sverige', tier: 3, description: 'Svensk utveckling, hosting kan ske utomlands' },
|
||||
|
||||
// Tier 4 - Blue (universal)
|
||||
'svenskt-foretag': { label: 'Svenskt Företag', tier: 4, description: 'Svenskregistrerat eller svenskgrundat företag' }
|
||||
};
|
||||
|
||||
function translateCategory(category) {
|
||||
return CATEGORY_LABELS[category] || category;
|
||||
}
|
||||
|
||||
function getBadgeHTML(badgeKey) {
|
||||
if (!badgeKey || !BADGE_TIERS[badgeKey]) return '';
|
||||
const badge = BADGE_TIERS[badgeKey];
|
||||
return `<span class="badge badge-tier-${badge.tier}" title="${badge.description}">${badge.label}</span>`;
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Copy directory recursively
|
||||
function copyDirRecursive(src, dest) {
|
||||
const items = fs.readdirSync(src, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
const srcPath = path.join(src, item.name);
|
||||
const destPath = path.join(dest, item.name);
|
||||
|
||||
if (item.isDirectory()) {
|
||||
ensureDir(destPath);
|
||||
copyDirRecursive(srcPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read all markdown files from a directory recursively
|
||||
function readContentFiles(dir) {
|
||||
const entries = [];
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item.name);
|
||||
|
||||
if (item.isDirectory()) {
|
||||
entries.push(...readContentFiles(fullPath));
|
||||
} else if (item.name.endsWith('.md')) {
|
||||
const content = fs.readFileSync(fullPath, 'utf-8');
|
||||
const { data, content: body } = matter(content);
|
||||
const category = path.basename(path.dirname(fullPath));
|
||||
const slug = path.basename(item.name, '.md');
|
||||
|
||||
entries.push({
|
||||
...data,
|
||||
category: data.category || category,
|
||||
body: marked(body),
|
||||
bodyRaw: body,
|
||||
slug,
|
||||
url: `${category}/${slug}.html`,
|
||||
sourcePath: fullPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Simple template replacement
|
||||
function renderTemplate(template, data) {
|
||||
let result = template;
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
||||
result = result.replace(regex, value ?? '');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate entry cards HTML
|
||||
function generateEntryCards(entries) {
|
||||
return entries.map(entry => {
|
||||
const imageHtml = entry.image
|
||||
? `<div class="entry-image"><img src="${entry.image}" alt="${entry.title}" loading="lazy"></div>`
|
||||
: '';
|
||||
|
||||
const logoHtml = entry.logo
|
||||
? `<img src="${entry.logo}" alt="${entry.title} logo" class="entry-logo">`
|
||||
: '';
|
||||
|
||||
const badgeHtml = getBadgeHTML(entry.badge);
|
||||
|
||||
return `
|
||||
<article class="entry-card"
|
||||
data-category="${entry.category || ''}"
|
||||
data-region="${entry.region || ''}"
|
||||
data-tags="${(entry.tags || []).join(',')}"
|
||||
data-badge="${entry.badge || ''}">
|
||||
${imageHtml}
|
||||
<div class="entry-card-content">
|
||||
<div class="entry-header">
|
||||
${logoHtml}
|
||||
<h2><a href="${entry.url}">${entry.title}</a></h2>
|
||||
${badgeHtml}
|
||||
</div>
|
||||
<div class="entry-meta">
|
||||
<button type="button" class="filter-btn category" data-filter-type="category" data-filter-value="${entry.category || ''}">${translateCategory(entry.category) || ''}</button>
|
||||
${entry.region ? `<button type="button" class="filter-btn region" data-filter-type="region" data-filter-value="${entry.region}">${entry.region}</button>` : ''}
|
||||
</div>
|
||||
<div class="entry-body">${entry.body}</div>
|
||||
${entry.tags?.length ? `
|
||||
<div class="tags">
|
||||
${entry.tags.map(tag => `<button type="button" class="tag filter-btn" data-filter-type="tag" data-filter-value="${tag}">${tag}</button>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</article>
|
||||
`}).join('\n');
|
||||
}
|
||||
|
||||
// Generate single entry page HTML
|
||||
function generateEntryPage(entry, template) {
|
||||
const imageHtml = entry.image
|
||||
? `<div class="single-image"><img src="${entry.image}" alt="${entry.title}"></div>`
|
||||
: '';
|
||||
|
||||
const logoHtml = entry.logo
|
||||
? `<img src="${entry.logo}" alt="${entry.title} logo" class="single-logo">`
|
||||
: '';
|
||||
|
||||
const badgeHtml = getBadgeHTML(entry.badge);
|
||||
|
||||
const entryHtml = `
|
||||
<article class="single-entry">
|
||||
${imageHtml}
|
||||
<div class="single-header">
|
||||
${logoHtml}
|
||||
<div class="single-title-wrapper">
|
||||
<h1>${entry.title}</h1>
|
||||
${badgeHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="single-meta">
|
||||
<a href="../index.html?category=${encodeURIComponent(entry.category || '')}" class="filter-link category">${translateCategory(entry.category) || ''}</a>
|
||||
${entry.region ? `<a href="../index.html?region=${encodeURIComponent(entry.region)}" class="filter-link region">${entry.region}</a>` : ''}
|
||||
</div>
|
||||
<div class="single-body">${entry.body}</div>
|
||||
${entry.website ? `<a href="${entry.website}" class="website-link" target="_blank" rel="noopener">Besök webbplats →</a>` : ''}
|
||||
${entry.tags?.length ? `
|
||||
<div class="tags">
|
||||
${entry.tags.map(tag => `<a href="../index.html?tag=${encodeURIComponent(tag)}" class="tag">${tag}</a>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<a href="../index.html" class="back-link">← Tillbaka till alla poster</a>
|
||||
</article>
|
||||
`;
|
||||
|
||||
return renderTemplate(template, {
|
||||
title: `${entry.title} | Ursprung Sverige`,
|
||||
subtitle: entry.title,
|
||||
content: entryHtml,
|
||||
year: new Date().getFullYear()
|
||||
});
|
||||
}
|
||||
|
||||
// Extract unique values for filters
|
||||
function extractFilters(entries) {
|
||||
const categories = [...new Set(entries.map(e => e.category).filter(Boolean))].sort();
|
||||
const regions = [...new Set(entries.map(e => e.region).filter(Boolean))].sort();
|
||||
const tags = [...new Set(entries.flatMap(e => e.tags || []))].sort();
|
||||
|
||||
return { categories, regions, tags };
|
||||
}
|
||||
|
||||
// Generate filter HTML
|
||||
function generateFilterHTML(filters) {
|
||||
const categoryOptions = filters.categories.map(c =>
|
||||
`<option value="${c}">${translateCategory(c)}</option>`
|
||||
).join('');
|
||||
|
||||
const regionOptions = filters.regions.map(r =>
|
||||
`<option value="${r}">${r}</option>`
|
||||
).join('');
|
||||
|
||||
const tagOptions = filters.tags.map(t =>
|
||||
`<option value="${t}">${t}</option>`
|
||||
).join('');
|
||||
|
||||
return `
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label for="category-filter">Kategori</label>
|
||||
<select id="category-filter">
|
||||
<option value="">Alla kategorier</option>
|
||||
${categoryOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="region-filter">Region</label>
|
||||
<select id="region-filter">
|
||||
<option value="">Alla regioner</option>
|
||||
${regionOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="tag-filter">Tagg</label>
|
||||
<select id="tag-filter">
|
||||
<option value="">Alla taggar</option>
|
||||
${tagOptions}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="search-input">Sök</label>
|
||||
<input type="text" id="search-input" placeholder="Sök...">
|
||||
</div>
|
||||
<button type="button" id="clear-filters" class="clear-filters-btn">Rensa filter</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Main build function
|
||||
function build() {
|
||||
console.log('Building site...');
|
||||
|
||||
// Clean and create dist directory
|
||||
if (fs.existsSync(DIST_DIR)) {
|
||||
fs.rmSync(DIST_DIR, { recursive: true });
|
||||
}
|
||||
ensureDir(DIST_DIR);
|
||||
|
||||
// Read content
|
||||
const entries = readContentFiles(CONTENT_DIR);
|
||||
console.log(`Found ${entries.length} entries`);
|
||||
|
||||
// Extract filters
|
||||
const filters = extractFilters(entries);
|
||||
|
||||
// Read templates
|
||||
const indexTemplatePath = path.join(TEMPLATE_DIR, 'template.html');
|
||||
const singleTemplatePath = path.join(TEMPLATE_DIR, 'single.html');
|
||||
|
||||
if (!fs.existsSync(indexTemplatePath)) {
|
||||
console.error('Template not found:', indexTemplatePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const indexTemplate = fs.readFileSync(indexTemplatePath, 'utf-8');
|
||||
const singleTemplate = fs.existsSync(singleTemplatePath)
|
||||
? fs.readFileSync(singleTemplatePath, 'utf-8')
|
||||
: indexTemplate; // Fallback to index template
|
||||
|
||||
// Generate index HTML
|
||||
const indexHtml = renderTemplate(indexTemplate, {
|
||||
title: 'Ursprung Sverige',
|
||||
subtitle: 'En kurerad samling av svenska produkter, tjänster, upplevelser och tillverkare',
|
||||
filters: generateFilterHTML(filters),
|
||||
entries: generateEntryCards(entries),
|
||||
entryCount: entries.length,
|
||||
year: new Date().getFullYear()
|
||||
});
|
||||
|
||||
// Write index.html
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'index.html'), indexHtml);
|
||||
|
||||
// Generate badges info page
|
||||
const badgesTemplatePath = path.join(TEMPLATE_DIR, 'badges.html');
|
||||
if (fs.existsSync(badgesTemplatePath)) {
|
||||
const badgesTemplate = fs.readFileSync(badgesTemplatePath, 'utf-8');
|
||||
const badgesHtml = renderTemplate(badgesTemplate, {
|
||||
year: new Date().getFullYear()
|
||||
});
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'om-markning.html'), badgesHtml);
|
||||
}
|
||||
|
||||
// Generate about page
|
||||
const aboutTemplatePath = path.join(TEMPLATE_DIR, 'about.html');
|
||||
if (fs.existsSync(aboutTemplatePath)) {
|
||||
const aboutTemplate = fs.readFileSync(aboutTemplatePath, 'utf-8');
|
||||
const aboutHtml = renderTemplate(aboutTemplate, {
|
||||
year: new Date().getFullYear()
|
||||
});
|
||||
fs.writeFileSync(path.join(DIST_DIR, 'om-oss.html'), aboutHtml);
|
||||
}
|
||||
|
||||
// Generate individual entry pages
|
||||
for (const entry of entries) {
|
||||
const categoryDir = path.join(DIST_DIR, entry.category);
|
||||
ensureDir(categoryDir);
|
||||
|
||||
const entryHtml = generateEntryPage(entry, singleTemplate);
|
||||
fs.writeFileSync(path.join(categoryDir, `${entry.slug}.html`), entryHtml);
|
||||
}
|
||||
console.log(`Generated ${entries.length} entry pages`);
|
||||
|
||||
// Copy public assets (CSS, JS - small files)
|
||||
if (fs.existsSync(PUBLIC_DIR)) {
|
||||
copyDirRecursive(PUBLIC_DIR, DIST_DIR);
|
||||
}
|
||||
|
||||
// Symlink static folder (images, large assets - avoids copying)
|
||||
const staticDest = path.join(DIST_DIR, 'static');
|
||||
if (fs.existsSync(STATIC_DIR) && !fs.existsSync(staticDest)) {
|
||||
const absoluteStatic = path.resolve(STATIC_DIR);
|
||||
fs.symlinkSync(absoluteStatic, staticDest);
|
||||
}
|
||||
|
||||
console.log('Build complete! Output in', DIST_DIR);
|
||||
}
|
||||
|
||||
// Watch mode
|
||||
if (process.argv.includes('--watch')) {
|
||||
console.log('Watching for changes...');
|
||||
build();
|
||||
|
||||
const watchDirs = [CONTENT_DIR, TEMPLATE_DIR, PUBLIC_DIR];
|
||||
for (const dir of watchDirs) {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.watch(dir, { recursive: true }, (event, filename) => {
|
||||
console.log(`\nChange detected: ${filename}`);
|
||||
build();
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
build();
|
||||
}
|
||||
Reference in New Issue
Block a user