@atmo-dev/kit 0.5.0 → 0.7.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
@@ -9,6 +9,97 @@ Svelte 5 and Tailwind CSS 4 components for Atmo apps.
9
9
  - Releases: `pnpm changeset`, then merge the generated release PR. [Details](.changeset/README.md).
10
10
  - License: [MIT](LICENSE).
11
11
 
12
+ ## Navigation
13
+
14
+ `IconRail` items accept a `variant` from `Button` (`primary`, `secondary`, `ghost`,
15
+ `link`); omitted variants remain `ghost`. Use `primary: true, variant: 'primary'`
16
+ for a separated, filled creation action in the rail, mobile bar or drawer. The
17
+ existing `primary` flag alone retains its subtle accent/spacing behavior.
18
+
19
+ ## AT record metadata (AT Tags)
20
+
21
+ Use `AtTags` to let other clients discover which AT Protocol records and identities
22
+ an HTML page represents. It renders server-side `<meta name="at:…" content="at://…">`
23
+ tags in the document head and updates/removes them when its props change. It needs
24
+ no theme, styles, network requests, or app state.
25
+
26
+ The [AT Tags proposal](https://tangled.org/chrisshank.com/at-tags) is a draft. Its four
27
+ standard relationships are:
28
+
29
+ | Property | Meaning | Content |
30
+ | ----------- | ------------------------------------------------------------------------ | --------------- |
31
+ | `canonical` | The record this page represents; deleting it would remove the page. | A record AT URI |
32
+ | `alternate` | Auxiliary records shown or referenced; the page survives their deletion. | A record AT URI |
33
+ | `author` | Account(s) who authored the page's content. | `at://<DID>` |
34
+ | `me` | Account(s) this site or section belongs to. | `at://<DID>` |
35
+
36
+ Each property accepts an array; multiple values produce separate tags, with exact
37
+ duplicates removed within each property. Use meta **names**, not Open Graph
38
+ `property` attributes or `<link href="at://…">`. These tags supplement—not
39
+ replace—your title, description, HTTPS canonical URL and Open Graph metadata.
40
+
41
+ For a page backed by one record authored by its repository owner, use the
42
+ framework-free `recordAtTags` helper in your server loader:
43
+
44
+ ```ts
45
+ import { recordAtTags } from '@atmo-dev/kit/at-tags';
46
+
47
+ // After app-owned loading, visibility/moderation checks and handle resolution:
48
+ return {
49
+ atTags: recordAtTags(record, relatedRecords)
50
+ };
51
+ ```
52
+
53
+ `record` and each optional `relatedRecords` entry only need `{ uri: string }`.
54
+ The helper sets `canonical`, derives `author` from the record's repository DID,
55
+ and lists related records as `alternate`, excluding the canonical URI. It throws
56
+ `SyntaxError` for non-record or handle-based URIs: pass resolved DID-based record
57
+ URIs, not a URL path or viewer parameter. It does not resolve or fetch anything.
58
+
59
+ Render the metadata once in a SvelteKit root layout (covering embeds too):
60
+
61
+ ```svelte
62
+ <script lang="ts">
63
+ import { page } from '$app/state';
64
+ import { AtTags } from '@atmo-dev/kit';
65
+
66
+ let { children } = $props();
67
+ </script>
68
+
69
+ <AtTags tags={page.error ? undefined : page.data.atTags} />
70
+ {@render children()}
71
+ ```
72
+
73
+ Add `atTags?: import('@atmo-dev/kit/at-tags').AtTagMetadata` to `App.PageData`.
74
+ In other Svelte apps, pass the current page's metadata directly; set `tags` to
75
+ `undefined` on pages with no relationships. Do not mount competing tag owners.
76
+
77
+ You can supply `AtTagMetadata` directly for multiple canonical records, different
78
+ or additional authors, or an account section:
79
+
80
+ ```ts
81
+ import type { AtTagMetadata } from '@atmo-dev/kit/at-tags';
82
+
83
+ const atTags: AtTagMetadata = {
84
+ me: [`at://${profile.did}`],
85
+ alternate: profile.uri ? [profile.uri] : []
86
+ };
87
+ ```
88
+
89
+ An account's events page might survive deletion of its optional Bluesky profile,
90
+ so that profile record is an alternate, not necessarily canonical. Likewise,
91
+ embedded comments are alternate records, not the page's authors. `me` is not the
92
+ signed-in viewer or every record author; do not assign it site-wide unless the
93
+ site actually belongs to that account. Ordinary discovery and creation pages
94
+ usually have no canonical record.
95
+
96
+ Apps own these semantic choices, record loading, identity resolution, visibility,
97
+ and error-page suppression. The component escapes attributes but does not validate
98
+ raw metadata or verify record existence. Use record URIs with collection and rkey,
99
+ and bare `at://<DID>` identity URIs without a trailing slash. Only the four standard
100
+ properties are supported; parsing/scraping pages and draft custom namespaces are
101
+ outside this API.
102
+
12
103
  ## Events
