Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 44 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,16 @@ Hi! This is just my boring personal static blog ^w^

## Build Chain

The build produces a static SvelteKit artifact. Tinyland snapshots and local
Markdown remain first-paint, no-JS, and regression fixtures; canonical blog and
Pulse display hydrates in the browser from the public Tinyland broker when it is
available.

```mermaid
flowchart LR
Posts["src/posts Markdown"] --> Mdsvex["mdsvex"]
TinylandPosts["Tinyland post snapshot"] --> Ingest["ingest check"]
PulseJson["Pulse public snapshot"] --> PulseCheck["snapshot validator"]
TinylandPosts["Tinyland post snapshot fixture"] --> Ingest["fallback ingest check"]
PulseJson["Pulse public snapshot fixture"] --> PulseCheck["snapshot validator"]
Static["static assets"] --> Images["image optimization"]
Routes["SvelteKit routes"] --> Svelte["Svelte 5 compiler"]

Expand All @@ -41,6 +46,11 @@ flowchart LR
Adapter --> Build["build/"]
Build --> Redirects["redirect pages"]
Build --> Pagefind["Pagefind index"]
Build --> RuntimeHydration["browser runtime hydration"]
HubBlog["hub.tinyland.dev blog broker stream"] --> RuntimeHydration
HubPulse["hub.tinyland.dev Pulse public snapshot"] --> RuntimeHydration
RuntimeHydration --> Blog["/blog and /blog/[slug]"]
RuntimeHydration --> PulseRoute["/pulse"]

CvTex["CV TeX"] --> Tectonic["Tectonic PDF workflow"]
```
Expand Down Expand Up @@ -95,40 +105,50 @@ Current boundary: this proves narrow public SvelteKit/Vite/Vitest, SvelteKit/Vit



## Content Automation
## Content Authority And Fallback Automation

```mermaid
flowchart LR
SourceRepo["source repo blog/docs/posts"] --> Notify["repository_dispatch"]
Author["Jess edits greymatter in tinyland.dev"] --> Tinyland["tinyland.dev content authority"]
Tinyland --> HubStream["hub.tinyland.dev broker stream"]
HubStream --> RuntimeBlog["/blog runtime hydration"]

Tinyland --> StaticSnapshots["checked snapshot fixtures"]
StaticSnapshots --> FirstPaint["static first paint and no-JS fallback"]

SourceRepo["legacy source repo posts"] --> Notify["repository_dispatch"]
Notify --> Collect["collect-posts workflow"]
Collect --> DraftPR["draft content PR"]
DraftPR --> Bot["blog-agent review"]
DraftPR --> DateGuard["future-date guard"]
Bot --> Human["review and edit"]
DateGuard --> Human
Human --> Schedule["scheduled label and PR body gate"]
Schedule --> AutoMerge["daily auto-merge check"]
AutoMerge --> Main["main"]
Collect --> DraftPR["draft fallback PR"]
DraftPR --> Human["review before merge"]
```

## Federation Approach
Cross-repo collection is legacy/static intake for fallback content. It is not the
primary authoring path for Tinyland-managed posts.

## Brokered Display And Federation Boundary

```mermaid
flowchart TB
Tinyland["tinyland.dev projection authority"] --> PostSnapshot["reviewed post snapshot"]
Tinyland --> PulseSnapshot["public Pulse snapshot"]
Tinyland --> StreamDemo["AP-shaped stream demo"]
Tinyland --> Edge["projection-only public edge"]
Edge --> WebFinger["WebFinger and NodeInfo"]
TinylandEditor["tinyland.dev blog editor"] --> Greymatter["content/users/jesssullivan greymatter"]
Greymatter --> BlogBroker["hub.tinyland.dev blog broker stream"]
BlogBroker --> BlogRuntime["CF Pages /blog and /blog/[slug] runtime display"]

PulseBroker["Tinyland Pulse broker/public policy"] --> PulseSnapshot["hub.tinyland.dev Pulse public snapshot"]
PulseSnapshot --> PulseRuntime["CF Pages /pulse runtime refresh"]

StaticFixtures["checked-in snapshots and src/posts"] --> FirstPaint["static first paint/fallback"]
FirstPaint --> BlogRuntime
FirstPaint --> PulseRuntime

PostSnapshot --> IngestPosts["materialize checked posts"]
IngestPosts --> Blog["/blog"]
BlogBroker --> DisplayOnly["brokered display only"]
PulseSnapshot --> DisplayOnly
DisplayOnly --> NotFederation["not public Fediverse delivery"]

PulseSnapshot --> PulseRoute["/pulse"]
StreamDemo --> HiddenLab["/pulse/client/brokered-stream"]
ApLab["/pulse/client/brokered-stream"] --> ApDemo["AP-shaped hidden lab demo"]
ApDemo --> NotFederation

HiddenLab --> Boundary["projection demo only"]
Boundary --> NotFederation["not public Fediverse delivery"]
HubDiscovery["hub.tinyland.dev WebFinger and NodeInfo"] --> DiscoveryOnly["public discovery/projection metadata"]
DiscoveryOnly --> NotFederation
```


