@happyvertical/smrt-template-site-static-json 0.30.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/AGENTS.md +30 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +115 -0
- package/index.js +8 -0
- package/package.json +43 -0
- package/template/.env.example +8 -0
- package/template/AGENTS.md +21 -0
- package/template/CLAUDE.md +1 -0
- package/template/README.md +59 -0
- package/template/data/.gitkeep +2 -0
- package/template/package.json +21 -0
- package/template/scripts/init-data.ts +56 -0
- package/template/smrt.config.js +138 -0
- package/template/src/app.css +156 -0
- package/template/src/app.d.ts +13 -0
- package/template/src/app.html +12 -0
- package/template/src/lib/components/WeatherHeader.svelte +59 -0
- package/template/src/lib/utils/markdown.ts +35 -0
- package/template/src/routes/+layout.server.ts +193 -0
- package/template/src/routes/+layout.svelte +73 -0
- package/template/src/routes/+page.server.ts +60 -0
- package/template/src/routes/+page.svelte +118 -0
- package/template/src/routes/about/+page.server.ts +8 -0
- package/template/src/routes/about/+page.svelte +92 -0
- package/template/src/routes/contact/+page.server.ts +8 -0
- package/template/src/routes/contact/+page.svelte +95 -0
- package/template/src/site.config.ts +35 -0
- package/template/svelte.config.js +19 -0
- package/template/tsconfig.json +14 -0
- package/template/vite.config.ts +6 -0
- package/template.config.js +86 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
2
|
+
// for information about these interfaces
|
|
3
|
+
declare global {
|
|
4
|
+
namespace App {
|
|
5
|
+
// interface Error {}
|
|
6
|
+
// interface Locals {}
|
|
7
|
+
// interface PageData {}
|
|
8
|
+
// interface PageState {}
|
|
9
|
+
// interface Platform {}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
%sveltekit.head%
|
|
8
|
+
</head>
|
|
9
|
+
<body data-sveltekit-preload-data="hover">
|
|
10
|
+
<div style="display: contents">%sveltekit.body%</div>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Local WeatherHeader shim — was previously imported from
|
|
3
|
+
// `@happyvertical/smrt-svelte` but that package no longer ships the
|
|
4
|
+
// component (it was removed before the 0.24.x baseline this template
|
|
5
|
+
// pins). Inlining a minimal renderer here keeps the scaffold buildable
|
|
6
|
+
// and gives consumers a starting point to customize.
|
|
7
|
+
//
|
|
8
|
+
// The shape of `forecast` follows what the caelus workflow writes into
|
|
9
|
+
// the data layer; if your project pulls a different upstream, update
|
|
10
|
+
// this component to match.
|
|
11
|
+
type Forecast = {
|
|
12
|
+
location?: string;
|
|
13
|
+
summary?: string;
|
|
14
|
+
temperatureC?: number;
|
|
15
|
+
temperatureF?: number;
|
|
16
|
+
iconUrl?: string;
|
|
17
|
+
} | null | undefined;
|
|
18
|
+
|
|
19
|
+
let { forecast }: { forecast: Forecast } = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#if forecast}
|
|
23
|
+
<div class="weather-header" aria-label="Current weather">
|
|
24
|
+
{#if forecast.iconUrl}
|
|
25
|
+
<img src={forecast.iconUrl} alt="" class="icon" />
|
|
26
|
+
{/if}
|
|
27
|
+
{#if forecast.location}
|
|
28
|
+
<span class="location">{forecast.location}</span>
|
|
29
|
+
{/if}
|
|
30
|
+
{#if forecast.summary}
|
|
31
|
+
<span class="summary">{forecast.summary}</span>
|
|
32
|
+
{/if}
|
|
33
|
+
{#if typeof forecast.temperatureC === 'number'}
|
|
34
|
+
<span class="temp">{forecast.temperatureC.toFixed(0)}°C</span>
|
|
35
|
+
{:else if typeof forecast.temperatureF === 'number'}
|
|
36
|
+
<span class="temp">{forecast.temperatureF.toFixed(0)}°F</span>
|
|
37
|
+
{/if}
|
|
38
|
+
</div>
|
|
39
|
+
{/if}
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.weather-header {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 0.5rem;
|
|
46
|
+
padding: 0.25rem 0;
|
|
47
|
+
font-size: 0.875rem;
|
|
48
|
+
color: var(--color-neutral-gray700, #4b5563);
|
|
49
|
+
}
|
|
50
|
+
.icon {
|
|
51
|
+
width: 1.5rem;
|
|
52
|
+
height: 1.5rem;
|
|
53
|
+
}
|
|
54
|
+
.location,
|
|
55
|
+
.summary,
|
|
56
|
+
.temp {
|
|
57
|
+
line-height: 1.2;
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple Markdown Renderer
|
|
3
|
+
*
|
|
4
|
+
* Converts basic markdown to HTML for article bodies.
|
|
5
|
+
* For more complex needs, consider using a library like marked or remark.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function renderMarkdown(md: string): string {
|
|
9
|
+
if (!md) return '';
|
|
10
|
+
|
|
11
|
+
return md
|
|
12
|
+
// Escape HTML entities
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
// Headers
|
|
17
|
+
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
|
18
|
+
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
|
19
|
+
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
|
20
|
+
// Bold and italic
|
|
21
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
22
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
23
|
+
// Lists
|
|
24
|
+
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
|
25
|
+
.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>')
|
|
26
|
+
// Paragraphs
|
|
27
|
+
.replace(/\n\n/g, '</p><p>')
|
|
28
|
+
.replace(/\n/g, '<br>')
|
|
29
|
+
.replace(/^/, '<p>')
|
|
30
|
+
.replace(/$/, '</p>')
|
|
31
|
+
// Clean up
|
|
32
|
+
.replace(/<p><\/p>/g, '')
|
|
33
|
+
.replace(/<p>(<[hu])/g, '$1')
|
|
34
|
+
.replace(/(<\/[hu]l>)<\/p>/g, '$1');
|
|
35
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Enable prerendering for static site generation
|
|
2
|
+
export const prerender = true;
|
|
3
|
+
|
|
4
|
+
// Use trailing slashes for clean URLs
|
|
5
|
+
export const trailingSlash = 'always';
|
|
6
|
+
|
|
7
|
+
import type { LayoutServerLoad } from './$types';
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { initSiteConfig } from '../site.config';
|
|
11
|
+
|
|
12
|
+
interface ForecastPeriod {
|
|
13
|
+
name: string;
|
|
14
|
+
conditions: string;
|
|
15
|
+
temperature: number;
|
|
16
|
+
windSpeed: number;
|
|
17
|
+
windDirection: number;
|
|
18
|
+
humidity: number;
|
|
19
|
+
precipProbability: number;
|
|
20
|
+
localHour: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ForecastDay {
|
|
24
|
+
day: string;
|
|
25
|
+
icon: string;
|
|
26
|
+
high: number;
|
|
27
|
+
low: number;
|
|
28
|
+
periods: ForecastPeriod[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getWeatherIcon(conditions: string): string {
|
|
32
|
+
const conditionsLower = conditions.toLowerCase();
|
|
33
|
+
|
|
34
|
+
if (conditionsLower.includes('sunny') || conditionsLower.includes('clear')) return '☀️';
|
|
35
|
+
if (conditionsLower.includes('partly cloudy') || conditionsLower.includes('a few clouds')) return '⛅';
|
|
36
|
+
if (conditionsLower.includes('cloud')) return '☁️';
|
|
37
|
+
if (conditionsLower.includes('rain') || conditionsLower.includes('shower')) return '🌧️';
|
|
38
|
+
if (conditionsLower.includes('snow')) return '❄️';
|
|
39
|
+
if (conditionsLower.includes('thunder') || conditionsLower.includes('storm')) return '⛈️';
|
|
40
|
+
if (conditionsLower.includes('fog')) return '🌫️';
|
|
41
|
+
|
|
42
|
+
return '🌤️';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface WeatherForecastData {
|
|
46
|
+
id?: string;
|
|
47
|
+
name: string;
|
|
48
|
+
issued_at: string;
|
|
49
|
+
conditions: string;
|
|
50
|
+
temperature?: string | number;
|
|
51
|
+
temperature_high?: string | number;
|
|
52
|
+
temperature_low?: string | number;
|
|
53
|
+
wind_speed?: string | number;
|
|
54
|
+
wind_direction?: string | number;
|
|
55
|
+
humidity?: string | number;
|
|
56
|
+
precipitation_probability?: string | number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const load: LayoutServerLoad = async () => {
|
|
60
|
+
// Load site config
|
|
61
|
+
const siteConfig = await initSiteConfig();
|
|
62
|
+
|
|
63
|
+
// Load weather data
|
|
64
|
+
let weather: ForecastDay[] | null = null;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const eventsPath = join(process.cwd(), 'data', 'events.json');
|
|
68
|
+
const eventsRaw = await readFile(eventsPath, 'utf-8');
|
|
69
|
+
const allEvents = JSON.parse(eventsRaw);
|
|
70
|
+
|
|
71
|
+
const allForecasts: WeatherForecastData[] = allEvents
|
|
72
|
+
.filter((e: any) => e._meta_type === 'WeatherForecast')
|
|
73
|
+
.map((e: any) => {
|
|
74
|
+
const meta = typeof e._meta_data === 'string' ? JSON.parse(e._meta_data) : e._meta_data;
|
|
75
|
+
return {
|
|
76
|
+
id: e.id,
|
|
77
|
+
name: e.name,
|
|
78
|
+
issued_at: meta?.issuedAt || e.created_at,
|
|
79
|
+
conditions: meta?.conditions || '',
|
|
80
|
+
temperature: meta?.temperature,
|
|
81
|
+
temperature_high: meta?.temperatureHigh,
|
|
82
|
+
temperature_low: meta?.temperatureLow,
|
|
83
|
+
wind_speed: meta?.windSpeed,
|
|
84
|
+
wind_direction: meta?.windDirection,
|
|
85
|
+
humidity: meta?.humidity,
|
|
86
|
+
precipitation_probability: meta?.precipProbability,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
allForecasts.sort((a, b) => new Date(a.issued_at).getTime() - new Date(b.issued_at).getTime());
|
|
91
|
+
|
|
92
|
+
if (allForecasts.length > 0) {
|
|
93
|
+
const hourlyForecastsRaw = allForecasts.filter((f) => f.name.includes('(') && f.name.includes(')'));
|
|
94
|
+
|
|
95
|
+
const dailyForecasts = new Map<
|
|
96
|
+
string,
|
|
97
|
+
{
|
|
98
|
+
high: number;
|
|
99
|
+
low: number;
|
|
100
|
+
conditions: string;
|
|
101
|
+
date: Date;
|
|
102
|
+
dayName: string;
|
|
103
|
+
periods: ForecastPeriod[];
|
|
104
|
+
temps: number[];
|
|
105
|
+
}
|
|
106
|
+
>();
|
|
107
|
+
|
|
108
|
+
const now = new Date();
|
|
109
|
+
const timezone = siteConfig.location.timezone || 'America/Edmonton';
|
|
110
|
+
const todayInTimezone = new Date(now.toLocaleString('en-US', { timeZone: timezone }));
|
|
111
|
+
|
|
112
|
+
for (const forecast of hourlyForecastsRaw) {
|
|
113
|
+
const timeMatch = forecast.name.match(/\((\d+):(\d+)\)/);
|
|
114
|
+
if (!timeMatch) continue;
|
|
115
|
+
|
|
116
|
+
const hour = parseInt(timeMatch[1]!);
|
|
117
|
+
const localHour = hour;
|
|
118
|
+
|
|
119
|
+
const fullDayName = forecast.name.split(' ')[0]!;
|
|
120
|
+
const dayName = fullDayName.substring(0, 3);
|
|
121
|
+
|
|
122
|
+
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
123
|
+
const forecastDayOfWeek = dayNames.indexOf(fullDayName);
|
|
124
|
+
|
|
125
|
+
if (forecastDayOfWeek === -1) continue;
|
|
126
|
+
|
|
127
|
+
const todayDayOfWeek = todayInTimezone.getDay();
|
|
128
|
+
let daysFromToday = forecastDayOfWeek - todayDayOfWeek;
|
|
129
|
+
if (daysFromToday < 0) daysFromToday += 7;
|
|
130
|
+
|
|
131
|
+
const forecastDate = new Date(todayInTimezone);
|
|
132
|
+
forecastDate.setDate(todayInTimezone.getDate() + daysFromToday);
|
|
133
|
+
|
|
134
|
+
const dateKey = forecastDate.toLocaleDateString('en-CA', { timeZone: timezone });
|
|
135
|
+
const temp = Math.round(Number(forecast.temperature) || 0);
|
|
136
|
+
|
|
137
|
+
const period: ForecastPeriod = {
|
|
138
|
+
name: forecast.name,
|
|
139
|
+
conditions: forecast.conditions,
|
|
140
|
+
temperature: temp,
|
|
141
|
+
windSpeed: Math.round(Number(forecast.wind_speed) || 0),
|
|
142
|
+
windDirection: Math.round(Number(forecast.wind_direction) || 0),
|
|
143
|
+
humidity: Math.round(Number(forecast.humidity) || 0),
|
|
144
|
+
precipProbability: Math.round(Number(forecast.precipitation_probability) || 0),
|
|
145
|
+
localHour,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!dailyForecasts.has(dateKey)) {
|
|
149
|
+
dailyForecasts.set(dateKey, {
|
|
150
|
+
high: temp,
|
|
151
|
+
low: temp,
|
|
152
|
+
conditions: forecast.conditions,
|
|
153
|
+
date: forecastDate,
|
|
154
|
+
dayName,
|
|
155
|
+
periods: [],
|
|
156
|
+
temps: [],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const day = dailyForecasts.get(dateKey)!;
|
|
161
|
+
day.periods.push(period);
|
|
162
|
+
day.temps.push(temp);
|
|
163
|
+
day.high = Math.max(day.high, temp);
|
|
164
|
+
day.low = Math.min(day.low, temp);
|
|
165
|
+
if (localHour >= 12 && localHour <= 17) {
|
|
166
|
+
day.conditions = forecast.conditions;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const [_, dayData] of dailyForecasts.entries()) {
|
|
171
|
+
dayData.periods.sort((a, b) => a.localHour - b.localHour);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
weather = Array.from(dailyForecasts.values())
|
|
175
|
+
.sort((a, b) => a.date.getTime() - b.date.getTime())
|
|
176
|
+
.slice(0, 10)
|
|
177
|
+
.map(({ high, low, conditions, dayName, periods }) => ({
|
|
178
|
+
day: dayName,
|
|
179
|
+
icon: getWeatherIcon(conditions),
|
|
180
|
+
high,
|
|
181
|
+
low,
|
|
182
|
+
periods,
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error('Failed to load weather:', error);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
siteConfig,
|
|
191
|
+
weather,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { LayoutData } from './$types';
|
|
3
|
+
import '../app.css';
|
|
4
|
+
import { Masthead, Footer, Container } from '@happyvertical/smrt-ui';
|
|
5
|
+
import WeatherHeader from '$lib/components/WeatherHeader.svelte';
|
|
6
|
+
|
|
7
|
+
let { children, data }: { children: any; data: LayoutData } = $props();
|
|
8
|
+
|
|
9
|
+
const { siteConfig, weather } = data;
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<svelte:head>
|
|
13
|
+
<meta charset="utf-8" />
|
|
14
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
15
|
+
<meta name="theme-color" content={siteConfig.theme?.primaryColor || '#1976d2'} />
|
|
16
|
+
{#if siteConfig.meta?.gtmId}
|
|
17
|
+
<!-- Google Tag Manager -->
|
|
18
|
+
{@html `<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
19
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
20
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
21
|
+
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
|
22
|
+
})(window,document,'script','dataLayer','${siteConfig.meta.gtmId}');</script>`}
|
|
23
|
+
{/if}
|
|
24
|
+
</svelte:head>
|
|
25
|
+
|
|
26
|
+
<div class="layout">
|
|
27
|
+
<div class="weather-header">
|
|
28
|
+
<Container>
|
|
29
|
+
<WeatherHeader forecast={weather} />
|
|
30
|
+
</Container>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<Masthead location={siteConfig.location.name}>
|
|
34
|
+
{#snippet nav()}
|
|
35
|
+
{#each siteConfig.navigation.primary as link}
|
|
36
|
+
<a href={link.href}>{link.label}</a>
|
|
37
|
+
{/each}
|
|
38
|
+
{/snippet}
|
|
39
|
+
</Masthead>
|
|
40
|
+
|
|
41
|
+
<main class="main">
|
|
42
|
+
<Container>
|
|
43
|
+
{@render children?.()}
|
|
44
|
+
</Container>
|
|
45
|
+
</main>
|
|
46
|
+
|
|
47
|
+
<Footer>
|
|
48
|
+
{#snippet children()}
|
|
49
|
+
{#each siteConfig.navigation.footer || [] as link, i}
|
|
50
|
+
{#if i > 0} • {/if}
|
|
51
|
+
<a href={link.href}>{link.label}</a>
|
|
52
|
+
{/each}
|
|
53
|
+
{/snippet}
|
|
54
|
+
</Footer>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.layout {
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
min-height: 100vh;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.weather-header {
|
|
65
|
+
background: var(--color-neutral-white);
|
|
66
|
+
border-bottom: 1px solid var(--color-neutral-gray300);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.main {
|
|
70
|
+
flex: 1;
|
|
71
|
+
padding: var(--spacing-xl) 0;
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { PageServerLoad } from './$types';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { getSite } from '../site.config';
|
|
5
|
+
|
|
6
|
+
export const prerender = true;
|
|
7
|
+
|
|
8
|
+
const ARTICLES_PER_PAGE = 5;
|
|
9
|
+
|
|
10
|
+
function getAllArticles() {
|
|
11
|
+
try {
|
|
12
|
+
const contentsPath = join(process.cwd(), 'data', 'contents.json');
|
|
13
|
+
const contentsJson = readFileSync(contentsPath, 'utf-8');
|
|
14
|
+
const allContents = JSON.parse(contentsJson);
|
|
15
|
+
|
|
16
|
+
return allContents
|
|
17
|
+
.filter((content: any) => content._meta_type === 'MeetingRecap')
|
|
18
|
+
.sort((a: any, b: any) => new Date(b.publish_date).getTime() - new Date(a.publish_date).getTime());
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mapArticle(article: any) {
|
|
25
|
+
return {
|
|
26
|
+
id: article.id,
|
|
27
|
+
slug: article.slug,
|
|
28
|
+
title: article.title || '',
|
|
29
|
+
description: article.description || '',
|
|
30
|
+
body: article.body || '',
|
|
31
|
+
publish_date: article.publish_date || article.created_at,
|
|
32
|
+
created_at: article.created_at,
|
|
33
|
+
meeting_id: article.meeting_id,
|
|
34
|
+
council_id: article.council_id,
|
|
35
|
+
category: article.category || null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const load: PageServerLoad = async ({ parent }) => {
|
|
40
|
+
const { siteConfig } = await parent();
|
|
41
|
+
|
|
42
|
+
const articles = getAllArticles();
|
|
43
|
+
const totalArticles = articles.length;
|
|
44
|
+
const totalPages = Math.ceil(totalArticles / ARTICLES_PER_PAGE);
|
|
45
|
+
|
|
46
|
+
const articlesData = articles.slice(0, ARTICLES_PER_PAGE).map(mapArticle);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
siteConfig,
|
|
50
|
+
articles: articlesData,
|
|
51
|
+
events: [],
|
|
52
|
+
pagination: {
|
|
53
|
+
currentPage: 1,
|
|
54
|
+
totalPages,
|
|
55
|
+
totalArticles,
|
|
56
|
+
hasNextPage: totalPages > 1,
|
|
57
|
+
hasPrevPage: false,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { PageData } from './$types';
|
|
3
|
+
import { Pagination } from '@happyvertical/smrt-ui';
|
|
4
|
+
|
|
5
|
+
let { data }: { data: PageData } = $props();
|
|
6
|
+
const { articles, pagination, siteConfig } = data;
|
|
7
|
+
|
|
8
|
+
function formatDate(dateString: string): string {
|
|
9
|
+
return new Date(dateString).toLocaleDateString('en-CA', {
|
|
10
|
+
year: 'numeric',
|
|
11
|
+
month: 'long',
|
|
12
|
+
day: 'numeric',
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<svelte:head>
|
|
18
|
+
<title>{siteConfig.name} - Local News & Weather</title>
|
|
19
|
+
<meta name="description" content={siteConfig.description} />
|
|
20
|
+
</svelte:head>
|
|
21
|
+
|
|
22
|
+
<div class="home">
|
|
23
|
+
<section class="articles">
|
|
24
|
+
<h1>Latest News</h1>
|
|
25
|
+
|
|
26
|
+
{#if articles.length === 0}
|
|
27
|
+
<div class="empty-state">
|
|
28
|
+
<p>No articles yet. Run <code>pnpm workflow:praeco</code> to generate content from council meetings.</p>
|
|
29
|
+
</div>
|
|
30
|
+
{:else}
|
|
31
|
+
<div class="article-list">
|
|
32
|
+
{#each articles as article}
|
|
33
|
+
<article class="article-card">
|
|
34
|
+
<a href="/{article.category}/{article.slug}/">
|
|
35
|
+
<h2>{article.title}</h2>
|
|
36
|
+
<p class="description">{article.description}</p>
|
|
37
|
+
<time datetime={article.publish_date}>{formatDate(article.publish_date)}</time>
|
|
38
|
+
</a>
|
|
39
|
+
</article>
|
|
40
|
+
{/each}
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{#if pagination.totalPages > 1}
|
|
44
|
+
<Pagination
|
|
45
|
+
currentPage={pagination.currentPage}
|
|
46
|
+
totalPages={pagination.totalPages}
|
|
47
|
+
baseUrl="/page"
|
|
48
|
+
/>
|
|
49
|
+
{/if}
|
|
50
|
+
{/if}
|
|
51
|
+
</section>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.home {
|
|
56
|
+
max-width: 800px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
h1 {
|
|
60
|
+
font-size: var(--font-size-2xl);
|
|
61
|
+
margin-bottom: var(--spacing-lg);
|
|
62
|
+
color: var(--color-neutral-gray900);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.empty-state {
|
|
66
|
+
padding: var(--spacing-xl);
|
|
67
|
+
background: var(--color-neutral-gray100);
|
|
68
|
+
border-radius: var(--radius-md);
|
|
69
|
+
text-align: center;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.empty-state code {
|
|
73
|
+
background: var(--color-neutral-gray200);
|
|
74
|
+
padding: var(--spacing-xs) var(--spacing-sm);
|
|
75
|
+
border-radius: var(--radius-sm);
|
|
76
|
+
font-family: monospace;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.article-list {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: var(--spacing-lg);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.article-card {
|
|
86
|
+
border-bottom: 1px solid var(--color-neutral-gray200);
|
|
87
|
+
padding-bottom: var(--spacing-lg);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.article-card:last-child {
|
|
91
|
+
border-bottom: none;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.article-card a {
|
|
95
|
+
text-decoration: none;
|
|
96
|
+
color: inherit;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.article-card h2 {
|
|
100
|
+
font-size: var(--font-size-xl);
|
|
101
|
+
margin-bottom: var(--spacing-sm);
|
|
102
|
+
color: var(--color-primary);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.article-card:hover h2 {
|
|
106
|
+
text-decoration: underline;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.description {
|
|
110
|
+
color: var(--color-neutral-gray700);
|
|
111
|
+
margin-bottom: var(--spacing-sm);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
time {
|
|
115
|
+
font-size: var(--font-size-sm);
|
|
116
|
+
color: var(--color-neutral-gray500);
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { PageData } from './$types';
|
|
3
|
+
|
|
4
|
+
let { data }: { data: PageData } = $props();
|
|
5
|
+
const { siteConfig } = data;
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<svelte:head>
|
|
9
|
+
<title>About - {siteConfig.name}</title>
|
|
10
|
+
<meta name="description" content="About {siteConfig.name}" />
|
|
11
|
+
</svelte:head>
|
|
12
|
+
|
|
13
|
+
<div class="about">
|
|
14
|
+
<h1>About {siteConfig.name}</h1>
|
|
15
|
+
|
|
16
|
+
<section>
|
|
17
|
+
<h2>Our Mission</h2>
|
|
18
|
+
<p>
|
|
19
|
+
{siteConfig.name} provides timely, accurate, and comprehensive coverage of local government,
|
|
20
|
+
community events, and news for {siteConfig.location.name} and surrounding areas.
|
|
21
|
+
</p>
|
|
22
|
+
</section>
|
|
23
|
+
|
|
24
|
+
<section>
|
|
25
|
+
<h2>What We Cover</h2>
|
|
26
|
+
<ul>
|
|
27
|
+
<li><strong>Local Government</strong> - Council meetings, decisions, and policy changes</li>
|
|
28
|
+
<li><strong>County News</strong> - Regional government coverage</li>
|
|
29
|
+
<li><strong>Weather</strong> - Local forecasts from Environment Canada</li>
|
|
30
|
+
<li><strong>Community Events</strong> - Local happenings and announcements</li>
|
|
31
|
+
</ul>
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<section>
|
|
35
|
+
<h2>How It Works</h2>
|
|
36
|
+
<p>
|
|
37
|
+
Our coverage is powered by AI-assisted journalism that processes official government documents
|
|
38
|
+
to create clear, accessible summaries of council meetings and decisions. This helps residents
|
|
39
|
+
stay informed about matters that affect their daily lives.
|
|
40
|
+
</p>
|
|
41
|
+
</section>
|
|
42
|
+
|
|
43
|
+
<section>
|
|
44
|
+
<h2>Contact</h2>
|
|
45
|
+
<p>
|
|
46
|
+
Have questions, feedback, or a tip? <a href="/contact">Contact us</a>.
|
|
47
|
+
</p>
|
|
48
|
+
</section>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<style>
|
|
52
|
+
.about {
|
|
53
|
+
max-width: 700px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
h1 {
|
|
57
|
+
font-size: var(--font-size-2xl);
|
|
58
|
+
margin-bottom: var(--spacing-xl);
|
|
59
|
+
color: var(--color-neutral-gray900);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
section {
|
|
63
|
+
margin-bottom: var(--spacing-xl);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
h2 {
|
|
67
|
+
font-size: var(--font-size-lg);
|
|
68
|
+
margin-bottom: var(--spacing-md);
|
|
69
|
+
color: var(--color-neutral-gray800);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
p {
|
|
73
|
+
line-height: 1.7;
|
|
74
|
+
color: var(--color-neutral-gray700);
|
|
75
|
+
margin-bottom: var(--spacing-md);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ul {
|
|
79
|
+
list-style: disc;
|
|
80
|
+
padding-left: var(--spacing-lg);
|
|
81
|
+
color: var(--color-neutral-gray700);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
li {
|
|
85
|
+
margin-bottom: var(--spacing-sm);
|
|
86
|
+
line-height: 1.6;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
a {
|
|
90
|
+
color: var(--color-primary);
|
|
91
|
+
}
|
|
92
|
+
</style>
|