13
104
 
14
105
  The event UI formerly in `@atmo-dev/events-ui` lives in this package:
@@ -30,6 +121,8 @@ The event UI formerly in `@atmo-dev/events-ui` lives in this package:
30
121
  - `@atmo-dev/kit/events-data`: structural event types, adapter contracts, conference/date helpers, URL helpers, theme values, linked-record types and thumbnail selection. This entry is safe in plain Node and server routes: no Svelte, CSS, maps or players are loaded. Browser helpers such as `compressImage` only access browser APIs when called.
31
122
  - `@atmo-dev/kit/events/editor`: `buildEventRecord` and `buildThumbnailMedia` for hosts that need explicit record construction. This separate entry includes browser-oriented thumbnail code; it is not part of the pure data API.
32
123
 
124
+ Event cards and covers prefer native `media` images, then an HTTPS `poster.url`, then a URI named `Event Image` (laugh.town compatibility). `getEventExternalImage(event)` from `events-data` returns `{ url, alt, aspectRatio? }` or `null`, validating URLs and optional poster dimensions. It performs no IO; apps must apply their own allowlist/redirect policy before fetching an external image server-side.
125
+
33
126
  Apps implement `EditorAdapter` for authentication prompts, repository IO, uploads, save/delete navigation, and optional linked-record transport/indexing notifications. Keep sessions, Cloudflare bindings, app routes, server endpoints, caching and moderation in the app. The editor/view never imports a consumer's app state. Existing `EventView` page-data shape and RSVP behavior are preserved in this migration.
34
127
 
35
128
  Full event UI requires optional peers (ordinary Kit consumers do not):