Expand Down
6 changes: 4 additions & 2 deletions docs/blog-shadow-preview.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@ uses the `tinyland-dind` ARC runner by default and accepts the same
SvelteKit output and can publish it to Cloudflare Pages by Direct Upload.

This is a shadow lane for moving `transscendsurvival.org` toward the Tinyland
static-spoke edge posture. It does not cut over DNS, replace GitHub Pages
production, or add live Tinyland broker fallback behavior.
static-spoke edge posture. It does not cut over DNS or replace GitHub Pages
production. The built site is still static, but current `/blog`, `/blog/[slug]`,
and `/pulse` client code may hydrate from public `hub.tinyland.dev` broker
endpoints at runtime when those endpoints are available.

Required repository secrets:

Expand Down
33 changes: 28 additions & 5 deletions docs/blog-staging.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Cross-Repo Blog Staging Pipeline

Status on 2026-05-19: this is a legacy/static intake path for fallback posts,
migration evidence, and source-repo drafts. It is not the primary authoring path
for the live blog.

The primary content path is:

```text
tinyland.dev blog editor / greymatter
-> hub.tinyland.dev broker stream
-> transscendsurvival.org /blog runtime hydration
```

Checked-in posts and snapshots remain useful for first paint, no-JS fallback,
search/index fixtures, and regression tests. New Tinyland-managed blog posts are
authored and edited in `tinyland.dev`, not in this repo's collector flow.

Blog posts can live in any repo. A collection pipeline pulls them into
`jesssullivan.github.io`, normalizes frontmatter, rewrites links and images,
and stages them as draft PRs for review before publication.
Expand All @@ -26,6 +42,14 @@ Source repo push repository_dispatch Collector script

## Writing a Blog Post

For live Tinyland-managed posts, write or edit the post in the `tinyland.dev`
blog editor. The public site consumes the reviewed broker stream and must not
own mutation APIs, admin credentials, ActivityPub delivery workers, or media
lifecycle state.

Use this collector flow only when intentionally staging a legacy/static fallback
post from another repository.

Put a markdown file in one of the scanned directories (`blog/`, `posts/`, or
`docs/blog/`) in any configured source repo.

Expand All @@ -50,11 +74,10 @@ linear_project: "Blog + Profile Integration"
intended for the blog. If a file is already inside a scanned directory, the
marker is optional.

If you are running the post through Tinyland's Linear surface, add
`linear_issue` and optionally `linear_project`. Keep the actual post body in
git-backed markdown, not in a Linear document. Linear is the control plane
for idea state, review state, and scheduling context — not the canonical
longform content store.
If you are running a legacy/static fallback post through Tinyland's Linear
surface, add `linear_issue` and optionally `linear_project`. Linear is the
control plane for idea state, review state, and scheduling context, not the
canonical longform content store.

### Image conventions

Expand Down
15 changes: 11 additions & 4 deletions docs/tinyland-pulse-lifecycle-architecture-spec-2026-04-27.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ The core rule is simple: the tinyland broker owns the source of truth. The stati
- Production photo upload and processing.
- A production native/mobile app.
- Public exposure of local git summaries.
- Runtime dependency from the static blog to a live tinyland broker.
- Runtime dependency from the static blog to a private Tinyland broker,
mutation API, queue, or event store. A browser fetch from the public
`hub.tinyland.dev` Pulse snapshot projection is allowed as a display refresh
after static first paint.

