@ewanc26/pds-landing 0.1.2 → 2.0.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 CHANGED
@@ -1,22 +1,132 @@
1
- # pds-landing
1
+ # @ewanc26/pds-landing
2
2
 
3
- Static landing page for [pds.ewancroft.uk](https://pds.ewancroft.uk)a personal ATProto PDS.
3
+ Composable Svelte 5 components for an ATProto PDS landing page terminal aesthetic, live status fetching, zero config to drop in.
4
4
 
5
- Built with SvelteKit, Svelte 5, TypeScript, and Tailwind CSS v4.
5
+ ## Install
6
6
 
7
- Displays live PDS status by querying `/xrpc/_health` and
8
- `/xrpc/com.atproto.server.describeServer` on load.
7
+ ```bash
8
+ pnpm add @ewanc26/pds-landing @ewanc26/ui
9
+ ```
10
+
11
+ ## Quick start — full page
12
+
13
+ ```svelte
14
+ <script>
15
+ import { PDSPage } from '@ewanc26/pds-landing';
16
+ </script>
17
+
18
+ <!-- Drop-in: renders the complete terminal landing page -->
19
+ <PDSPage
20
+ cardTitle="ewan's pds"
21
+ promptUser="server"
22
+ promptHost="pds.ewancroft.uk"
23
+ tagline="Bluesky-compatible ATProto PDS · personal instance"
24
+ blueskyHandle="ewancroft.uk"
25
+ />
26
+ ```
27
+
28
+ Import the PDS design tokens and base styles once in your layout:
29
+
30
+ ```css
31
+ /* app.css / layout.css */
32
+ @import '@ewanc26/ui/styles/pds-tokens.css';
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Mix-and-match — primitives
38
+
39
+ All primitives are exported individually so you can compose custom layouts.
40
+
41
+ ```svelte
42
+ <script>
43
+ import {
44
+ TerminalCard,
45
+ PromptLine,
46
+ Tagline,
47
+ SectionLabel,
48
+ Divider,
49
+ StatusGrid,
50
+ LinkList,
51
+ ContactSection,
52
+ PDSFooter,
53
+ } from '@ewanc26/pds-landing';
54
+ </script>
55
+
56
+ <TerminalCard title="my pds">
57
+ <PromptLine user="server" host="pds.example.com" />
58
+ <Tagline text="My custom tagline" />
59
+
60
+ <SectionLabel label="status" />
61
+ <StatusGrid /> <!-- fetches live from same origin -->
62
+
63
+ <Divider />
9
64
 
10
- ## Build
65
+ <SectionLabel label="links" />
66
+ <LinkList links={[
67
+ { href: 'https://bsky.app', label: 'Bluesky' },
68
+ { href: 'https://atproto.com', label: 'ATProto docs' },
69
+ ]} />
11
70
 
12
- ```sh
13
- pnpm build
71
+ <Divider />
72
+
73
+ <SectionLabel label="contact" />
74
+ <ContactSection blueskyHandle="you.bsky.social" />
75
+ </TerminalCard>
76
+
77
+ <PDSFooter />
78
+ ```
79
+
80
+ ### Use raw KV data
81
+
82
+ ```svelte
83
+ <script>
84
+ import { KVGrid } from '@ewanc26/pds-landing';
85
+ import type { KVItem } from '@ewanc26/pds-landing';
86
+
87
+ const items: KVItem[] = [
88
+ { key: 'status', value: '✓ online', status: 'ok' },
89
+ { key: 'region', value: 'eu-west-1' },
90
+ { key: 'invite', value: 'required', status: 'warn' },
91
+ ];
92
+ </script>
93
+
94
+ <KVGrid {items} />
14
95
  ```
15
96
 
16
- Output is in `build/` — a directory of static files suitable for serving with Caddy or any file server.
97
+ ### Fetch status yourself
17
98
 
18
- ## Dev
99
+ ```ts
100
+ import { fetchPDSStatus } from '@ewanc26/pds-landing';
19
101
 
20
- ```sh
21
- pnpm dev
102
+ const { health, description, accountCount } = await fetchPDSStatus('https://pds.example.com');
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Components
108
+
109
+ | Component | Description |
110
+ |---|---|
111
+ | `PDSPage` | Full assembled landing page (convenience) |
112
+ | `TerminalCard` | Terminal window shell with traffic-light dots titlebar |
113
+ | `PromptLine` | `user@host:path $` bash prompt header |
114
+ | `Tagline` | Dimmed subtitle beneath the prompt |
115
+ | `SectionLabel` | Uppercase section heading |
116
+ | `Divider` | Thin green-tinted `<hr>` |
117
+ | `KVGrid` | Key-value grid with ok/warn/err/loading states |
118
+ | `StatusGrid` | Live-fetching PDS status grid (wraps `KVGrid`) |
119
+ | `LinkList` | `→ link` list |
120
+ | `ContactSection` | Bluesky mention + optional email |
121
+ | `PDSFooter` | Footer with nixpkgs / atproto links |
122
+
123
+ ## Design tokens
124
+
125
+ All components consume CSS custom properties from `@ewanc26/ui/styles/pds-tokens.css`:
126
+
127
+ ```
128
+ --pds-font-mono
129
+ --pds-color-crust / mantle / base / surface-0 / surface-1 / overlay-0
130
+ --pds-color-text / subtext-0
131
+ --pds-color-green / red / yellow / shadow
22
132
  ```
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /**
4
+ * Bluesky handle (without @) to link to.
5
+ * Example: `'ewancroft.uk'`
6
+ */
7
+ blueskyHandle?: string;
8
+ /**
9
+ * Base URL of the Bluesky web client used to build the profile link.
10
+ * Defaults to `'https://bsky.app'`.
11
+ */
12
+ blueskyClientUrl?: string;
13
+ /** Contact email address. Rendered only when provided. */
14
+ email?: string;
15
+ }
16
+
17
+ let {
18
+ blueskyHandle,
19
+ blueskyClientUrl = 'https://bsky.app',
20
+ email
21
+ }: Props = $props();
22
+
23
+ let profileUrl = $derived(
24
+ blueskyHandle ? `${blueskyClientUrl}/profile/${blueskyHandle}` : null
25
+ );
26
+ </script>
27
+
28
+ {#if blueskyHandle && profileUrl}
29
+ <p class="pds-contact-note">
30
+ Send a mention on Bluesky to
31
+ <a href={profileUrl} target="_blank" rel="noopener">@{blueskyHandle}</a>
32
+ </p>
33
+ {/if}
34
+
35
+ {#if email}
36
+ <p class="pds-contact-note pds-contact-email-row">
37
+ Email: <a href="mailto:{email}">{email}</a>
38
+ </p>
39
+ {/if}
40
+
41
+ <style>
42
+ .pds-contact-note {
43
+ font-size: 0.88em;
44
+ color: var(--pds-color-subtext-0);
45
+ }
46
+
47
+ .pds-contact-email-row {
48
+ margin-top: 0.4rem;
49
+ }
50
+
51
+ .pds-contact-note a {
52
+ text-decoration: none;
53
+ color: var(--pds-color-green);
54
+ }
55
+
56
+ .pds-contact-note a:hover {
57
+ text-decoration: underline;
58
+ }
59
+ </style>
@@ -0,0 +1,17 @@
1
+ interface Props {
2
+ /**
3
+ * Bluesky handle (without @) to link to.
4
+ * Example: `'ewancroft.uk'`
5
+ */
6
+ blueskyHandle?: string;
7
+ /**
8
+ * Base URL of the Bluesky web client used to build the profile link.
9
+ * Defaults to `'https://bsky.app'`.
10
+ */
11
+ blueskyClientUrl?: string;
12
+ /** Contact email address. Rendered only when provided. */
13
+ email?: string;
14
+ }
15
+ declare const ContactSection: import("svelte").Component<Props, {}, "">;
16
+ type ContactSection = ReturnType<typeof ContactSection>;
17
+ export default ContactSection;
@@ -0,0 +1,9 @@
1
+ <hr class="pds-divider" />
2
+
3
+ <style>
4
+ .pds-divider {
5
+ border: none;
6
+ border-top: 1px solid color-mix(in srgb, var(--pds-color-green) 12%, transparent);
7
+ margin: 1.2rem 0;
8
+ }
9
+ </style>
@@ -0,0 +1,26 @@
1
+ export default Divider;
2
+ type Divider = SvelteComponent<{
3
+ [x: string]: never;
4
+ }, {
5
+ [evt: string]: CustomEvent<any>;
6
+ }, {}> & {
7
+ $$bindings?: string | undefined;
8
+ };
9
+ declare const Divider: $$__sveltets_2_IsomorphicComponent<{
10
+ [x: string]: never;
11
+ }, {
12
+ [evt: string]: CustomEvent<any>;
13
+ }, {}, {}, string>;
14
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
15
+ new (options: import("svelte").ComponentConstructorOptions<Props>): import("svelte").SvelteComponent<Props, Events, Slots> & {
16
+ $$bindings?: Bindings;
17
+ } & Exports;
18
+ (internal: unknown, props: {
19
+ $$events?: Events;
20
+ $$slots?: Slots;
21
+ }): Exports & {
22
+ $set?: any;
23
+ $on?: any;
24
+ };
25
+ z_$$bindings?: Bindings;
26
+ }
@@ -0,0 +1,87 @@
1
+ <script lang="ts">
2
+ /** A single row in the key-value status grid. */
3
+ export interface KVItem {
4
+ key: string;
5
+ value: string;
6
+ /** Visual state applied to the value cell. */
7
+ status?: 'ok' | 'warn' | 'err' | 'loading';
8
+ }
9
+
10
+ interface Props {
11
+ items: KVItem[];
12
+ }
13
+
14
+ let { items }: Props = $props();
15
+ </script>
16
+
17
+ <div class="pds-kv-grid" role="list">
18
+ {#each items as item (item.key)}
19
+ <span class="pds-kv-key" role="term">{item.key}</span>
20
+ <span class="pds-kv-val {item.status ?? ''}" role="definition">{item.value}</span>
21
+ {/each}
22
+ </div>
23
+
24
+ <style>
25
+ .pds-kv-grid {
26
+ display: grid;
27
+ grid-template-columns: max-content 1fr;
28
+ gap: 0.25rem 1.2rem;
29
+ font-size: 0.88em;
30
+ margin-bottom: 1.4rem;
31
+ }
32
+
33
+ .pds-kv-key {
34
+ color: var(--pds-color-green);
35
+ opacity: 0.6;
36
+ white-space: nowrap;
37
+ }
38
+
39
+ .pds-kv-val {
40
+ color: var(--pds-color-text);
41
+ word-break: break-all;
42
+ min-width: 0;
43
+ }
44
+
45
+ .pds-kv-val.ok {
46
+ color: var(--pds-color-green);
47
+ }
48
+ .pds-kv-val.warn {
49
+ color: var(--pds-color-yellow);
50
+ }
51
+ .pds-kv-val.err {
52
+ color: var(--pds-color-red);
53
+ }
54
+ .pds-kv-val.loading {
55
+ color: var(--pds-color-surface-1);
56
+ animation: pds-kv-pulse 1.2s ease-in-out infinite;
57
+ }
58
+
59
+ @keyframes pds-kv-pulse {
60
+ 0%,
61
+ 100% {
62
+ opacity: 0.4;
63
+ }
64
+ 50% {
65
+ opacity: 1;
66
+ }
67
+ }
68
+
69
+ @media (max-width: 440px) {
70
+ .pds-kv-grid {
71
+ grid-template-columns: 1fr;
72
+ gap: 0;
73
+ }
74
+
75
+ .pds-kv-key {
76
+ text-transform: uppercase;
77
+ opacity: 1;
78
+ font-size: 0.7em;
79
+ letter-spacing: 0.1em;
80
+ margin-top: 0.7rem;
81
+ }
82
+
83
+ .pds-kv-key:first-child {
84
+ margin-top: 0;
85
+ }
86
+ }
87
+ </style>
@@ -0,0 +1,13 @@
1
+ /** A single row in the key-value status grid. */
2
+ export interface KVItem {
3
+ key: string;
4
+ value: string;
5
+ /** Visual state applied to the value cell. */
6
+ status?: 'ok' | 'warn' | 'err' | 'loading';
7
+ }
8
+ interface Props {
9
+ items: KVItem[];
10
+ }
11
+ declare const KVGrid: import("svelte").Component<Props, {}, "">;
12
+ type KVGrid = ReturnType<typeof KVGrid>;
13
+ export default KVGrid;
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ /** A single entry in a link list. */
3
+ export interface LinkItem {
4
+ href: string;
5
+ label: string;
6
+ /**
7
+ * Whether to open in a new tab.
8
+ * Defaults to `true` — set to `false` for internal navigation.
9
+ */
10
+ external?: boolean;
11
+ }
12
+
13
+ interface Props {
14
+ links: LinkItem[];
15
+ }
16
+
17
+ let { links }: Props = $props();
18
+ </script>
19
+
20
+ <ul class="pds-link-list">
21
+ {#each links as link (link.href)}
22
+ <li>
23
+ <a
24
+ href={link.href}
25
+ target={link.external !== false ? '_blank' : undefined}
26
+ rel={link.external !== false ? 'noopener' : undefined}
27
+ >
28
+ {link.label}
29
+ </a>
30
+ </li>
31
+ {/each}
32
+ </ul>
33
+
34
+ <style>
35
+ .pds-link-list {
36
+ list-style: none;
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 0.35rem;
40
+ font-size: 0.88em;
41
+ padding: 0;
42
+ margin: 0;
43
+ }
44
+
45
+ .pds-link-list li::before {
46
+ content: '→ ';
47
+ color: var(--pds-color-green);
48
+ }
49
+
50
+ .pds-link-list a {
51
+ text-decoration: none;
52
+ color: var(--pds-color-green);
53
+ opacity: 0.85;
54
+ transition: opacity 0.15s;
55
+ }
56
+
57
+ .pds-link-list a:hover {
58
+ opacity: 1;
59
+ }
60
+ </style>
@@ -0,0 +1,16 @@
1
+ /** A single entry in a link list. */
2
+ export interface LinkItem {
3
+ href: string;
4
+ label: string;
5
+ /**
6
+ * Whether to open in a new tab.
7
+ * Defaults to `true` — set to `false` for internal navigation.
8
+ */
9
+ external?: boolean;
10
+ }
11
+ interface Props {
12
+ links: LinkItem[];
13
+ }
14
+ declare const LinkList: import("svelte").Component<Props, {}, "">;
15
+ type LinkList = ReturnType<typeof LinkList>;
16
+ export default LinkList;
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /** Show the `nixpkgs#bluesky-pds` credit. Defaults to `true`. */
4
+ showNixpkg?: boolean;
5
+ /** Show the `atproto.com` link. Defaults to `true`. */
6
+ showAtproto?: boolean;
7
+ }
8
+
9
+ let { showNixpkg = true, showAtproto = true }: Props = $props();
10
+ </script>
11
+
12
+ <footer class="pds-footer">
13
+ {#if showNixpkg}
14
+ powered by
15
+ <a href="https://search.nixos.org/packages?show=bluesky-pds" target="_blank" rel="noopener"
16
+ >nixpkgs#bluesky-pds</a
17
+ >
18
+ {/if}
19
+ {#if showNixpkg && showAtproto}
20
+ &nbsp;·&nbsp;
21
+ {/if}
22
+ {#if showAtproto}
23
+ <a href="https://atproto.com" target="_blank" rel="noopener">atproto.com</a>
24
+ {/if}
25
+ </footer>
26
+
27
+ <style>
28
+ .pds-footer {
29
+ text-align: center;
30
+ color: color-mix(in srgb, var(--pds-color-green) 35%, transparent);
31
+ font-size: 0.75em;
32
+ }
33
+
34
+ .pds-footer a {
35
+ text-decoration: underline;
36
+ color: color-mix(in srgb, var(--pds-color-green) 35%, transparent);
37
+ transition: color 0.15s;
38
+ }
39
+
40
+ .pds-footer a:hover {
41
+ color: var(--pds-color-green);
42
+ }
43
+ </style>
@@ -0,0 +1,9 @@
1
+ interface Props {
2
+ /** Show the `nixpkgs#bluesky-pds` credit. Defaults to `true`. */
3
+ showNixpkg?: boolean;
4
+ /** Show the `atproto.com` link. Defaults to `true`. */
5
+ showAtproto?: boolean;
6
+ }
7
+ declare const PDSFooter: import("svelte").Component<Props, {}, "">;
8
+ type PDSFooter = ReturnType<typeof PDSFooter>;
9
+ export default PDSFooter;
@@ -0,0 +1,172 @@
1
+ <script lang="ts">
2
+ /**
3
+ * PDSPage — a fully assembled ATProto PDS landing page.
4
+ *
5
+ * All sections (status, endpoints, links, contact) are included by default.
6
+ * Each section can be individually disabled via boolean props, and the
7
+ * individual primitives (TerminalCard, StatusGrid, LinkList, …) can be
8
+ * composed manually for bespoke layouts.
9
+ */
10
+ import { onMount } from 'svelte';
11
+ import TerminalCard from './TerminalCard.svelte';
12
+ import PromptLine from './PromptLine.svelte';
13
+ import Tagline from './Tagline.svelte';
14
+ import SectionLabel from './SectionLabel.svelte';
15
+ import Divider from './Divider.svelte';
16
+ import StatusGrid from './StatusGrid.svelte';
17
+ import LinkList from './LinkList.svelte';
18
+ import ContactSection from './ContactSection.svelte';
19
+ import PDSFooter from './PDSFooter.svelte';
20
+ import type { LinkItem } from './LinkList.svelte';
21
+ import { fetchPDSStatus } from '../utils/fetchPDSStatus.js';
22
+
23
+ interface Props {
24
+ // ── Card chrome ──────────────────────────────────────────────────────
25
+ /** Text shown in the terminal titlebar. */
26
+ cardTitle?: string;
27
+
28
+ // ── Prompt ───────────────────────────────────────────────────────────
29
+ promptUser?: string;
30
+ promptHost?: string;
31
+ promptPath?: string;
32
+ tagline?: string;
33
+
34
+ // ── API ──────────────────────────────────────────────────────────────
35
+ /**
36
+ * Origin prepended to `/xrpc/…` calls.
37
+ * Leave empty (`''`) to use the current page's origin (default).
38
+ */
39
+ baseUrl?: string;
40
+
41
+ // ── Sections ─────────────────────────────────────────────────────────
42
+ showStatus?: boolean;
43
+ showEndpoints?: boolean;
44
+ showLinks?: boolean;
45
+ showContact?: boolean;
46
+ showFooter?: boolean;
47
+
48
+ // ── Links section ────────────────────────────────────────────────────
49
+ /**
50
+ * Static links always shown in the Links section.
51
+ * Dynamic links (privacy policy / ToS) from `describeServer` are
52
+ * appended automatically.
53
+ */
54
+ staticLinks?: LinkItem[];
55
+
56
+ // ── Contact section ──────────────────────────────────────────────────
57
+ blueskyHandle?: string;
58
+ blueskyClientUrl?: string;
59
+
60
+ // ── Footer ───────────────────────────────────────────────────────────
61
+ showNixpkg?: boolean;
62
+ showAtproto?: boolean;
63
+ }
64
+
65
+ let {
66
+ cardTitle = "ewan's pds",
67
+ promptUser = 'server',
68
+ promptHost = 'pds.ewancroft.uk',
69
+ promptPath = '~',
70
+ tagline = 'Bluesky-compatible ATProto PDS · personal instance',
71
+ baseUrl = '',
72
+ showStatus = true,
73
+ showEndpoints = true,
74
+ showLinks = true,
75
+ showContact = true,
76
+ showFooter = true,
77
+ staticLinks = [{ href: 'https://witchsky.app', label: 'Witchsky Web Client' }],
78
+ blueskyHandle = 'ewancroft.uk',
79
+ blueskyClientUrl = 'https://witchsky.app',
80
+ showNixpkg = true,
81
+ showAtproto = true
82
+ }: Props = $props();
83
+
84
+ const ENDPOINT_LINKS: LinkItem[] = [
85
+ { href: 'https://github.com/bluesky-social/atproto', label: 'atproto source code' },
86
+ { href: 'https://github.com/bluesky-social/pds', label: 'self-hosting guide' },
87
+ { href: 'https://atproto.com', label: 'protocol docs' }
88
+ ];
89
+
90
+ // Dynamic state populated from describeServer
91
+ let extraLinks: LinkItem[] = $state([]);
92
+ let dynamicLinks: LinkItem[] = $derived([...staticLinks, ...extraLinks]);
93
+ let contactEmail: string | null = $state(null);
94
+
95
+ onMount(async () => {
96
+ try {
97
+ const { description } = await fetchPDSStatus(baseUrl);
98
+ const extras: LinkItem[] = [];
99
+ if (description.links?.privacyPolicy) {
100
+ extras.push({ href: description.links.privacyPolicy, label: 'Privacy Policy' });
101
+ }
102
+ if (description.links?.termsOfService) {
103
+ extras.push({ href: description.links.termsOfService, label: 'Terms of Service' });
104
+ }
105
+ if (extras.length) extraLinks = extras;
106
+ if (description.contact?.email) contactEmail = description.contact.email;
107
+ } catch {
108
+ // silently fall back to static defaults
109
+ }
110
+ });
111
+ </script>
112
+
113
+ <div class="pds-page">
114
+ <TerminalCard title={cardTitle}>
115
+ <PromptLine user={promptUser} host={promptHost} path={promptPath} />
116
+ <Tagline text={tagline} />
117
+
118
+ {#if showStatus}
119
+ <SectionLabel label="status" />
120
+ <StatusGrid {baseUrl} />
121
+ {/if}
122
+
123
+ {#if showEndpoints}
124
+ <Divider />
125
+ <SectionLabel label="endpoints" />
126
+ <p class="pds-endpoints-note">
127
+ Most API routes are under <span class="pds-highlight">/xrpc/</span>
128
+ </p>
129
+ <LinkList links={ENDPOINT_LINKS} />
130
+ {/if}
131
+
132
+ {#if showLinks}
133
+ <Divider />
134
+ <SectionLabel label="links" />
135
+ <LinkList links={dynamicLinks} />
136
+ {/if}
137
+
138
+ {#if showContact}
139
+ <Divider />
140
+ <SectionLabel label="contact" />
141
+ <ContactSection
142
+ {blueskyHandle}
143
+ {blueskyClientUrl}
144
+ email={contactEmail ?? undefined}
145
+ />
146
+ {/if}
147
+ </TerminalCard>
148
+
149
+ {#if showFooter}
150
+ <PDSFooter {showNixpkg} {showAtproto} />
151
+ {/if}
152
+ </div>
153
+
154
+ <style>
155
+ .pds-page {
156
+ display: flex;
157
+ flex-direction: column;
158
+ align-items: center;
159
+ gap: 2rem;
160
+ width: 100%;
161
+ }
162
+
163
+ .pds-endpoints-note {
164
+ font-size: 0.88em;
165
+ color: var(--pds-color-subtext-0);
166
+ margin-bottom: 0.7rem;
167
+ }
168
+
169
+ .pds-highlight {
170
+ color: var(--pds-color-green);
171
+ }
172
+ </style>
@@ -0,0 +1,32 @@
1
+ import type { LinkItem } from './LinkList.svelte';
2
+ interface Props {
3
+ /** Text shown in the terminal titlebar. */
4
+ cardTitle?: string;
5
+ promptUser?: string;
6
+ promptHost?: string;
7
+ promptPath?: string;
8
+ tagline?: string;
9
+ /**
10
+ * Origin prepended to `/xrpc/…` calls.
11
+ * Leave empty (`''`) to use the current page's origin (default).
12
+ */
13
+ baseUrl?: string;
14
+ showStatus?: boolean;
15
+ showEndpoints?: boolean;
16
+ showLinks?: boolean;
17
+ showContact?: boolean;
18
+ showFooter?: boolean;
19
+ /**
20
+ * Static links always shown in the Links section.
21
+ * Dynamic links (privacy policy / ToS) from `describeServer` are
22
+ * appended automatically.
23
+ */
24
+ staticLinks?: LinkItem[];
25
+ blueskyHandle?: string;
26
+ blueskyClientUrl?: string;
27
+ showNixpkg?: boolean;
28
+ showAtproto?: boolean;
29
+ }
30
+ declare const PDSPage: import("svelte").Component<Props, {}, "">;
31
+ type PDSPage = ReturnType<typeof PDSPage>;
32
+ export default PDSPage;
@@ -0,0 +1,48 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ /** Username portion (before the @). */
4
+ user: string;
5
+ /** Hostname (after the @). */
6
+ host: string;
7
+ /** Shell path shown after the colon. Defaults to `~`. */
8
+ path?: string;
9
+ }
10
+
11
+ let { user, host, path = '~' }: Props = $props();
12
+ </script>
13
+
14
+ <div class="pds-prompt-line">
15
+ <span class="pds-user-marker">{user}@{host}</span><span class="pds-prompt-path">:{path}</span
16
+ ><span class="pds-prompt-char" aria-hidden="true"> $</span>
17
+ </div>
18
+
19
+ <style>
20
+ .pds-prompt-line {
21
+ display: flex;
22
+ align-items: baseline;
23
+ margin-bottom: 0.3rem;
24
+ }
25
+
26
+ .pds-user-marker {
27
+ color: var(--pds-color-green);
28
+ font-weight: 700;
29
+ word-break: break-all;
30
+ font-size: clamp(0.95em, 4vw, 1.15em);
31
+ letter-spacing: -0.01em;
32
+ }
33
+
34
+ .pds-prompt-path {
35
+ color: var(--pds-color-subtext-0);
36
+ font-weight: 700;
37
+ font-size: clamp(0.95em, 4vw, 1.15em);
38
+ opacity: 0.6;
39
+ user-select: none;
40
+ }
41
+
42
+ .pds-prompt-char {
43
+ color: var(--pds-color-subtext-0);
44
+ font-weight: 700;
45
+ font-size: clamp(0.95em, 4vw, 1.15em);
46
+ user-select: none;
47
+ }
48
+ </style>
@@ -0,0 +1,11 @@
1
+ interface Props {
2
+ /** Username portion (before the @). */
3
+ user: string;
4
+ /** Hostname (after the @). */
5
+ host: string;
6
+ /** Shell path shown after the colon. Defaults to `~`. */
7
+ path?: string;
8
+ }
9
+ declare const PromptLine: import("svelte").Component<Props, {}, "">;
10
+ type PromptLine = ReturnType<typeof PromptLine>;
11
+ export default PromptLine;
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ interface Props {
5
+ label?: string;
6
+ children?: Snippet;
7
+ }
8
+
9
+ let { label, children }: Props = $props();
10
+ </script>
11
+
12
+ <div class="pds-section-label">
13
+ {#if children}{@render children()}{:else}{label ?? ''}{/if}
14
+ </div>
15
+
16
+ <style>
17
+ .pds-section-label {
18
+ font-weight: 700;
19
+ text-transform: uppercase;
20
+ font-size: 0.72em;
21
+ letter-spacing: 0.12em;
22
+ color: var(--pds-color-green);
23
+ margin-bottom: 0.7rem;
24
+ }
25
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ label?: string;
4
+ children?: Snippet;
5
+ }
6
+ declare const SectionLabel: import("svelte").Component<Props, {}, "">;
7
+ type SectionLabel = ReturnType<typeof SectionLabel>;
8
+ export default SectionLabel;
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import KVGrid from './KVGrid.svelte';
4
+ import type { KVItem } from './KVGrid.svelte';
5
+ import { fetchPDSStatus, type PDSHealth, type PDSDescription } from '../utils/fetchPDSStatus.js';
6
+
7
+ interface Props {
8
+ /**
9
+ * Base URL to prepend to `/xrpc/…` calls.
10
+ * Defaults to `''` (same origin — correct for in-browser use).
11
+ */
12
+ baseUrl?: string;
13
+ }
14
+
15
+ let { baseUrl = '' }: Props = $props();
16
+
17
+ const LOADING_ITEMS: KVItem[] = [
18
+ { key: 'reachable', value: '…', status: 'loading' },
19
+ { key: 'version', value: '…', status: 'loading' },
20
+ { key: 'did', value: '…', status: 'loading' },
21
+ { key: 'accounts', value: '…', status: 'loading' },
22
+ { key: 'invite required', value: '…', status: 'loading' }
23
+ ];
24
+
25
+ let items: KVItem[] = $state([...LOADING_ITEMS]);
26
+
27
+ function buildItems(
28
+ health: PDSHealth,
29
+ description: PDSDescription,
30
+ accountCount: number
31
+ ): KVItem[] {
32
+ const result: KVItem[] = [
33
+ {
34
+ key: 'reachable',
35
+ value: health.reachable ? '✓ online' : '✗ unreachable',
36
+ status: health.reachable ? 'ok' : 'err'
37
+ },
38
+ {
39
+ key: 'version',
40
+ value: health.version ?? (health.reachable ? 'unknown' : '—'),
41
+ status: health.reachable ? undefined : 'err'
42
+ },
43
+ {
44
+ key: 'did',
45
+ value: description.did ?? '—'
46
+ },
47
+ {
48
+ key: 'accounts',
49
+ value: accountCount >= 0 ? accountCount.toString() : '—'
50
+ },
51
+ {
52
+ key: 'invite required',
53
+ value:
54
+ description.inviteCodeRequired === null
55
+ ? '—'
56
+ : description.inviteCodeRequired
57
+ ? 'yes'
58
+ : 'no',
59
+ status:
60
+ description.inviteCodeRequired === null
61
+ ? undefined
62
+ : description.inviteCodeRequired
63
+ ? 'warn'
64
+ : 'ok'
65
+ }
66
+ ];
67
+
68
+ if (description.phoneVerificationRequired !== null) {
69
+ result.push({
70
+ key: 'phone verify',
71
+ value: description.phoneVerificationRequired ? 'yes' : 'no',
72
+ status: description.phoneVerificationRequired ? 'warn' : 'ok'
73
+ });
74
+ }
75
+
76
+ if (description.availableUserDomains.length > 0) {
77
+ result.push({
78
+ key: 'user domains',
79
+ value: description.availableUserDomains.join(', ')
80
+ });
81
+ }
82
+
83
+ return result;
84
+ }
85
+
86
+ onMount(async () => {
87
+ const { health, description, accountCount } = await fetchPDSStatus(baseUrl);
88
+ items = buildItems(health, description, accountCount);
89
+ });
90
+ </script>
91
+
92
+ <KVGrid {items} />
@@ -0,0 +1,10 @@
1
+ interface Props {
2
+ /**
3
+ * Base URL to prepend to `/xrpc/…` calls.
4
+ * Defaults to `''` (same origin — correct for in-browser use).
5
+ */
6
+ baseUrl?: string;
7
+ }
8
+ declare const StatusGrid: import("svelte").Component<Props, {}, "">;
9
+ type StatusGrid = ReturnType<typeof StatusGrid>;
10
+ export default StatusGrid;
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ interface Props {
5
+ text?: string;
6
+ children?: Snippet;
7
+ }
8
+
9
+ let { text, children }: Props = $props();
10
+ </script>
11
+
12
+ <p class="pds-tagline">
13
+ {#if children}{@render children()}{:else}{text ?? ''}{/if}
14
+ </p>
15
+
16
+ <style>
17
+ .pds-tagline {
18
+ color: var(--pds-color-overlay-0);
19
+ font-size: 0.82em;
20
+ margin-top: 0.2rem;
21
+ margin-bottom: 1.4rem;
22
+ line-height: 1.5;
23
+ }
24
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ text?: string;
4
+ children?: Snippet;
5
+ }
6
+ declare const Tagline: import("svelte").Component<Props, {}, "">;
7
+ type Tagline = ReturnType<typeof Tagline>;
8
+ export default Tagline;
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ interface Props {
5
+ /** Text shown in the titlebar after the traffic-light dots. */
6
+ title?: string;
7
+ class?: string;
8
+ children?: Snippet;
9
+ }
10
+
11
+ let { title = 'terminal', class: cls = '', children }: Props = $props();
12
+ </script>
13
+
14
+ <div class="pds-card {cls}">
15
+ <div class="pds-card-titlebar">
16
+ <span class="pds-dot" aria-hidden="true"></span>
17
+ <span class="pds-dot" aria-hidden="true"></span>
18
+ <span class="pds-dot" aria-hidden="true"></span>
19
+ <span class="pds-card-title">{title}</span>
20
+ </div>
21
+ <div class="pds-card-body">
22
+ {#if children}{@render children()}{/if}
23
+ </div>
24
+ </div>
25
+
26
+ <style>
27
+ .pds-card {
28
+ width: 100%;
29
+ max-width: 680px;
30
+ overflow: hidden;
31
+ border-radius: 0.5rem;
32
+ background-color: var(--pds-color-mantle);
33
+ border: 1px solid var(--pds-color-surface-1);
34
+ box-shadow:
35
+ 0 0 0 1px color-mix(in srgb, var(--pds-color-green) 6%, transparent),
36
+ 0 8px 32px color-mix(in srgb, var(--pds-color-shadow) 50%, transparent);
37
+ }
38
+
39
+ .pds-card-titlebar {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 0.5rem;
43
+ padding: 0.55rem 1rem;
44
+ background-color: var(--pds-color-surface-0);
45
+ color: var(--pds-color-green);
46
+ border-bottom: 1px solid color-mix(in srgb, var(--pds-color-green) 15%, transparent);
47
+ font-size: 0.75rem;
48
+ min-width: 0;
49
+ }
50
+
51
+ .pds-card-title {
52
+ overflow: hidden;
53
+ text-overflow: ellipsis;
54
+ white-space: nowrap;
55
+ min-width: 0;
56
+ margin-left: 0.4rem;
57
+ }
58
+
59
+ .pds-dot {
60
+ width: 10px;
61
+ height: 10px;
62
+ border-radius: 50%;
63
+ flex-shrink: 0;
64
+ background-color: color-mix(in srgb, var(--pds-color-green) 25%, transparent);
65
+ border: 1px solid color-mix(in srgb, var(--pds-color-green) 40%, transparent);
66
+ }
67
+
68
+ .pds-card-body {
69
+ padding: 1.4rem 1.6rem;
70
+ }
71
+
72
+ @media (max-width: 440px) {
73
+ .pds-card-body {
74
+ padding: 1.1rem;
75
+ }
76
+ }
77
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Text shown in the titlebar after the traffic-light dots. */
4
+ title?: string;
5
+ class?: string;
6
+ children?: Snippet;
7
+ }
8
+ declare const TerminalCard: import("svelte").Component<Props, {}, "">;
9
+ type TerminalCard = ReturnType<typeof TerminalCard>;
10
+ export default TerminalCard;
@@ -0,0 +1,15 @@
1
+ export { fetchPDSStatus } from './utils/fetchPDSStatus.js';
2
+ export type { PDSHealth, PDSDescription, PDSStatusResult } from './utils/fetchPDSStatus.js';
3
+ export { default as TerminalCard } from './components/TerminalCard.svelte';
4
+ export { default as PromptLine } from './components/PromptLine.svelte';
5
+ export { default as Tagline } from './components/Tagline.svelte';
6
+ export { default as SectionLabel } from './components/SectionLabel.svelte';
7
+ export { default as Divider } from './components/Divider.svelte';
8
+ export { default as KVGrid } from './components/KVGrid.svelte';
9
+ export type { KVItem } from './components/KVGrid.svelte';
10
+ export { default as LinkList } from './components/LinkList.svelte';
11
+ export type { LinkItem } from './components/LinkList.svelte';
12
+ export { default as StatusGrid } from './components/StatusGrid.svelte';
13
+ export { default as ContactSection } from './components/ContactSection.svelte';
14
+ export { default as PDSFooter } from './components/PDSFooter.svelte';
15
+ export { default as PDSPage } from './components/PDSPage.svelte';
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ // ─── Utilities ────────────────────────────────────────────────────────────────
2
+ export { fetchPDSStatus } from './utils/fetchPDSStatus.js';
3
+ // ─── Primitive components ─────────────────────────────────────────────────────
4
+ export { default as TerminalCard } from './components/TerminalCard.svelte';
5
+ export { default as PromptLine } from './components/PromptLine.svelte';
6
+ export { default as Tagline } from './components/Tagline.svelte';
7
+ export { default as SectionLabel } from './components/SectionLabel.svelte';
8
+ export { default as Divider } from './components/Divider.svelte';
9
+ export { default as KVGrid } from './components/KVGrid.svelte';
10
+ export { default as LinkList } from './components/LinkList.svelte';
11
+ // ─── Smart / data-fetching components ────────────────────────────────────────
12
+ export { default as StatusGrid } from './components/StatusGrid.svelte';
13
+ // ─── Compound / section components ───────────────────────────────────────────
14
+ export { default as ContactSection } from './components/ContactSection.svelte';
15
+ export { default as PDSFooter } from './components/PDSFooter.svelte';
16
+ // ─── Full-page convenience component ─────────────────────────────────────────
17
+ export { default as PDSPage } from './components/PDSPage.svelte';
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Types and utilities for fetching live status from an ATProto PDS.
3
+ */
4
+ export interface PDSHealth {
5
+ reachable: boolean;
6
+ /** Reported software version, or null if unreachable / not exposed. */
7
+ version: string | null;
8
+ }
9
+ export interface PDSDescription {
10
+ did: string | null;
11
+ inviteCodeRequired: boolean | null;
12
+ phoneVerificationRequired: boolean | null;
13
+ availableUserDomains: string[];
14
+ links: {
15
+ privacyPolicy?: string;
16
+ termsOfService?: string;
17
+ } | null;
18
+ contact: {
19
+ email?: string;
20
+ } | null;
21
+ }
22
+ export interface PDSStatusResult {
23
+ health: PDSHealth;
24
+ description: PDSDescription;
25
+ /** Total repo count, or -1 if the fetch failed. */
26
+ accountCount: number;
27
+ }
28
+ /**
29
+ * Fetch live status from a PDS.
30
+ *
31
+ * @param baseUrl - Origin to prepend to `/xrpc/…` paths.
32
+ * Defaults to `''` (same origin, works in-browser).
33
+ */
34
+ export declare function fetchPDSStatus(baseUrl?: string): Promise<PDSStatusResult>;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Types and utilities for fetching live status from an ATProto PDS.
3
+ */
4
+ async function fetchJSON(url) {
5
+ const r = await fetch(url);
6
+ if (!r.ok)
7
+ throw new Error(`HTTP ${r.status}`);
8
+ return r.json();
9
+ }
10
+ /**
11
+ * Fetch live status from a PDS.
12
+ *
13
+ * @param baseUrl - Origin to prepend to `/xrpc/…` paths.
14
+ * Defaults to `''` (same origin, works in-browser).
15
+ */
16
+ export async function fetchPDSStatus(baseUrl = '') {
17
+ // ── health ────────────────────────────────────────────────────────────────
18
+ let health = { reachable: false, version: null };
19
+ try {
20
+ const h = (await fetchJSON(`${baseUrl}/xrpc/_health`));
21
+ health = { reachable: true, version: h.version ?? null };
22
+ }
23
+ catch {
24
+ // leave defaults
25
+ }
26
+ // ── description ───────────────────────────────────────────────────────────
27
+ let description = {
28
+ did: null,
29
+ inviteCodeRequired: null,
30
+ phoneVerificationRequired: null,
31
+ availableUserDomains: [],
32
+ links: null,
33
+ contact: null
34
+ };
35
+ try {
36
+ const d = (await fetchJSON(`${baseUrl}/xrpc/com.atproto.server.describeServer`));
37
+ description = {
38
+ did: d.did ?? null,
39
+ inviteCodeRequired: d.inviteCodeRequired ?? null,
40
+ phoneVerificationRequired: d.phoneVerificationRequired ?? null,
41
+ availableUserDomains: d.availableUserDomains ?? [],
42
+ links: d.links ?? null,
43
+ contact: d.contact ?? null
44
+ };
45
+ }
46
+ catch {
47
+ // leave defaults
48
+ }
49
+ // ── account count (paginated) ─────────────────────────────────────────────
50
+ let accountCount = 0;
51
+ try {
52
+ let cursor;
53
+ do {
54
+ const url = `${baseUrl}/xrpc/com.atproto.sync.listRepos?limit=1000` +
55
+ (cursor ? `&cursor=${encodeURIComponent(cursor)}` : '');
56
+ const r = (await fetchJSON(url));
57
+ accountCount += (r.repos ?? []).length;
58
+ cursor = r.cursor;
59
+ } while (cursor);
60
+ }
61
+ catch {
62
+ accountCount = -1;
63
+ }
64
+ return { health, description, accountCount };
65
+ }
package/package.json CHANGED
@@ -1,45 +1,69 @@
1
1
  {
2
2
  "name": "@ewanc26/pds-landing",
3
- "version": "0.1.2",
4
- "description": "SvelteKit landing page for Ewan's personal ATProto PDS at pds.ewancroft.uk",
5
- "author": "Ewan Croft",
6
- "license": "AGPL-3.0-only",
7
- "private": false,
8
- "publishConfig": {
9
- "access": "public"
10
- },
11
- "files": [
12
- "build",
13
- "README.md"
14
- ],
15
- "type": "module",
3
+ "version": "2.0.0",
4
+ "description": "Composable Svelte components for an ATProto PDS landing page — terminal-aesthetic UI with live status fetching.",
16
5
  "scripts": {
17
6
  "dev": "vite dev",
18
- "build": "vite build",
7
+ "build": "vite build && npm run prepack",
19
8
  "preview": "vite preview",
20
9
  "prepare": "svelte-kit sync || echo ''",
10
+ "prepack": "svelte-kit sync && svelte-package && publint",
21
11
  "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
22
12
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
23
13
  "lint": "prettier --check .",
24
- "format": "prettier --write .",
25
- "prepublishOnly": "pnpm build"
14
+ "format": "prettier --write ."
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "!dist/**/*.test.*",
19
+ "!dist/**/*.spec.*"
20
+ ],
21
+ "sideEffects": [
22
+ "**/*.css"
23
+ ],
24
+ "svelte": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "svelte": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@sveltejs/kit": ">=2.0.0",
36
+ "svelte": "^5.0.0",
37
+ "tailwindcss": ">=4.0.0"
26
38
  },
27
39
  "dependencies": {
28
40
  "@ewanc26/ui": "workspace:*"
29
41
  },
30
42
  "devDependencies": {
31
- "@sveltejs/adapter-auto": "^7.0.0",
43
+ "@sveltejs/adapter-static": "^3.0.10",
32
44
  "@sveltejs/kit": "^2.50.2",
45
+ "@sveltejs/package": "^2.5.7",
33
46
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
34
47
  "@tailwindcss/typography": "^0.5.19",
35
48
  "@tailwindcss/vite": "^4.1.18",
36
49
  "prettier": "^3.8.1",
37
50
  "prettier-plugin-svelte": "^3.4.1",
38
51
  "prettier-plugin-tailwindcss": "^0.7.2",
52
+ "publint": "^0.3.17",
39
53
  "svelte": "^5.51.0",
40
54
  "svelte-check": "^4.4.2",
41
55
  "tailwindcss": "^4.1.18",
42
56
  "typescript": "^5.9.3",
43
57
  "vite": "^7.3.1"
58
+ },
59
+ "keywords": [
60
+ "svelte",
61
+ "atproto",
62
+ "bluesky",
63
+ "pds",
64
+ "landing-page"
65
+ ],
66
+ "publishConfig": {
67
+ "access": "public"
44
68
  }
45
69
  }