@@ -0,0 +1,18 @@
1
+ /** Standard AT Tags draft properties. Identity values are `at://<DID>` URIs. */
2
+ export interface AtTagMetadata {
3
+ canonical?: readonly string[];
4
+ alternate?: readonly string[];
5
+ author?: readonly string[];
6
+ me?: readonly string[];
7
+ }
8
+ /** A resolved record reference; no lexicon-specific fields are needed. */
9
+ export interface AtTagRecordRef {
10
+ uri: string;
11
+ }
12
+ /**
13
+ * Metadata for a page backed by one record, authored by its repository owner.
14
+ * Supply only auxiliary records represented on the page. Throws SyntaxError for
15
+ * non-record or non-DID-based URIs; resolve handles before calling this helper.
16
+ * For multiple canonical records or different authors, supply metadata directly.
17
+ */
18
+ export declare function recordAtTags(record: AtTagRecordRef, alternates?: readonly AtTagRecordRef[]): AtTagMetadata;
@@ -0,0 +1,18 @@
1
+ import { parseCanonicalResourceUri } from '@atcute/lexicons/syntax';
2
+ /**
3
+ * Metadata for a page backed by one record, authored by its repository owner.
4
+ * Supply only auxiliary records represented on the page. Throws SyntaxError for
5
+ * non-record or non-DID-based URIs; resolve handles before calling this helper.
6
+ * For multiple canonical records or different authors, supply metadata directly.
7
+ */
8
+ export function recordAtTags(record, alternates = []) {
9
+ const { repo } = parseCanonicalResourceUri(record.uri);
10
+ const alternate = [...new Set(alternates.map(({ uri }) => uri))].filter((uri) => uri !== record.uri);
11
+ for (const uri of alternate)
12
+ parseCanonicalResourceUri(uri);
13
+ return {
14
+ canonical: [record.uri],
15
+ author: [`at://${repo}`],
16
+ alternate
17
+ };
18
+ }
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import type { AtTagMetadata } from '../at-tags.js';
3
+
4
+ export interface AtTagsProps {
5
+ tags?: AtTagMetadata;
6
+ }
7
+
8
+ let { tags }: AtTagsProps = $props();
9
+ </script>
10
+
11
+ <svelte:head>
12
+ {#each ['canonical', 'alternate', 'author', 'me'] as property (property)}
13
+ {#each [...new Set(tags?.[property as keyof AtTagMetadata] ?? [])] as uri (uri)}
14
+ <meta name={`at:${property}`} content={uri} />
15
+ {/each}
16
+ {/each}
17
+ </svelte:head>
@@ -0,0 +1,7 @@
1
+ import type { AtTagMetadata } from '../at-tags.js';
2
+ export interface AtTagsProps {
3
+ tags?: AtTagMetadata;
4
+ }
5
+ declare const AtTags: import("svelte").Component<AtTagsProps, {}, "">;
6
+ type AtTags = ReturnType<typeof AtTags>;
7
+ export default AtTags;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts" module>
2
2
  import type { IconNode } from '@sketchyicons/svelte';
3
3
  import type { ButtonProps } from './Button.svelte';
4
- export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled'> & {
4
+ export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled' | 'variant'> & {
5
5
  label: string;
6
6
  icon: IconNode;
7
7
  active?: boolean;
@@ -77,7 +77,7 @@
77
77
  <Button
78
78
  href={item.href}
79
79
  disabled={item.disabled}
80
- variant="ghost"
80
+ variant={item.variant ?? 'ghost'}
81
81
  aria-current={item.active ? 'page' : undefined}
82
82
  data-part="item"
83
83
  onclick={(event) => {
@@ -85,8 +85,11 @@
85
85
  item.onclick?.(event);
86
86
  }}
87
87
  class={cn(
88
- 'min-h-11 w-full justify-start gap-3 px-3 py-3 text-sm aria-[current=page]:bg-recess aria-[current=page]:text-action',
89
- item.primary && 'mt-3 text-action'
88
+ 'min-h-11 w-full justify-start gap-3 px-3 py-3 text-sm',
89
+ (!item.variant || item.variant === 'ghost') &&
90
+ 'aria-[current=page]:bg-recess aria-[current=page]:text-action',
91
+ item.primary && 'mt-3',
92
+ item.primary && (!item.variant || item.variant === 'ghost') && 'text-action'
90
93
  )}
91
94
  >
92
95
  <Icon img={item.icon} size={22} />
@@ -128,15 +131,22 @@
128
131
  {...props}
129
132
  href={item.href}
130
133
  disabled={item.disabled}
131
- variant="ghost"
134
+ variant={item.variant ?? 'ghost'}
132
135
  size="icon"
133
136
  aria-label={`${item.label}${item.badge ? (typeof item.badge === 'number' ? `, ${item.badge} unread` : ', unread') : ''}`}
134
137
  aria-current={item.active ? 'page' : undefined}
135
138
  data-part="item"
136
139
  class={cn(
137
- 'relative size-11 rounded-(--atmo-radius-button) text-muted hover:bg-recess/55 aria-[current=page]:text-action max-sm:size-10',
138
- item.primary && 'mt-2 text-action hover:text-action max-sm:mt-0',
139
- backed && 'bg-recess/45 hover:bg-recess/75'
140
+ 'relative size-11 rounded-(--atmo-radius-button) max-sm:size-10',
141
+ (!item.variant || item.variant === 'ghost') &&
142
+ 'text-muted hover:bg-recess/55 aria-[current=page]:text-action',
143
+ item.primary && 'mt-2 max-sm:mt-0',
144
+ item.primary &&
145
+ (!item.variant || item.variant === 'ghost') &&
146
+ 'text-action hover:text-action',
147
+ backed &&
148
+ (!item.variant || item.variant === 'ghost') &&
149
+ 'bg-recess/45 hover:bg-recess/75'
140
150
  )}
141
151
  >
142
152
  <Icon img={item.icon} size={22} />{#if item.badge}<span
@@ -1,6 +1,6 @@
1
1
  import type { IconNode } from '@sketchyicons/svelte';
2
2
  import type { ButtonProps } from './Button.svelte';
3
- export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled'> & {
3
+ export type IconRailItem = Pick<ButtonProps, 'href' | 'onclick' | 'disabled' | 'variant'> & {
4
4
  label: string;
5
5
  icon: IconNode;
6
6
  active?: boolean;
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { getCDNImageBlobUrl } from './atproto-helpers.js';
3
- import { getEventThumbnail } from './event-media.js';
3
+ import { getEventThumbnail, getEventExternalImage } from './event-media.js';
4
4
  import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
5
5
  import Avatar from 'svelte-boring-avatars';
6
6
  import { Badge, Icon } from '../index.js';
@@ -75,7 +75,7 @@
75
75
  if (url) return { url, alt: banner.alt || event.name };
76
76
  }
77
77
 
78
- return null;
78
+ return getEventExternalImage(event);
79
79
  }
80
80
 
81
81
  let thumbnail = $derived(getThumbnail(event));
@@ -2,7 +2,7 @@
2
2
  import { eventUrl, isEventOngoing, type FlatEventRecord } from './contrail.js';
3
3
  import type { EventViewData } from './view-data.js';
4
4
  import { getCDNImageBlobUrl } from './atproto-helpers.js';
5
- import { getEventThumbnail } from './event-media.js';
5
+ import { getEventThumbnail, getEventExternalImage } from './event-media.js';
6
6
  import { Button, ToggleGroup, ToggleGroupItem } from '../index.js';
7
7
  import ShareModal from './ShareModal.svelte';
8
8
  import EventComments from './EventComments.svelte';
@@ -159,8 +159,8 @@
159
159
  return { url, alt: media.alt || eventData.name, aspectRatio: media.aspect_ratio };
160
160
  });