## System Shape

Expand All @@ -58,7 +61,8 @@ client/demo composer
-> queue/workflow boundary
-> projection worker
-> public snapshot + manifest
-> static blog /pulse
-> static blog /pulse first paint
-> hub.tinyland.dev public snapshot refresh

optional later projections:
-> ActivityStreams outbox mirror
Expand Down Expand Up @@ -297,7 +301,10 @@ Build behavior:
- `/pulse` remains `prerender = true`.
- `npm run check` or a dedicated validation script fails on invalid snapshot shape.
- `npm run build` must not require the broker to be online.
- A future optional fetch step may refresh the checked snapshot before build, but only behind an explicit operator command or CI input.
- Runtime browser hydration may refresh from the public hub snapshot endpoint
after first paint. Failure keeps the checked snapshot visible.
- A future optional fetch step may refresh the checked snapshot before build,
but only behind an explicit operator command or CI input.

This keeps the static site fast and reviewable.

Expand Down Expand Up @@ -332,7 +339,7 @@ M1 public projection must block:
- generated git summaries
- listening history
- sensor readings
- live broker fetches
- private broker fetches, mutation APIs, and non-public event streams

Photo support requires a separate media lifecycle:

Expand Down
9 changes: 7 additions & 2 deletions docs/tinyland-pulse-public-data-policy-2026-04-27.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,18 @@ These remain blocked until they have their own policy version. Adding one of the
- Generated git summaries. Require an explicit per-repository allowlist and a redaction layer for paths and committer information.
- Listening history. Require either explicit user-facing opt-in per item or a heavy aggregation step that strips temporal granularity.
- Sensor readings. Require an explicit decision about which environmental contexts can be inferred from the data.
- Live broker fetches at render time. The static blog must always read a checked or generated snapshot, never the live broker.
- Private broker fetches, mutation APIs, or non-public event streams at render
time. The static blog may only hydrate from the public
`hub.tinyland.dev/projections/jesssullivan-github-io/pulse/public-snapshot.v1.json`
projection after first paint, and it must keep the checked snapshot as a
fallback.

## ActivityPub language guidelines

