Files
ursprungsverige/build.js
Jonas Raneryd Imaizumi cfb35a3c38 Add badge filtering and shop category to the site
- Introduced a new 'shops' category in the build script and README.
- Implemented badge filtering in the filter functionality, allowing users to filter entries by badge.
2026-01-25 23:37:20 +01:00

384 lines
12 KiB
JavaScript

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',
shops: 'Butiker'
};
// 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();
const badges = [...new Set(entries.map(e => e.badge).filter(Boolean))].sort((a, b) => {
// Sort by tier (highest first)
const tierA = BADGE_TIERS[a]?.tier || 99;
const tierB = BADGE_TIERS[b]?.tier || 99;
return tierA - tierB;
});
return { categories, regions, tags, badges };
}
// 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('');
const badgeOptions = filters.badges.map(b =>
`<option value="${b}">${BADGE_TIERS[b]?.label || b}</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="badge-filter">Märkning</label>
<select id="badge-filter">
<option value="">Alla märkningar</option>
${badgeOptions}
</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();
}