161
161
 
162
- // Prefer thumbnail, then flyer; fall back to header/banner image.
163
- let displayImage = $derived(thumbnailImage ?? bannerImage);
162
+ // Prefer native media, then external posters.
163
+ let displayImage = $derived(thumbnailImage ?? bannerImage ?? getEventExternalImage(eventData));
164
164
  let imageAspectRatio = $derived.by(() => {
165
165
  const ratio = displayImage?.aspectRatio;
166
166
  if (!ratio || ratio.width <= 0 || ratio.height <= 0) return null;
@@ -2,3 +2,19 @@
2
2
  export declare function getEventThumbnail<T extends {
3
3
  role?: unknown;
4
4
  }>(media: readonly T[] | null | undefined): T | undefined;
5
+ export type ExternalEventImage = {
6
+ url: string;
7
+ alt: string;
8
+ aspectRatio?: {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ };
13
+ /** External poster compatibility (e.g. laugh.town). Prefer native media first.
14
+ * Validates display URLs only; apps must apply their own server-fetch policy.
15
+ */
16
+ export declare function getEventExternalImage(event: {
17
+ name: string;
18
+ poster?: unknown;
19
+ uris?: unknown;
20
+ }): ExternalEventImage | null;
@@ -2,3 +2,48 @@
2
2
  export function getEventThumbnail(media) {
3
3
  return media?.find((m) => m.role === 'thumbnail') ?? media?.find((m) => m.role === 'flyer');
4
4
  }
5
+ function imageUrl(value) {
6
+ if (typeof value !== 'string')
7
+ return null;
8
+ try {
9
+ const url = new URL(value);
10
+ if (url.protocol !== 'https:' || url.username || url.password)
11
+ return null;
12
+ return url.href;
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ /** External poster compatibility (e.g. laugh.town). Prefer native media first.
19
+ * Validates display URLs only; apps must apply their own server-fetch policy.
20
+ */
21
+ export function getEventExternalImage(event) {
22
+ const poster = event.poster;
23
+ if (poster && typeof poster === 'object' && 'url' in poster) {
24
+ const url = imageUrl(poster.url);
25
+ if (url) {
26
+ const width = 'width' in poster ? poster.width : undefined;
27
+ const height = 'height' in poster ? poster.height : undefined;
28
+ const aspectRatio = typeof width === 'number' &&
29
+ Number.isFinite(width) &&
30
+ width > 0 &&
31
+ typeof height === 'number' &&
32
+ Number.isFinite(height) &&
33
+ height > 0
34
+ ? { width, height }
35
+ : undefined;
36
+ return { url, alt: event.name, aspectRatio };
37
+ }
38
+ }
39
+ if (Array.isArray(event.uris)) {
40
+ for (const link of event.uris) {
41
+ if (!link || typeof link !== 'object' || link.name !== 'Event Image')
42
+ continue;
43
+ const url = imageUrl(link.uri);
44
+ if (url)
45
+ return { url, alt: event.name };
46
+ }
47
+ }
48
+ return null;
49
+ }
@@ -35,6 +35,8 @@ export type EventData = EventLexiconMain & {
35
35
  uri: string;
36
36
  name?: string;
37
37
  }>;
38
+ /** External poster extension used by laugh.town; validated when displayed. */
39
+ poster?: unknown;
38
40
  media?: Array<{
39
41
  role: string;
40
42
  alt?: string;
@@ -9,7 +9,7 @@ export * from './events/conference.js';
9
9
  export * from './events/linked-records.js';
10
10
  export * from './events/date-format.js';
11
11
  export * from './events/theme.js';
12
- export { getEventThumbnail } from './events/event-media.js';
12
+ export { getEventThumbnail, getEventExternalImage, type ExternalEventImage } from './events/event-media.js';
13
13
  export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
14
14
  export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
15
15
  export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
@@ -3,7 +3,7 @@ export * from './events/conference.js';
3
3
  export * from './events/linked-records.js';
4
4
  export * from './events/date-format.js';
5
5
  export * from './events/theme.js';
6
- export { getEventThumbnail } from './events/event-media.js';
6
+ export { getEventThumbnail, getEventExternalImage } from './events/event-media.js';
7
7
  export { getCDNImageBlobUrl, compressImage } from './events/atproto-helpers.js';
8
8
  export { getProfileUrl, getLocalProfileUrl } from './events/profile-url.js';
9
9
  export { normalizeCountryCode, normalizeEventLocations, LocationValidationError } from './events/editor/location.js';
package/dist/index.d.ts CHANGED
@@ -11,6 +11,9 @@ export * from './social.js';
11
11
  export { default as Checkbox } from './components/Checkbox.svelte';
12
12
  export { default as Label } from './components/Label.svelte';
13
13
  export { default as PageTitle } from './components/PageTitle.svelte';
14
+ export { default as AtTags } from './components/AtTags.svelte';
15
+ export type { AtTagsProps } from './components/AtTags.svelte';
16
+ export * from './at-tags.js';
14
17
  export { default as Heading } from './components/Heading.svelte';
15
18
  export { default as Subheading } from './components/Subheading.svelte';
16
19
  export { default as Surface } from './components/Surface.svelte';
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ export * from './social.js';
8
8
  export { default as Checkbox } from './components/Checkbox.svelte';
9
9
  export { default as Label } from './components/Label.svelte';
10
10
  export { default as PageTitle } from './components/PageTitle.svelte';
11
+ export { default as AtTags } from './components/AtTags.svelte';
12
+ export * from './at-tags.js';
11
13
  export { default as Heading } from './components/Heading.svelte';
12
14
  export { default as Subheading } from './components/Subheading.svelte';
13
15
  export { default as Surface } from './components/Surface.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmo-dev/kit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "The shared UI foundation for Atmo apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,10 @@
27
27
  "svelte": "./dist/index.js",
28
28
  "default": "./dist/index.js"
29
29
  },
30
+ "./at-tags": {
31
+ "types": "./dist/at-tags.d.ts",
32
+ "default": "./dist/at-tags.js"
33
+ },
30
34
  "./events": {
31
35
  "types": "./dist/events.d.ts",
32
36
  "svelte": "./dist/events.js",
@@ -166,7 +170,7 @@
166
170
  "test": "pnpm test:unit --run && pnpm test:e2e && pnpm test:package",
167
171
  "test:ci": "pnpm test:unit --run --no-file-parallelism && pnpm test:e2e && pnpm test:package",
168
172
  "test:e2e": "playwright test",
169
- "test:package": "pnpm prepack && node --test scripts/test-events-package.mjs",
173
+ "test:package": "pnpm prepack && node --test scripts/test-*-package.mjs",
170
174
  "changeset": "changeset",
171
175
  "version-packages": "changeset version && pnpm install --lockfile-only --no-frozen-lockfile --ignore-scripts",
172
176
  "release": "pnpm prepack && changeset publish"