@sprig-and-prose/sprig-ui-csr 0.1.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/README.md +120 -0
- package/biome.json +37 -0
- package/index.html +12 -0
- package/manifest.json +10711 -0
- package/package.json +29 -0
- package/src/App.svelte +202 -0
- package/src/cli.js +146 -0
- package/src/lib/components/ContentsCard.svelte +167 -0
- package/src/lib/components/FooterStatus.svelte +80 -0
- package/src/lib/components/GlobalSearch.svelte +451 -0
- package/src/lib/components/PageHeader.svelte +116 -0
- package/src/lib/components/Prose.svelte +260 -0
- package/src/lib/components/UniverseHeader.svelte +20 -0
- package/src/lib/data/universeStore.js +252 -0
- package/src/lib/format/title.js +97 -0
- package/src/lib/references/isWildcardPath.js +9 -0
- package/src/lib/references/linkForPath.js +65 -0
- package/src/lib/references/linkForRepository.js +42 -0
- package/src/lib/router.js +75 -0
- package/src/lib/stores/describeRenderMode.js +9 -0
- package/src/lib/stores/theme.js +98 -0
- package/src/main.js +143 -0
- package/src/pages/AnthologyPage.svelte +84 -0
- package/src/pages/ConceptPage.svelte +873 -0
- package/src/pages/HomePage.svelte +80 -0
- package/src/pages/SeriesPage.svelte +657 -0
- package/src/server.js +115 -0
- package/src/styles/app.css +353 -0
- package/tsconfig.json +16 -0
- package/vite.config.js +10 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { get } from 'svelte/store';
|
|
2
|
+
import { universeGraph } from '../data/universeStore.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds a URL for a repository root
|
|
6
|
+
* @param {string} repository - Repository key (e.g., "amaranthine")
|
|
7
|
+
* @returns {string | null} - Full URL to the repository root, or null if repository is unconfigured
|
|
8
|
+
*/
|
|
9
|
+
export function linkForRepository(repository) {
|
|
10
|
+
const graph = get(universeGraph);
|
|
11
|
+
if (!graph?.repositories) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const repoConfig = graph.repositories[repository];
|
|
16
|
+
if (!repoConfig) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { kind, options } = repoConfig;
|
|
21
|
+
|
|
22
|
+
// Handle GitHub repositories
|
|
23
|
+
if (kind === 'sprig-repository-github') {
|
|
24
|
+
// Prefer url if available, otherwise build from owner/repo
|
|
25
|
+
if (options?.url) {
|
|
26
|
+
const baseUrl = options.url;
|
|
27
|
+
// Remove trailing slash if present
|
|
28
|
+
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const owner = options?.owner;
|
|
32
|
+
const repo = options?.repo;
|
|
33
|
+
if (owner && repo) {
|
|
34
|
+
const baseUrl = `https://github.com/${owner}/${repo}`;
|
|
35
|
+
// Remove trailing slash if present
|
|
36
|
+
return baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple client-side router for CSR app
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a route and extract parameters
|
|
7
|
+
* @param {string} pathname
|
|
8
|
+
* @returns {{ route: string, params: Record<string, string> } | null}
|
|
9
|
+
*/
|
|
10
|
+
export function parseRoute(pathname) {
|
|
11
|
+
// Remove leading/trailing slashes
|
|
12
|
+
const path = pathname.replace(/^\/+|\/+$/g, '');
|
|
13
|
+
|
|
14
|
+
// Root route
|
|
15
|
+
if (!path || path === '') {
|
|
16
|
+
return { route: 'home', params: {} };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Match /universes/:universe/series/:series
|
|
20
|
+
const seriesMatch = path.match(/^universes\/([^/]+)\/series\/([^/]+)$/);
|
|
21
|
+
if (seriesMatch) {
|
|
22
|
+
return {
|
|
23
|
+
route: 'series',
|
|
24
|
+
params: {
|
|
25
|
+
universe: seriesMatch[1],
|
|
26
|
+
series: seriesMatch[2],
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Match /universes/:universe/anthology/:anthology
|
|
32
|
+
const anthologyMatch = path.match(/^universes\/([^/]+)\/anthology\/([^/]+)$/);
|
|
33
|
+
if (anthologyMatch) {
|
|
34
|
+
return {
|
|
35
|
+
route: 'anthology',
|
|
36
|
+
params: {
|
|
37
|
+
universe: anthologyMatch[1],
|
|
38
|
+
anthology: anthologyMatch[2],
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Match /universes/:universe/concept/:id
|
|
44
|
+
const conceptMatch = path.match(/^universes\/([^/]+)\/concept\/(.+)$/);
|
|
45
|
+
if (conceptMatch) {
|
|
46
|
+
return {
|
|
47
|
+
route: 'concept',
|
|
48
|
+
params: {
|
|
49
|
+
universe: conceptMatch[1],
|
|
50
|
+
id: conceptMatch[2],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Unknown route
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get current route from window.location
|
|
61
|
+
* @returns {{ route: string, params: Record<string, string> } | null}
|
|
62
|
+
*/
|
|
63
|
+
export function getCurrentRoute() {
|
|
64
|
+
return parseRoute(window.location.pathname);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Navigate to a new route
|
|
69
|
+
* @param {string} path
|
|
70
|
+
*/
|
|
71
|
+
export function navigate(path) {
|
|
72
|
+
window.history.pushState({}, '', path);
|
|
73
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
74
|
+
}
|
|
75
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { writable } from 'svelte/store';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get system preference for color scheme
|
|
5
|
+
* @returns {'light' | 'dark'}
|
|
6
|
+
*/
|
|
7
|
+
function getSystemPreference() {
|
|
8
|
+
if (typeof window === 'undefined') return 'light';
|
|
9
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get initial theme: manual preference from localStorage, or system preference
|
|
14
|
+
* @returns {'light' | 'dark' | 'auto'}
|
|
15
|
+
*/
|
|
16
|
+
function getInitialTheme() {
|
|
17
|
+
if (typeof localStorage === 'undefined') return 'auto';
|
|
18
|
+
const stored = localStorage.getItem('theme');
|
|
19
|
+
if (stored === 'light' || stored === 'dark') return stored;
|
|
20
|
+
return 'auto'; // 'auto' means use system preference
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get effective theme (resolves 'auto' to system preference)
|
|
25
|
+
* @param {'light' | 'dark' | 'auto'} theme
|
|
26
|
+
* @returns {'light' | 'dark'}
|
|
27
|
+
*/
|
|
28
|
+
function getEffectiveTheme(theme) {
|
|
29
|
+
if (theme === 'auto') {
|
|
30
|
+
return getSystemPreference();
|
|
31
|
+
}
|
|
32
|
+
return theme;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const initial = getInitialTheme();
|
|
36
|
+
export const theme = writable(initial);
|
|
37
|
+
|
|
38
|
+
// Apply theme to document
|
|
39
|
+
function applyTheme(themeValue) {
|
|
40
|
+
if (typeof document === 'undefined') return;
|
|
41
|
+
const effective = getEffectiveTheme(themeValue);
|
|
42
|
+
document.documentElement.dataset.theme = effective;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Listen for system preference changes when theme is 'auto'
|
|
46
|
+
let mediaQuery = null;
|
|
47
|
+
let mediaQueryListener = null;
|
|
48
|
+
|
|
49
|
+
function setupSystemPreferenceListener() {
|
|
50
|
+
if (typeof window === 'undefined') return;
|
|
51
|
+
if (mediaQuery) return; // Already set up
|
|
52
|
+
|
|
53
|
+
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
54
|
+
mediaQueryListener = () => {
|
|
55
|
+
// Re-read current theme value and reapply if it's 'auto'
|
|
56
|
+
const unsubscribe = theme.subscribe((t) => {
|
|
57
|
+
unsubscribe(); // Immediately unsubscribe to avoid memory leak
|
|
58
|
+
if (t === 'auto') {
|
|
59
|
+
applyTheme('auto');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
mediaQuery.addEventListener('change', mediaQueryListener);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cleanupSystemPreferenceListener() {
|
|
67
|
+
if (mediaQuery && mediaQueryListener) {
|
|
68
|
+
mediaQuery.removeEventListener('change', mediaQueryListener);
|
|
69
|
+
mediaQuery = null;
|
|
70
|
+
mediaQueryListener = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Subscribe to theme changes
|
|
75
|
+
let currentThemeValue = initial;
|
|
76
|
+
theme.subscribe((t) => {
|
|
77
|
+
currentThemeValue = t;
|
|
78
|
+
applyTheme(t);
|
|
79
|
+
if (typeof localStorage !== 'undefined') {
|
|
80
|
+
localStorage.setItem('theme', t);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Setup/cleanup system preference listener based on theme
|
|
84
|
+
if (t === 'auto') {
|
|
85
|
+
setupSystemPreferenceListener();
|
|
86
|
+
} else {
|
|
87
|
+
cleanupSystemPreferenceListener();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Initialize: apply theme and setup listener if needed
|
|
92
|
+
if (typeof window !== 'undefined') {
|
|
93
|
+
applyTheme(initial);
|
|
94
|
+
if (initial === 'auto') {
|
|
95
|
+
setupSystemPreferenceListener();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
package/src/main.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import './styles/app.css';
|
|
2
|
+
import './lib/stores/theme.js'; // Initialize theme store
|
|
3
|
+
import { theme } from './lib/stores/theme.js';
|
|
4
|
+
import { get } from 'svelte/store';
|
|
5
|
+
import App from './App.svelte';
|
|
6
|
+
import { mount } from 'svelte';
|
|
7
|
+
|
|
8
|
+
const target = document.getElementById('app');
|
|
9
|
+
if (!target) {
|
|
10
|
+
throw new Error('Target element #app not found');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const app = mount(App, {
|
|
14
|
+
target,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// Initialize texture generation after component mounts (outside component to avoid effect_orphan)
|
|
18
|
+
async function initTexture() {
|
|
19
|
+
try {
|
|
20
|
+
const textureModule = await import('@sprig-and-prose/sprig-texture');
|
|
21
|
+
const { generateTexture, releaseObjectUrl, sprigGrey, parchment, subtle } = textureModule;
|
|
22
|
+
|
|
23
|
+
const textureState = {
|
|
24
|
+
light: null,
|
|
25
|
+
dark: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get system preference for color scheme
|
|
30
|
+
* @returns {'light' | 'dark'}
|
|
31
|
+
*/
|
|
32
|
+
function getSystemPreference() {
|
|
33
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get effective theme (resolves 'auto' to system preference)
|
|
38
|
+
* @param {'light' | 'dark' | 'auto'} themeValue
|
|
39
|
+
* @returns {'light' | 'dark'}
|
|
40
|
+
*/
|
|
41
|
+
function getEffectiveTheme(themeValue) {
|
|
42
|
+
if (themeValue === 'auto') {
|
|
43
|
+
return getSystemPreference();
|
|
44
|
+
}
|
|
45
|
+
return themeValue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function updateTexture(themeValue) {
|
|
49
|
+
const effectiveTheme = getEffectiveTheme(themeValue);
|
|
50
|
+
const url = effectiveTheme === 'dark' ? textureState.dark : textureState.light;
|
|
51
|
+
if (url) {
|
|
52
|
+
document.body.style.backgroundImage = `url(${url})`;
|
|
53
|
+
document.body.style.backgroundRepeat = 'repeat';
|
|
54
|
+
document.body.style.backgroundSize = '1024px 1024px';
|
|
55
|
+
document.body.style.backgroundAttachment = 'fixed';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Generate both textures
|
|
60
|
+
const { url: lightUrl } = await generateTexture({
|
|
61
|
+
key: 'sprig-ui-csr-light',
|
|
62
|
+
palette: parchment,
|
|
63
|
+
preset: subtle,
|
|
64
|
+
});
|
|
65
|
+
textureState.light = lightUrl;
|
|
66
|
+
|
|
67
|
+
const { url: darkUrl } = await generateTexture({
|
|
68
|
+
key: 'sprig-ui-csr-dark',
|
|
69
|
+
palette: sprigGrey,
|
|
70
|
+
preset: subtle,
|
|
71
|
+
});
|
|
72
|
+
textureState.dark = darkUrl;
|
|
73
|
+
|
|
74
|
+
// Track current theme value
|
|
75
|
+
let currentThemeValue = get(theme);
|
|
76
|
+
|
|
77
|
+
// Listen for system preference changes when theme is 'auto'
|
|
78
|
+
let mediaQuery = null;
|
|
79
|
+
let mediaQueryListener = null;
|
|
80
|
+
|
|
81
|
+
function setupSystemPreferenceListener() {
|
|
82
|
+
if (typeof window === 'undefined') return;
|
|
83
|
+
if (mediaQuery) return; // Already set up
|
|
84
|
+
|
|
85
|
+
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
86
|
+
mediaQueryListener = () => {
|
|
87
|
+
// Only update texture if current theme is 'auto'
|
|
88
|
+
if (currentThemeValue === 'auto') {
|
|
89
|
+
updateTexture('auto');
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
mediaQuery.addEventListener('change', mediaQueryListener);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function cleanupSystemPreferenceListener() {
|
|
96
|
+
if (mediaQuery && mediaQueryListener) {
|
|
97
|
+
mediaQuery.removeEventListener('change', mediaQueryListener);
|
|
98
|
+
mediaQuery = null;
|
|
99
|
+
mediaQueryListener = null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Subscribe to theme changes
|
|
104
|
+
const unsubscribe = theme.subscribe((themeValue) => {
|
|
105
|
+
currentThemeValue = themeValue;
|
|
106
|
+
updateTexture(themeValue);
|
|
107
|
+
|
|
108
|
+
// Setup/cleanup system preference listener based on theme
|
|
109
|
+
if (themeValue === 'auto') {
|
|
110
|
+
setupSystemPreferenceListener();
|
|
111
|
+
} else {
|
|
112
|
+
cleanupSystemPreferenceListener();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Apply initial texture
|
|
117
|
+
updateTexture(get(theme));
|
|
118
|
+
|
|
119
|
+
// Setup listener if initial theme is 'auto'
|
|
120
|
+
if (get(theme) === 'auto') {
|
|
121
|
+
setupSystemPreferenceListener();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Store cleanup on window for potential cleanup
|
|
125
|
+
window.__textureCleanup = () => {
|
|
126
|
+
unsubscribe();
|
|
127
|
+
cleanupSystemPreferenceListener();
|
|
128
|
+
if (textureState.light) {
|
|
129
|
+
releaseObjectUrl(textureState.light);
|
|
130
|
+
}
|
|
131
|
+
if (textureState.dark) {
|
|
132
|
+
releaseObjectUrl(textureState.dark);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('Failed to generate texture:', error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Initialize texture after component is mounted
|
|
141
|
+
setTimeout(initTexture, 0);
|
|
142
|
+
|
|
143
|
+
export default app;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { currentAnthology, anthologySeries, universeGraph, currentUniverseName, universeRootNode, getNodeRoute, currentAnthologyId } from '../lib/data/universeStore.js';
|
|
3
|
+
import { getDisplayTitle } from '../lib/format/title.js';
|
|
4
|
+
import Prose from '../lib/components/Prose.svelte';
|
|
5
|
+
import ContentsCard from '../lib/components/ContentsCard.svelte';
|
|
6
|
+
import FooterStatus from '../lib/components/FooterStatus.svelte';
|
|
7
|
+
import PageHeader from '../lib/components/PageHeader.svelte';
|
|
8
|
+
|
|
9
|
+
/** @type {{ universe: string, anthology: string }} */
|
|
10
|
+
export let params;
|
|
11
|
+
|
|
12
|
+
// Update stores reactively when params change
|
|
13
|
+
$: {
|
|
14
|
+
const anthologyId = `${params.universe}:anthology:${params.anthology}`;
|
|
15
|
+
currentAnthologyId.set(anthologyId);
|
|
16
|
+
currentUniverseName.set(params.universe);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
$: subtitle = $universeRootNode
|
|
20
|
+
? `An anthology in <a href="/">${getDisplayTitle($universeRootNode) || params.universe}</a>`
|
|
21
|
+
: `An anthology in ${params.universe}`;
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if $currentAnthology}
|
|
25
|
+
<PageHeader title={getDisplayTitle($currentAnthology)} {subtitle} />
|
|
26
|
+
|
|
27
|
+
<div class="grid">
|
|
28
|
+
<section class="narrative">
|
|
29
|
+
{#if $currentAnthology.describe}
|
|
30
|
+
<Prose textBlock={$currentAnthology.describe} />
|
|
31
|
+
{/if}
|
|
32
|
+
</section>
|
|
33
|
+
|
|
34
|
+
<aside class="index">
|
|
35
|
+
<ContentsCard children={$anthologySeries} />
|
|
36
|
+
</aside>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<FooterStatus graph={$universeGraph} root={$universeRootNode} />
|
|
40
|
+
{:else}
|
|
41
|
+
<div class="loading">Loading…</div>
|
|
42
|
+
{/if}
|
|
43
|
+
|
|
44
|
+
<style>
|
|
45
|
+
.grid {
|
|
46
|
+
display: grid;
|
|
47
|
+
grid-template-columns: 1fr 350px;
|
|
48
|
+
gap: 64px;
|
|
49
|
+
align-items: start;
|
|
50
|
+
max-width: 1200px;
|
|
51
|
+
margin: 0 auto;
|
|
52
|
+
width: 100%;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.narrative {
|
|
56
|
+
max-width: 680px;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
width: 100%;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.index {
|
|
62
|
+
min-width: 0;
|
|
63
|
+
width: 100%;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.loading {
|
|
67
|
+
color: var(--text-tertiary);
|
|
68
|
+
padding: 24px 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@media (max-width: 768px) {
|
|
72
|
+
.grid {
|
|
73
|
+
grid-template-columns: 1fr;
|
|
74
|
+
gap: 32px;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@media (max-width: 480px) {
|
|
79
|
+
.grid {
|
|
80
|
+
gap: 32px;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
</style>
|
|
84
|
+
|