A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1---
2import Base from '../layouts/Base.astro';
3import Logo from '../components/Logo.astro';
4import Footer from '../components/Footer.astro';
5import {
6 CONTENT_LEXICON,
7 CONTENT_LEXICON_ID,
8 CONTENT_LEXICON_DESCRIPTION,
9 contentSchemaFields,
10} from '../lib/lexicon/schema-doc';
11import { parseInlineCode } from '../lib/lexicon/inline-code';
12import { highlightSource } from '../lib/reader/highlight';
13
14const contentFields = contentSchemaFields();
15const schemaJson = JSON.stringify( CONTENT_LEXICON, null, 2 );
16// The schema is JSON we control, so highlight it directly (known language) rather
17// than auto-detecting. Server-side at build time → no client JS, like the reader.
18const schemaJsonHtml = highlightSource( schemaJson, 'json' );
19
20// A faithful, trimmed slice of a real published article's content object (the
21// "Potato buns, one way" recipe), so the page can show how the block tree
22// actually nests rather than describe it abstractly. The live record has 17
23// top-level blocks; this keeps the first heading and a two-item list, with the
24// `$type` union discriminator and `version` intact.
25const exampleContent = {
26 $type: 'blog.skypress.content.gutenberg',
27 version: 1,
28 blocks: [
29 {
30 name: 'core/heading',
31 attributes: { level: 2, content: 'Ingredients' },
32 innerBlocks: [],
33 },
34 {
35 name: 'core/list',
36 attributes: { ordered: false, values: '' },
37 innerBlocks: [
38 {
39 name: 'core/list-item',
40 attributes: { content: '600g T45 flour' },
41 innerBlocks: [],
42 },
43 {
44 name: 'core/list-item',
45 attributes: { content: '200g warm milk' },
46 innerBlocks: [],
47 },
48 ],
49 },
50 ],
51};
52const exampleJson = JSON.stringify( exampleContent, null, 2 );
53const exampleJsonHtml = highlightSource( exampleJson, 'json' );
54---
55
56<Base
57 title="The lexicon: how SkyPress stores your writing"
58 description="The open AT Protocol records SkyPress writes to your account when you publish: a publication, a document with your block content, and a Bluesky post linking back."
59>
60 <header class="lex-masthead">
61 <a class="lex-home" href="/" aria-label="SkyPress home"><Logo /></a>
62 <a class="btn btn--ghost" href="/write">Start writing</a>
63 </header>
64
65 <main class="lex">
66 <p class="lex-eyebrow">The open format</p>
67 <h1 class="lex-title">Your words live in <em>your</em> account.</h1>
68 <p class="lex-lede">
69 When you publish with SkyPress, nothing gets locked inside SkyPress. Your
70 writing is saved to your own AT Protocol account, in open formats any
71 reader on the social web can understand. You can take it anywhere. Here’s
72 exactly what we write, and why.
73 </p>
74
75 <section class="lex-section">
76 <h2>What gets written when you publish</h2>
77 <p>Publishing creates a few records on your PDS (the data store behind your account):</p>
78 <ul class="lex-records">
79 <li>
80 <code>site.standard.publication</code>: your SkyPress home. We create
81 it once, then reuse it.
82 </li>
83 <li>
84 <code>site.standard.document</code>: the article itself, with its
85 title, a plain-text copy, and your block content.
86 </li>
87 <li>
88 <code>app.bsky.feed.post</code>: a public Bluesky post that links back
89 to the article, so people can find it.
90 </li>
91 </ul>
92 <p>
93 The first two use the community
94 <a href="https://tangled.org/standard.site/lexicons" target="_blank" rel="noopener noreferrer">standard.site</a>
95 lexicons, not anything SkyPress-specific, so other tools and readers that
96 already speak them can pick up your writing too. The one format SkyPress
97 actually owns is the block content tucked inside the document; that’s
98 the part below.
99 </p>
100 <p class="lex-honest">
101 One thing worth repeating: publishing also creates a public Bluesky post.
102 We’ll always remind you before you hit publish :)
103 </p>
104 </section>
105
106 <section class="lex-section">
107 <h2>The format we own: <code>{ CONTENT_LEXICON_ID }</code></h2>
108 <p>{ parseInlineCode( CONTENT_LEXICON_DESCRIPTION ).map( ( s ) => ( s.code ? <code>{ s.text }</code> : s.text ) ) }</p>
109 <table class="lex-table">
110 <thead>
111 <tr><th scope="col">Field</th><th scope="col">Type</th><th scope="col">Required</th><th scope="col">What it is</th></tr>
112 </thead>
113 <tbody>
114 { contentFields.map( ( f ) => (
115 <tr>
116 <td><code>{ f.name }</code></td>
117 <td>{ f.type }</td>
118 <td>{ f.required ? 'yes' : 'optional' }</td>
119 <td>{ parseInlineCode( f.description ).map( ( s ) => ( s.code ? <code>{ s.text }</code> : s.text ) ) }</td>
120 </tr>
121 ) ) }
122 </tbody>
123 </table>
124 <p class="lex-note">
125 This is the only lexicon SkyPress owns. If a reader doesn’t understand the
126 format, it falls back to the document’s plain-text
127 <code>textContent</code>, so your article is never unreadable. Here’s
128 the full schema:
129 </p>
130 <pre class="lex-code"><code class="hljs language-json" set:html={ schemaJsonHtml } /></pre>
131
132 <h3>What the blocks look like</h3>
133 <p>
134 The schema says <code>blocks</code> is “the block tree exactly as
135 produced by the editor”, which is a bit abstract. So here’s a
136 real one. This is the <code>content</code> object from a published recipe,
137 <a href="/@jeherve.com/recipes/3mnwlydxcr22u">Potato buns, one way</a>,
138 trimmed to its first heading and a two-item list:
139 </p>
140 <pre class="lex-code"><code class="hljs language-json" set:html={ exampleJsonHtml } /></pre>
141 <p>
142 Every block is the same little shape: a <code>name</code> (the Gutenberg
143 block, like <code>core/heading</code>), an <code>attributes</code> object
144 for its content and settings, and <code>innerBlocks</code> for anything
145 nested inside it, which is how the list here holds its items. The same
146 shape repeats all the way down. The <code>$type</code> tells any reader
147 which format it’s looking at; everything else is just blocks. You can
148 see the full record behind any article yourself with the
149 “view record” link in its eyebrow :)
150 </p>
151 </section>
152
153 <section class="lex-section">
154 <h2>Want to run your own?</h2>
155 <p>
156 SkyPress is open source (GPL-2.0-or-later), and it’s only ever an
157 editor, an OAuth client, and a public renderer, never a home for your
158 data. Your writing already lives in your AT Protocol account, in the
159 open records above, so nothing’s tied to this particular site.
160 </p>
161 <p>
162 If you’d rather not use skypress.blog, you can host your own copy. It’s
163 an Astro app that deploys to Cloudflare; the only thing you need to set is a
164 <code>PUBLIC_SITE_URL</code>, and the OAuth client metadata and redirect URIs
165 adapt to wherever you put it. The code, and the setup steps, live in the repo:
166 <a href="https://tangled.org/jeremy.herve.bzh/skypress" target="_blank" rel="noopener noreferrer">tangled.org/jeremy.herve.bzh/skypress</a>.
167 Fork it, send patches, or just have a read :)
168 </p>
169 </section>
170
171 <div class="lex-actions">
172 <a class="btn btn--primary" href="/write">Start writing</a>
173 </div>
174 </main>
175
176 <Footer />
177</Base>
178
179<style>
180 .lex-masthead {
181 display: flex;
182 align-items: center;
183 justify-content: space-between;
184 padding: 1.5rem clamp( 1.25rem, 5vw, 4rem );
185 border-bottom: 1px solid var( --line );
186 }
187 .lex-home { text-decoration: none; }
188
189 .lex {
190 max-width: 48rem;
191 margin: 0 auto;
192 padding: clamp( 2.5rem, 7vh, 4.5rem ) clamp( 1.25rem, 5vw, 4rem ) 4rem;
193 }
194 .lex-eyebrow {
195 font-family: var( --font-mono );
196 text-transform: uppercase;
197 letter-spacing: 0.12em;
198 font-size: 0.72rem;
199 color: var( --sun );
200 margin: 0;
201 }
202 .lex-title {
203 font-size: clamp( 2.2rem, 6vw, 3.4rem );
204 font-weight: 760;
205 letter-spacing: -0.03em;
206 line-height: 1.02;
207 margin: 0.5rem 0 0;
208 }
209 .lex-title em { font-style: italic; color: var( --sun ); }
210 .lex-lede {
211 font-size: clamp( 1.1rem, 2.2vw, 1.3rem );
212 line-height: 1.6;
213 color: var( --ink-soft );
214 margin: 1.25rem 0 0;
215 }
216
217 .lex-section {
218 margin-top: 3rem;
219 padding-top: 2.5rem;
220 border-top: 1px solid var( --line );
221 }
222 .lex-section h2 {
223 font-size: 1.5rem;
224 letter-spacing: -0.02em;
225 margin: 0 0 0.75rem;
226 }
227 .lex-section h3 {
228 font-size: 1.1rem;
229 margin: 2rem 0 0.6rem;
230 }
231 .lex-section p { color: var( --ink-soft ); line-height: 1.65; }
232 .lex-section code {
233 font-family: var( --font-mono );
234 font-size: 0.86em;
235 background: var( --sun-tint );
236 color: var( --sun-strong );
237 padding: 0.08em 0.36em;
238 border-radius: 5px;
239 }
240 /* Long NSIDs (e.g. blog.skypress.content.gutenberg) have no spaces, so let
241 them break inside headings rather than overflow narrow viewports. */
242 .lex-section h2 code,
243 .lex-section h3 code {
244 overflow-wrap: anywhere;
245 word-break: break-word;
246 }
247
248 .lex-records { padding-left: 1.2rem; color: var( --ink-soft ); line-height: 1.7; }
249 .lex-records li { margin: 0.35rem 0; }
250 .lex-honest { color: var( --muted ); font-size: 0.95rem; }
251 .lex-note { margin-top: 1.25rem; }
252
253 .lex-table {
254 width: 100%;
255 border-collapse: collapse;
256 margin: 1rem 0 0;
257 font-size: 0.95rem;
258 }
259 .lex-table th,
260 .lex-table td {
261 text-align: left;
262 vertical-align: top;
263 padding: 0.6rem 0.75rem;
264 border-bottom: 1px solid var( --line );
265 }
266 .lex-table th {
267 font-family: var( --font-mono );
268 text-transform: uppercase;
269 letter-spacing: 0.04em;
270 font-size: 0.7rem;
271 color: var( --muted );
272 }
273 .lex-table td { color: var( --ink-soft ); }
274 .lex-table td:first-child code { white-space: nowrap; }
275
276 .lex-code {
277 margin-top: 1.25rem;
278 background: var( --paper-raised );
279 border: 1px solid var( --line );
280 border-radius: 10px;
281 padding: 1rem 1.1rem;
282 overflow: auto;
283 font-size: 0.85rem;
284 line-height: 1.55;
285 /* String/number hues that read on the light code surface; the warm-orange
286 and muted token colours below reuse brand tokens that already flip. */
287 --code-string: #3f7d4f;
288 --code-number: #2f6f86;
289 }
290 .lex-code code {
291 font-family: var( --font-mono );
292 background: none;
293 color: var( --ink );
294 padding: 0;
295 }
296 /* highlight.js token colours, scoped to this light code block (unlike the
297 reader's dark <pre>). The spans arrive via set:html, so they need :global.
298 No background/padding on .hljs, so the .lex-code chrome above is untouched. */
299 .lex-code :global( .hljs-comment ),
300 .lex-code :global( .hljs-quote ),
301 .lex-code :global( .hljs-punctuation ) {
302 color: var( --muted );
303 }
304 .lex-code :global( .hljs-comment ),
305 .lex-code :global( .hljs-quote ) {
306 font-style: italic;
307 }
308 .lex-code :global( .hljs-attr ),
309 .lex-code :global( .hljs-keyword ),
310 .lex-code :global( .hljs-selector-tag ),
311 .lex-code :global( .hljs-name ) {
312 color: var( --sun-strong );
313 }
314 .lex-code :global( .hljs-string ),
315 .lex-code :global( .hljs-symbol ),
316 .lex-code :global( .hljs-meta .hljs-string ) {
317 color: var( --code-string );
318 }
319 .lex-code :global( .hljs-number ),
320 .lex-code :global( .hljs-literal ),
321 .lex-code :global( .hljs-built_in ) {
322 color: var( --code-number );
323 }
324
325 @media ( prefers-color-scheme: dark ) {
326 .lex-code {
327 --code-string: #8fc98f;
328 --code-number: #7fb2c9;
329 }
330 }
331
332 .lex-actions {
333 display: flex;
334 gap: 0.85rem;
335 flex-wrap: wrap;
336 margin-top: 3rem;
337 padding-top: 2.5rem;
338 border-top: 1px solid var( --line );
339 }
340</style>