···1+# CLAUDE.md
2+3+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+5+## Project Overview
6+7+Sequoia is a CLI tool for publishing Markdown documents with frontmatter to the AT Protocol (Bluesky's decentralized social network). It converts blog posts into ATProto records (`site.standard.document`, `space.litenote.note`) and publishes them to a user's PDS.
8+9+Website: <https://sequoia.pub>
10+11+## Monorepo Structure
12+13+- **`packages/cli/`** โ Main CLI package (the core product)
14+- **`docs/`** โ Documentation website (Vocs-based, deployed to Cloudflare Pages)
15+16+Bun workspaces manage the monorepo.
17+18+## Commands
19+20+```bash
21+# Build CLI
22+bun run build:cli
23+24+# Run CLI in dev (build + link)
25+cd packages/cli && bun run dev
26+27+# Run tests
28+bun run test:cli
29+30+# Run a single test file
31+cd packages/cli && bun test src/lib/markdown.test.ts
32+33+# Lint (auto-fix)
34+cd packages/cli && bun run lint
35+36+# Format (auto-fix)
37+cd packages/cli && bun run format
38+39+# Docs dev server
40+bun run dev:docs
41+```
42+43+## Architecture
44+45+**Entry point:** `packages/cli/src/index.ts` โ Uses `cmd-ts` for type-safe subcommand routing.
46+47+**Commands** (`src/commands/`):
48+49+- `publish` โ Core workflow: scans markdown files, publishes to ATProto
50+- `sync` โ Fetches published records state from ATProto
51+- `update` โ Updates existing records
52+- `auth` โ Multi-identity management (app-password + OAuth)
53+- `init` โ Interactive config setup
54+- `inject` โ Injects verification links into static HTML output
55+- `login` โ Legacy auth (deprecated)
56+57+**Libraries** (`src/lib/`):
58+59+- `atproto.ts` โ ATProto API wrapper (two client types: AtpAgent for app-password, OAuth client)
60+- `config.ts` โ Loads `sequoia.json` config and `.sequoia-state.json` state files
61+- `credentials.ts` โ Multi-identity credential storage at `~/.config/sequoia/credentials.json` (0o600 permissions)
62+- `markdown.ts` โ Frontmatter parsing (YAML/TOML), content hashing, atUri injection
63+64+**Extensions** (`src/extensions/`):
65+66+- `litenote.ts` โ Creates `space.litenote.note` records with embedded images
67+68+## Key Patterns
69+70+- **Config resolution:** `sequoia.json` is found by searching up the directory tree
71+- **Frontmatter formats:** YAML (`---`), TOML (`+++`), and alternative (`***`) delimiters
72+- **Credential types:** App-password (PDS URL + identifier + password) and OAuth (DID + handle)
73+- **Build:** `bun build src/index.ts --target node --outdir dist`
74+75+## Tooling
76+77+- **Runtime/bundler:** Bun
78+- **Linter/formatter:** Biome (tabs, double quotes)
79+- **Test runner:** Bun's native test runner
80+- **CLI framework:** `cmd-ts`
81+- **Interactive UI:** `@clack/prompts`
82+83+## Git Conventions
84+85+Never add 'Co-authored-by' lines to git commits unless explicitly asked.
···2425It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action:
2627-<iframe width="560" height="315" src="https://www.youtube.com/embed/sxursUHq5kw?si=aZSCmkMdYPiYns8u" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
0000000002829ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons.
30
···2425It's designed to be run inside your existing repo, build a one-time config, and then be part of your regular workflow by publishing content or updating existing content, all following the Standard.site lexicons. The best part? It's designed to be fully interoperable. It doesn't matter if you're using Astro, 11ty, Hugo, Svelte, Next, Gatsby, Zola, you name it. If it's a static blog with markdown, Sequoia will work (and if for some reason it doesn't, [open an issue!](https://tangled.org/stevedylan.dev/sequoia/issues/new)). Here's a quick demo of Sequoia in action:
2627+<iframe
28+ class="w-full"
29+ style={{aspectRatio: "16/9"}}
30+ src="https://www.youtube.com/embed/sxursUHq5kw"
31+ title="YouTube video player"
32+ frameborder="0"
33+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
34+ referrerpolicy="strict-origin-when-cross-origin"
35+ allowfullscreen
36+></iframe>
3738ATProto has proven to be one of the more exciting pieces of technology that has surfaced in the past few years, and it gives some of us hope for a web that is open once more. No more walled gardens, full control of our data, and connected through lexicons.
39
+34-1
docs/docs/pages/cli-reference.mdx
···1# CLI Reference
200000000000000003## `auth`
45```bash [Terminal]
6sequoia auth
7-> Authenticate with your ATProto PDS
89OPTIONS:
10 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional]
···13 --list - List all stored identities [optional]
14 --help, -h - show help [optional]
15```
001617## `init`
18···61 --dry-run, -n - Preview what would be synced without making changes [optional]
62 --help, -h - show help [optional]
63```
000000000000000
···1# CLI Reference
23+## `login`
4+5+```bash [Terminal]
6+sequoia login
7+> Login with OAuth (browser-based authentication)
8+9+OPTIONS:
10+ --logout <str> - Remove OAuth session for a specific DID [optional]
11+12+FLAGS:
13+ --list - List all stored OAuth sessions [optional]
14+ --help, -h - show help [optional]
15+```
16+17+OAuth is the recommended authentication method as it scopes permissions and refreshes tokens automatically.
18+19## `auth`
2021```bash [Terminal]
22sequoia auth
23+> Authenticate with your ATProto PDS using an app password
2425OPTIONS:
26 --logout <str> - Remove credentials for a specific identity (or all if only one exists) [optional]
···29 --list - List all stored identities [optional]
30 --help, -h - show help [optional]
31```
32+33+Use this as an alternative to `login` when OAuth isn't available or for CI environments.
3435## `init`
36···79 --dry-run, -n - Preview what would be synced without making changes [optional]
80 --help, -h - show help [optional]
81```
82+83+## `update`
84+85+```bash [Terminal]
86+sequoia update
87+> Update local config or ATProto publication record
88+89+FLAGS:
90+ --help, -h - show help [optional]
91+```
92+93+Interactive command to modify your existing configuration. Choose between:
94+95+- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings
96+- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+41-2
docs/docs/pages/config.mdx
···14| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15| `identity` | `string` | No | - | Which stored identity to use |
16| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
017| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
000001819### Example
20···31 "frontmatter": {
32 "publishDate": "date"
33 },
34- "ignore": ["_index.md"]
000035}
36```
37···44| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
45| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
46| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
04748### Example
49···54publishDate: 2024-01-15
55ogImage: cover.jpg
56tags: [welcome, intro]
057---
58```
59···65{
66 "frontmatter": {
67 "publishDate": "date",
68- "coverImage": "thumbnail"
000000000000069 }
70}
71```
000000000000007273### Ignoring Files
74
···14| `pdsUrl` | `string` | No | `"https://bsky.social"` | PDS server URL, generated automatically |
15| `identity` | `string` | No | - | Which stored identity to use |
16| `frontmatter` | `object` | No | - | Custom frontmatter field mappings |
17+| `frontmatter.slugField` | `string` | No | - | Frontmatter field to use for slug (defaults to filepath) |
18| `ignore` | `string[]` | No | - | Glob patterns for files to ignore |
19+| `removeIndexFromSlug` | `boolean` | No | `false` | Remove `/index` or `/_index` suffix from slugs |
20+| `stripDatePrefix` | `boolean` | No | `false` | Remove `YYYY-MM-DD-` date prefixes from slugs (Jekyll-style) |
21+| `bluesky` | `object` | No | - | Bluesky posting configuration |
22+| `bluesky.enabled` | `boolean` | No | `false` | Post to Bluesky when publishing documents |
23+| `bluesky.maxAgeDays` | `number` | No | `30` | Only post documents published within this many days |
2425### Example
26···37 "frontmatter": {
38 "publishDate": "date"
39 },
40+ "ignore": ["_index.md"],
41+ "bluesky": {
42+ "enabled": true,
43+ "maxAgeDays": 30
44+ }
45}
46```
47···54| `publishDate` | `string` | Yes | `"publishDate"`, `"pubDate"`, `"date"`, `"createdAt"`, `"created_at"` | Publication date |
55| `coverImage` | `string` | No | `"ogImage"` | Cover image filename |
56| `tags` | `string[]` | No | `"tags"` | Post tags/categories |
57+| `draft` | `boolean` | No | `"draft"` | If `true`, post is skipped during publish |
5859### Example
60···65publishDate: 2024-01-15
66ogImage: cover.jpg
67tags: [welcome, intro]
68+draft: false
69---
70```
71···77{
78 "frontmatter": {
79 "publishDate": "date",
80+ "coverImage": "thumbnail",
81+ "draft": "private"
82+ }
83+}
84+```
85+86+### Slug Configuration
87+88+By default, slugs are generated from the filepath (e.g., `posts/my-post.md` becomes `posts/my-post`). To use a frontmatter field instead:
89+90+```json
91+{
92+ "frontmatter": {
93+ "slugField": "url"
94 }
95}
96```
97+98+If the frontmatter field is not found, it falls back to the filepath.
99+100+### Jekyll-Style Date Prefixes
101+102+Jekyll uses date prefixes in filenames (e.g., `2024-01-15-my-post.md`) for ordering posts. To strip these from generated slugs:
103+104+```json
105+{
106+ "stripDatePrefix": true
107+}
108+```
109+110+This transforms `2024-01-15-my-post.md` into the slug `my-post`.
111112### Ignoring Files
113
+39-1
docs/docs/pages/publishing.mdx
···10sequoia publish --dry-run
11```
1213-This will print out the posts that it has discovered, what will be published, and how many. Once everything looks good, send it!
1415```bash [Terminal]
16sequoia publish
···27```
2829Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config.
000000000000000000000000000000000000003031## Troubleshooting
32
···10sequoia publish --dry-run
11```
1213+This will print out the posts that it has discovered, what will be published, and how many. If Bluesky posting is enabled, it will also show which posts will be shared to Bluesky. Once everything looks good, send it!
1415```bash [Terminal]
16sequoia publish
···27```
2829Sync will use your ATProto handle to look through all of the `standard.site.document` records on your PDS, and pull down the records that are for the publication in the config.
30+31+## Bluesky Posting
32+33+Sequoia can automatically post to Bluesky when new documents are published. Enable this in your config:
34+35+```json
36+{
37+ "bluesky": {
38+ "enabled": true,
39+ "maxAgeDays": 30
40+ }
41+}
42+```
43+44+When enabled, each new document will create a Bluesky post with the title, description, and canonical URL. If a cover image exists, it will be embedded in the post. The combined content is limited to 300 characters.
45+46+The `maxAgeDays` setting prevents flooding your feed when first setting up Sequoia. For example, if you have 40 existing blog posts, only those published within the last 30 days will be posted to Bluesky.
47+48+## Draft Posts
49+50+Posts with `draft: true` in their frontmatter are automatically skipped during publishing. This lets you work on content without accidentally publishing it.
51+52+```yaml
53+---
54+title: Work in Progress
55+draft: true
56+---
57+```
58+59+If your framework uses a different field name (like `private` or `hidden`), configure it in `sequoia.json`:
60+61+```json
62+{
63+ "frontmatter": {
64+ "draft": "private"
65+ }
66+}
67+```
6869## Troubleshooting
70
+9-7
docs/docs/pages/quickstart.mdx
···31sequoia
32```
3334-### Authorize
35-36-In order for Sequoia to publish or update records on your PDS, you need to authorize it with your ATProto handle and an app password.
3738-:::tip
39-You can create an app password [here](https://bsky.app/settings/app-passwords)
40-:::
4142```bash [Terminal]
43-sequoia auth
44```
0000004546### Initialize
47
···31sequoia
32```
3334+### Login
003536+In order for Sequoia to publish or update records on your PDS, you need to authenticate with your ATProto account.
003738```bash [Terminal]
39+sequoia login
40```
41+42+This will open your browser to complete OAuth authentication, and your sessions will refresh automatically as you use the CLI.
43+44+:::tip
45+Alternatively, you can use `sequoia auth` to authenticate with an [app password](https://bsky.app/settings/app-passwords) instead of OAuth.
46+:::
4748### Initialize
49
docs/docs/public/icon-dark.png
This is a binary file and will not be displayed.
docs/docs/public/og.png
This is a binary file and will not be displayed.
+2-1
package.json
···11 "build:docs": "cd docs && bun run build",
12 "build:cli": "cd packages/cli && bun run build",
13 "deploy:docs": "cd docs && bun run deploy",
14- "deploy:cli": "cd packages/cli && bun run deploy"
015 },
16 "devDependencies": {
17 "@types/bun": "latest",
···11 "build:docs": "cd docs && bun run build",
12 "build:cli": "cd packages/cli && bun run build",
13 "deploy:docs": "cd docs && bun run deploy",
14+ "deploy:cli": "cd packages/cli && bun run deploy",
15+ "test:cli": "cd packages/cli && bun test"
16 },
17 "devDependencies": {
18 "@types/bun": "latest",
···1+import * as fs from "node:fs/promises";
2+import * as os from "node:os";
3+import * as path from "node:path";
4+import type {
5+ NodeSavedSession,
6+ NodeSavedSessionStore,
7+ NodeSavedState,
8+ NodeSavedStateStore,
9+} from "@atproto/oauth-client-node";
10+11+const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
12+const OAUTH_FILE = path.join(CONFIG_DIR, "oauth.json");
13+14+interface OAuthStore {
15+ states: Record<string, NodeSavedState>;
16+ sessions: Record<string, NodeSavedSession>;
17+ handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat)
18+}
19+20+async function fileExists(filePath: string): Promise<boolean> {
21+ try {
22+ await fs.access(filePath);
23+ return true;
24+ } catch {
25+ return false;
26+ }
27+}
28+29+async function loadOAuthStore(): Promise<OAuthStore> {
30+ if (!(await fileExists(OAUTH_FILE))) {
31+ return { states: {}, sessions: {} };
32+ }
33+34+ try {
35+ const content = await fs.readFile(OAUTH_FILE, "utf-8");
36+ return JSON.parse(content) as OAuthStore;
37+ } catch {
38+ return { states: {}, sessions: {} };
39+ }
40+}
41+42+async function saveOAuthStore(store: OAuthStore): Promise<void> {
43+ await fs.mkdir(CONFIG_DIR, { recursive: true });
44+ await fs.writeFile(OAUTH_FILE, JSON.stringify(store, null, 2));
45+ await fs.chmod(OAUTH_FILE, 0o600);
46+}
47+48+/**
49+ * State store for PKCE flow (temporary, used during auth)
50+ */
51+export const stateStore: NodeSavedStateStore = {
52+ async set(key: string, state: NodeSavedState): Promise<void> {
53+ const store = await loadOAuthStore();
54+ store.states[key] = state;
55+ await saveOAuthStore(store);
56+ },
57+58+ async get(key: string): Promise<NodeSavedState | undefined> {
59+ const store = await loadOAuthStore();
60+ return store.states[key];
61+ },
62+63+ async del(key: string): Promise<void> {
64+ const store = await loadOAuthStore();
65+ delete store.states[key];
66+ await saveOAuthStore(store);
67+ },
68+};
69+70+/**
71+ * Session store for OAuth tokens (persistent)
72+ */
73+export const sessionStore: NodeSavedSessionStore = {
74+ async set(sub: string, session: NodeSavedSession): Promise<void> {
75+ const store = await loadOAuthStore();
76+ store.sessions[sub] = session;
77+ await saveOAuthStore(store);
78+ },
79+80+ async get(sub: string): Promise<NodeSavedSession | undefined> {
81+ const store = await loadOAuthStore();
82+ return store.sessions[sub];
83+ },
84+85+ async del(sub: string): Promise<void> {
86+ const store = await loadOAuthStore();
87+ delete store.sessions[sub];
88+ await saveOAuthStore(store);
89+ },
90+};
91+92+/**
93+ * List all stored OAuth session DIDs
94+ */
95+export async function listOAuthSessions(): Promise<string[]> {
96+ const store = await loadOAuthStore();
97+ return Object.keys(store.sessions);
98+}
99+100+/**
101+ * Get an OAuth session by DID
102+ */
103+export async function getOAuthSession(
104+ did: string,
105+): Promise<NodeSavedSession | undefined> {
106+ const store = await loadOAuthStore();
107+ return store.sessions[did];
108+}
109+110+/**
111+ * Delete an OAuth session by DID
112+ */
113+export async function deleteOAuthSession(did: string): Promise<boolean> {
114+ const store = await loadOAuthStore();
115+ if (!store.sessions[did]) {
116+ return false;
117+ }
118+ delete store.sessions[did];
119+ await saveOAuthStore(store);
120+ return true;
121+}
122+123+export function getOAuthStorePath(): string {
124+ return OAUTH_FILE;
125+}
126+127+/**
128+ * Store handle for an OAuth session (DID -> handle mapping)
129+ */
130+export async function setOAuthHandle(
131+ did: string,
132+ handle: string,
133+): Promise<void> {
134+ const store = await loadOAuthStore();
135+ if (!store.handles) {
136+ store.handles = {};
137+ }
138+ store.handles[did] = handle;
139+ await saveOAuthStore(store);
140+}
141+142+/**
143+ * Get handle for an OAuth session by DID
144+ */
145+export async function getOAuthHandle(did: string): Promise<string | undefined> {
146+ const store = await loadOAuthStore();
147+ return store.handles?.[did];
148+}
149+150+/**
151+ * List all stored OAuth sessions with their handles
152+ */
153+export async function listOAuthSessionsWithHandles(): Promise<
154+ Array<{ did: string; handle?: string }>
155+> {
156+ const store = await loadOAuthStore();
157+ return Object.keys(store.sessions).map((did) => ({
158+ did,
159+ handle: store.handles?.[did],
160+ }));
161+}
+6-6
packages/cli/src/lib/prompts.ts
···1-import { isCancel, cancel } from "@clack/prompts";
23export function exitOnCancel<T>(value: T | symbol): T {
4- if (isCancel(value)) {
5- cancel("Cancelled");
6- process.exit(0);
7- }
8- return value as T;
9}
···1+import { cancel, isCancel } from "@clack/prompts";
23export function exitOnCancel<T>(value: T | symbol): T {
4+ if (isCancel(value)) {
5+ cancel("Cancelled");
6+ process.exit(0);
7+ }
8+ return value as T;
9}
+56-1
packages/cli/src/lib/types.ts
···4 publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
5 coverImage?: string; // Field name for cover image (default: "ogImage")
6 tags?: string; // Field name for tags (default: "tags")
000000000000007}
89export interface PublisherConfig {
···18 identity?: string; // Which stored identity to use (matches identifier)
19 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
20 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
000021}
2223-export interface Credentials {
00000000024 pdsUrl: string;
25 identifier: string;
26 password: string;
27}
2800000000000000000000000029export interface PostFrontmatter {
30 title: string;
31 description?: string;
···33 tags?: string[];
34 ogImage?: string;
35 atUri?: string;
036}
3738export interface BlogPost {
···41 frontmatter: PostFrontmatter;
42 content: string;
43 rawContent: string;
044}
4546export interface BlobRef {
···62 contentHash: string;
63 atUri?: string;
64 lastPublished?: string;
0065}
6667export interface PublicationRecord {
···4 publishDate?: string; // Field name for publish date (default: "publishDate", also checks "pubDate", "date", "createdAt", "created_at")
5 coverImage?: string; // Field name for cover image (default: "ogImage")
6 tags?: string; // Field name for tags (default: "tags")
7+ draft?: string; // Field name for draft status (default: "draft")
8+ slugField?: string; // Frontmatter field to use for slug (if set, uses frontmatter value; otherwise uses filepath)
9+}
10+11+// Strong reference for Bluesky post (com.atproto.repo.strongRef)
12+export interface StrongRef {
13+ uri: string; // at:// URI format
14+ cid: string; // Content ID
15+}
16+17+// Bluesky posting configuration
18+export interface BlueskyConfig {
19+ enabled: boolean;
20+ maxAgeDays?: number; // Only post if published within N days (default: 7)
21}
2223export interface PublisherConfig {
···32 identity?: string; // Which stored identity to use (matches identifier)
33 frontmatter?: FrontmatterMapping; // Custom frontmatter field mappings
34 ignore?: string[]; // Glob patterns for files to ignore (e.g., ["_index.md", "**/drafts/**"])
35+ removeIndexFromSlug?: boolean; // Remove "/index" or "/_index" suffix from paths (default: false)
36+ stripDatePrefix?: boolean; // Remove YYYY-MM-DD- prefix from filenames (Jekyll-style, default: false)
37+ textContentField?: string; // Frontmatter field to use for textContent instead of markdown body
38+ bluesky?: BlueskyConfig; // Optional Bluesky posting configuration
39}
4041+// Legacy credentials format (for backward compatibility during migration)
42+export interface LegacyCredentials {
43+ pdsUrl: string;
44+ identifier: string;
45+ password: string;
46+}
47+48+// App password credentials (explicit type)
49+export interface AppPasswordCredentials {
50+ type: "app-password";
51 pdsUrl: string;
52 identifier: string;
53 password: string;
54}
5556+// OAuth credentials (references stored OAuth session)
57+// Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID
58+export interface OAuthCredentials {
59+ type: "oauth";
60+ did: string;
61+ handle: string;
62+}
63+64+// Union type for all credential types
65+export type Credentials = AppPasswordCredentials | OAuthCredentials;
66+67+// Helper to check credential type
68+export function isOAuthCredentials(
69+ creds: Credentials,
70+): creds is OAuthCredentials {
71+ return creds.type === "oauth";
72+}
73+74+export function isAppPasswordCredentials(
75+ creds: Credentials,
76+): creds is AppPasswordCredentials {
77+ return creds.type === "app-password";
78+}
79+80export interface PostFrontmatter {
81 title: string;
82 description?: string;
···84 tags?: string[];
85 ogImage?: string;
86 atUri?: string;
87+ draft?: boolean;
88}
8990export interface BlogPost {
···93 frontmatter: PostFrontmatter;
94 content: string;
95 rawContent: string;
96+ rawFrontmatter: Record<string, unknown>; // For accessing custom fields like textContentField
97}
9899export interface BlobRef {
···115 contentHash: string;
116 atUri?: string;
117 lastPublished?: string;
118+ slug?: string; // The generated slug for this post (used by inject command)
119+ bskyPostRef?: StrongRef; // Reference to corresponding Bluesky post
120}
121122export interface PublicationRecord {