The blog has shipped WebFinger discovery only ([PR #68](https://github.com/Jesssullivan/jesssullivan.github.io/pull/68)). It has not shipped real ActivityPub federation. Until it does, PRs and posts should use the following terms:

- "WebFinger discovery" - what is currently live: `acct:jess@transscendsurvival.org` resolves to `https://tinyland.dev/@jesssullivan` via `.well-known/webfinger`.
- "WebFinger discovery" - public discovery metadata only. The canonical public
broker actor handle is `acct:jesssullivan@hub.tinyland.dev`.
- "AP-shaped mirror" or "ActivityStreams projection" - any future static `outbox.json` or actor document that is not actually delivered to remote servers. This is a publication shape, not a federation.
- "ActivityPub federation" or "federated" - reserved for the day the broker speaks server-to-server: actor lifecycle, signed delivery, follower collection, inbox handling, retries, updates/deletes, tombstones, moderation, and compatibility testing against real servers.

Expand Down
16 changes: 9 additions & 7 deletions docs/tinyland-static-post-pulse-ingest-2026-05-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ reviewed source projection.

2026-05-19 correction: this checked-in ingest path is fallback and migration
evidence only. The intended Cloudflare Pages display path is runtime broker
fetch from `hub.tinyland.dev`, with Tinyland-managed greymatter as the source of
truth. Checked-in snapshots must not be treated as the live blog federation
mechanism.
fetch from `hub.tinyland.dev`, with Tinyland-managed greymatter and Pulse policy
snapshots as the source of truth. Checked-in snapshots must not be treated as
the live blog federation mechanism.

## Checked-In Inputs

Expand All @@ -26,11 +26,12 @@ static/data/pulse/public-snapshot.v1.json
```

Both are copied from Tinyland reviewed static artifacts. They remain useful as
first-paint and regression fixtures, but canonical blog display now hydrates
from:
first-paint and regression fixtures, but canonical display now hydrates from the
public broker endpoints when available:

```text
https://hub.tinyland.dev/projections/jesssullivan-github-io/blog/broker-stream.v1.json
https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/public-snapshot.v1.json
```

## Post Ingest
Expand Down Expand Up @@ -98,8 +99,9 @@ Allowed:
- checked-in static snapshots as fallback/regression fixtures;
- ordinary Markdown/frontmatter posts in `src/posts` for legacy/static
first-paint content;
- runtime display fetches from the public `hub.tinyland.dev` broker stream;
- the existing `PublicPulseSnapshot` validator and `/pulse` renderer.
- runtime display fetches from public `hub.tinyland.dev` broker endpoints;
- the existing `PublicPulseSnapshot` validator and `/pulse` first-paint
renderer.

Blocked:

Expand Down
92 changes: 92 additions & 0 deletions src/lib/pulse/load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest';
import {
PUBLIC_SNAPSHOT_PATH,
TINYLAND_PULSE_PUBLIC_SNAPSHOT_URL,
loadPulsePublicBrokerSnapshot,
loadPulseSnapshot,
type PulseSnapshotFetch,
} from './load';
import type { PublicPulseSnapshot } from '@blog/pulse-core/schema';

const validSnapshot: PublicPulseSnapshot = {
schemaVersion: 'tinyland.pulse.v1.PublicPulseSnapshot',
generatedAt: '2026-05-10T13:00:00.000Z',
items: [
{
id: 'tinyland-pulse-note-2026-05-10-001',
kind: 'note',
occurredAt: '2026-05-10T12:30:00.000Z',
summary: 'Live hello from Tinyland',
content: 'Live hello from Tinyland',
tags: ['tinyland', 'pulse'],
},
],
manifest: {
schemaVersion: 'tinyland.pulse.v1.PublicPulseSnapshot',
generatedAt: '2026-05-10T13:00:00.000Z',
sourceSnapshotId: 'tinyland-jesssullivan-pulse-static-seed-2026-05-10',
contentHash: 'sha256:6a0552b5648f3e80f3f17edd104d7d1389c034abcdaf981651af56929fbfd44e',
itemCount: 1,
policyVersion: 'm1-2026-04-27',
},
};

const jsonResponse = (body: unknown, init: ResponseInit = {}) =>
new Response(JSON.stringify(body), {
status: init.status ?? 200,
statusText: init.statusText,
headers: { 'Content-Type': 'application/json' },
});

describe('loadPulseSnapshot', () => {
it('loads the checked-in static Pulse snapshot for first paint', async () => {
const fetchMock = vi.fn<PulseSnapshotFetch>(async () => jsonResponse(validSnapshot));

await expect(loadPulseSnapshot(fetchMock)).resolves.toEqual(validSnapshot);
expect(fetchMock).toHaveBeenCalledWith(PUBLIC_SNAPSHOT_PATH);
});
});

describe('loadPulsePublicBrokerSnapshot', () => {
it('fetches the hub broker snapshot without falling back to the checked-in file', async () => {
const fetchMock = vi.fn<PulseSnapshotFetch>(async () => jsonResponse(validSnapshot));

await expect(loadPulsePublicBrokerSnapshot(fetchMock)).resolves.toEqual(validSnapshot);
expect(fetchMock).toHaveBeenCalledWith(
TINYLAND_PULSE_PUBLIC_SNAPSHOT_URL,
expect.objectContaining({
headers: { Accept: 'application/json' },
cache: 'no-store',
}),
);
expect(fetchMock.mock.calls.flat().join(' ')).not.toContain(PUBLIC_SNAPSHOT_PATH);
});

it('rejects invalid broker snapshots instead of rendering unchecked data', async () => {
const fetchMock = vi.fn<PulseSnapshotFetch>(async () =>
jsonResponse({
...validSnapshot,
items: validSnapshot.items.map((item) => ({
...item,
latitude: 42.44,
longitude: -76.5,
})),
}),
);

await expect(loadPulsePublicBrokerSnapshot(fetchMock)).rejects.toThrow(
'pulse snapshot failed schema validation',
);
});

it('fails closed when the broker endpoint is unavailable', async () => {
const fetchMock = vi.fn<PulseSnapshotFetch>(async () =>
jsonResponse({ error: 'unavailable' }, { status: 503, statusText: 'Service Unavailable' }),
);

await expect(loadPulsePublicBrokerSnapshot(fetchMock)).rejects.toThrow(
'pulse broker snapshot fetch failed: 503',
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading