···11+MIT License Copyright (c) 2025 flo-bit
22+33+Permission is hereby granted, free of
44+charge, to any person obtaining a copy of this software and associated
55+documentation files (the "Software"), to deal in the Software without
66+restriction, including without limitation the rights to use, copy, modify, merge,
77+publish, distribute, sublicense, and/or sell copies of the Software, and to
88+permit persons to whom the Software is furnished to do so, subject to the
99+following conditions:
1010+1111+The above copyright notice and this permission notice
1212+(including the next paragraph) shall be included in all copies or substantial
1313+portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
1616+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
1717+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
1818+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
1919+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
2020+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
2121+THE SOFTWARE.
+49
README.md
···11+# Editable Website
22+33+Work in progress! Preview only.
44+55+https://flo-bit.dev/svelsky/
66+77+Statically built svelte website using your bluesky pds as a backend with a wysiwyg editor.
88+99+## Why?
1010+1111+- Statically built websites are fast and super cheap to host (often free on github
1212+pages, cloudflare, etc).
1313+1414+- But they are usually hard to edit (for non-technical users), either you edit
1515+the code directly or you have to use (and usually pay for) a CMS of some kind.
1616+1717+- This repo aims to combine the best of both worlds: cheap, fast and easy to edit
1818+(content editing only, design is static/only changeable by editing code).
1919+2020+## Development
2121+2222+```bash
2323+pnpm install
2424+pnpm run dev
2525+```
2626+2727+## Deployment with github pages
2828+2929+1. fork the repo and enable github pages in the repo settings (Settings -> Pages -> Source -> Github Actions)
3030+3131+2. change the handle to your bluesky handle in `.github/workflows/deploy.yml` line 32:
3232+3333+```bash
3434+PUBLIC_HANDLE: 'your-bluesky-handle'
3535+```
3636+3737+3. change the base path to your repo name in `svelte.config.js` line 13:
3838+3939+```ts
4040+base: process.env.NODE_ENV === 'development' ? '' : '/svelsky'
4141+```
4242+4343+4. push to github and wait for it to deploy
4444+4545+5. edit the website by going to `https://<your-github-username>.github.io/<repo-name>/edit`,
4646+signing in with your bluesky account, editing the website and saving at the end.
4747+4848+6. rerun the workflow manually by selecting the last workflow in the github actions tab and
4949+clicking the `Re-run all jobs` button or wait for the scheduled workflow that runs every 6 hours.
···11+import { InputRule, markInputRule, markPasteRule, PasteRule } from '@tiptap/core';
22+import { Link } from '@tiptap/extension-link';
33+44+import type { LinkOptions } from '@tiptap/extension-link';
55+66+/**
77+ * The input regex for Markdown links with title support, and multiple quotation marks (required
88+ * in case the `Typography` extension is being included).
99+ */
1010+const inputRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)$/i;
1111+1212+/**
1313+ * The paste regex for Markdown links with title support, and multiple quotation marks (required
1414+ * in case the `Typography` extension is being included).
1515+ */
1616+const pasteRegex = /(?:^|\s)\[([^\]]*)?\]\((\S+)(?: ["“](.+)["”])?\)/gi;
1717+1818+/**
1919+ * Input rule built specifically for the `Link` extension, which ignores the auto-linked URL in
2020+ * parentheses (e.g., `(https://doist.dev)`).
2121+ *
2222+ * @see https://github.com/ueberdosis/tiptap/discussions/1865
2323+ */
2424+function linkInputRule(config: Parameters<typeof markInputRule>[0]) {
2525+ const defaultMarkInputRule = markInputRule(config);
2626+2727+ return new InputRule({
2828+ find: config.find,
2929+ handler(props) {
3030+ const { tr } = props.state;
3131+3232+ defaultMarkInputRule.handler(props);
3333+ tr.setMeta('preventAutolink', true);
3434+ }
3535+ });
3636+}
3737+3838+/**
3939+ * Paste rule built specifically for the `Link` extension, which ignores the auto-linked URL in
4040+ * parentheses (e.g., `(https://doist.dev)`). This extension was inspired from the multiple
4141+ * implementations found in a Tiptap discussion at GitHub.
4242+ *
4343+ * @see https://github.com/ueberdosis/tiptap/discussions/1865
4444+ */
4545+function linkPasteRule(config: Parameters<typeof markPasteRule>[0]) {
4646+ const defaultMarkPasteRule = markPasteRule(config);
4747+4848+ return new PasteRule({
4949+ find: config.find,
5050+ handler(props) {
5151+ const { tr } = props.state;
5252+5353+ defaultMarkPasteRule.handler(props);
5454+ tr.setMeta('preventAutolink', true);
5555+ }
5656+ });
5757+}
5858+5959+/**
6060+ * The options available to customize the `RichTextLink` extension.
6161+ */
6262+type RichTextLinkOptions = LinkOptions;
6363+6464+/**
6565+ * Custom extension that extends the built-in `Link` extension to add additional input/paste rules
6666+ * for converting the Markdown link syntax (i.e. `[Doist](https://doist.com)`) into links, and also
6767+ * adds support for the `title` attribute.
6868+ */
6969+const RichTextLink = Link.extend<RichTextLinkOptions>({
7070+ inclusive: false,
7171+ addOptions() {
7272+ return {
7373+ ...this.parent?.(),
7474+ openOnClick: 'whenNotEditable'
7575+ };
7676+ },
7777+ addAttributes() {
7878+ return {
7979+ ...this.parent?.(),
8080+ title: {
8181+ default: null
8282+ }
8383+ };
8484+ },
8585+ addInputRules() {
8686+ return [
8787+ linkInputRule({
8888+ find: inputRegex,
8989+ type: this.type,
9090+9191+ // We need to use `pop()` to remove the last capture groups from the match to
9292+ // satisfy Tiptap's `markPasteRule` expectation of having the content as the last
9393+ // capture group in the match (this makes the attribute order important)
9494+ getAttributes(match) {
9595+ return {
9696+ title: match.pop()?.trim(),
9797+ href: match.pop()?.trim()
9898+ };
9999+ }
100100+ })
101101+ ];
102102+ },
103103+ addPasteRules() {
104104+ return [
105105+ linkPasteRule({
106106+ find: pasteRegex,
107107+ type: this.type,
108108+109109+ // We need to use `pop()` to remove the last capture groups from the match to
110110+ // satisfy Tiptap's `markInputRule` expectation of having the content as the last
111111+ // capture group in the match (this makes the attribute order important)
112112+ getAttributes(match) {
113113+ return {
114114+ title: match.pop()?.trim(),
115115+ href: match.pop()?.trim()
116116+ };
117117+ }
118118+ })
119119+ ];
120120+ }
121121+});
122122+123123+export { RichTextLink };
124124+125125+export type { RichTextLinkOptions };
+3
src/lib/website/components/markdown/index.ts
···11+export { default as MarkdownItem } from './MarkdownItem.svelte';
22+export { default as MarkdownText } from './MarkdownText.svelte';
33+export { default as MarkdownTextEditor } from './MarkdownTextEditor.svelte';
···11+export { default as PlainTextItem } from './PlainTextItem.svelte';
22+export { default as PlainTextEditor } from './PlainTextEditor.svelte';
33+export { default as PlainText } from './PlainText.svelte';
···11+export const image_collection = 'com.example.image' as const;
22+33+// collections and records we want to grab
44+export const data = {
55+ 'app.bsky.actor.profile': ['self'],
66+77+ 'com.example.bento': 'all'
88+} as const;
···11+export {
22+ default as Button,
33+ type ButtonProps,
44+ type ButtonSize,
55+ type ButtonVariant,
66+ buttonVariants
77+} from './Button.svelte';
···11+import Heading from './Heading.svelte';
22+import Subheading from './Subheading.svelte';
33+44+export { Heading, Subheading };
+16
src/lib/website/foxui/index.ts
···11+import { type ClassValue, clsx } from 'clsx';
22+import { twMerge } from 'tailwind-merge';
33+44+export function cn(...inputs: ClassValue[]) {
55+ return twMerge(clsx(inputs));
66+}
77+88+export * from './avatar';
99+export * from './bluesky-login';
1010+export * from './button';
1111+export * from './heading';
1212+export * from './input';
1313+export * from './label';
1414+export * from './modal';
1515+export * from './navbar';
1616+export * from './sonner';
···11+import Root, {
22+ type InputProps,
33+ type InputSize,
44+ type InputVariant,
55+ inputVariants
66+} from './Input.svelte';
77+88+export { type InputProps, Root as Input, inputVariants, type InputSize, type InputVariant };
···11+export { default as Toaster } from './Toaster.svelte';
22+export { toast } from 'svelte-sonner';
+6
src/lib/website/foxui/utils.ts
···11+import { type ClassValue, clsx } from 'clsx';
22+import { twMerge } from 'tailwind-merge';
33+44+export function cn(...inputs: ClassValue[]) {
55+ return twMerge(clsx(inputs));
66+}
+16
src/lib/website/types.ts
···11+import type { data } from './data';
22+import type { Record as ListRecord } from '@atproto/api/dist/client/types/com/atproto/repo/listRecords';
33+44+export type Collection = keyof typeof data;
55+66+export type IndividualCollections = {
77+ [K in Collection]: (typeof data)[K] extends readonly unknown[] ? K : never;
88+}[Collection];
99+1010+export type ListCollections = Exclude<Collection, IndividualCollections>;
1111+1212+export type ElementType<C extends Collection> = (typeof data)[C] extends readonly (infer U)[]
1313+ ? U
1414+ : unknown;
1515+1616+export type DownloadedData = { [C in Collection]: Record<string, ListRecord> };
+41
src/lib/website/utils.ts
···11+import { type Collection, type DownloadedData } from './types';
22+import { getRecord, listRecords, resolveHandle } from '$lib/oauth/atproto';
33+import type { Record as ListRecord } from '@atproto/api/dist/client/types/com/atproto/repo/listRecords';
44+import { data } from './data';
55+66+export function parseUri(uri: string) {
77+ // at://did:plc:257wekqxg4hyapkq6k47igmp/link.flo-bit.dev/3lnblfznvhr2a
88+ const [did, collection, rkey] = uri.split('/').slice(2);
99+ return { did, collection, rkey } as {
1010+ collection: `${string}.${string}.${string}`;
1111+ rkey: string;
1212+ did: string;
1313+ };
1414+}
1515+1616+export async function loadData(handle: string) {
1717+ const did = await resolveHandle({ handle });
1818+1919+ const downloadedData = {} as DownloadedData;
2020+2121+ for (const collection of Object.keys(data) as Collection[]) {
2222+ const cfg = data[collection];
2323+2424+ try {
2525+ if (Array.isArray(cfg)) {
2626+ for (const rkey of cfg) {
2727+ const record = await getRecord({ did, collection, rkey });
2828+ downloadedData[collection] ??= {} as Record<string, ListRecord>;
2929+ downloadedData[collection][rkey] = record;
3030+ }
3131+ } else if (cfg === 'all') {
3232+ const records = await listRecords({ did, collection });
3333+ downloadedData[collection] = records;
3434+ }
3535+ } catch (error) {
3636+ console.error('failed getting', collection, cfg, error);
3737+ }
3838+ }
3939+4040+ return { did, data: JSON.parse(JSON.stringify(downloadedData)) as DownloadedData };
4141+}
+14
src/routes/+layout.svelte
···11+<script lang="ts">
22+ import '../app.css';
33+44+ import { onMount } from 'svelte';
55+ import { initClient } from '$lib/oauth';
66+77+ let { children } = $props();
88+99+ onMount(() => {
1010+ initClient();
1111+ });
1212+</script>
1313+1414+{@render children()}
+5
src/routes/[handle]/+layout.server.ts
···11+import { loadData } from '$lib/website/utils';
22+33+export async function load({ params }) {
44+ return await loadData(params.handle);
55+}
+11
src/routes/[handle]/+page.svelte
···11+<script lang="ts">
22+ import { page } from '$app/state';
33+ import Website from '$lib/Website.svelte';
44+ import WebsiteWrapper from '$lib/website/WebsiteWrapper.svelte';
55+66+ let { data } = $props();
77+</script>
88+99+<WebsiteWrapper {data} handle={page.params.handle}>
1010+ <Website handle={page.params.handle} did={data.did} />
1111+</WebsiteWrapper>
+11
src/routes/[handle]/edit/+page.svelte
···11+<script lang="ts">
22+ import { page } from '$app/state';
33+ import Website from '$lib/Website.svelte';
44+ import EditingWebsiteWrapper from '$lib/website/EditingWebsiteWrapper.svelte';
55+66+ let { data } = $props();
77+</script>
88+99+<EditingWebsiteWrapper {data}>
1010+ <Website handle={page.params.handle} did={data.did} />
1111+</EditingWebsiteWrapper>
+8
src/routes/client-metadata.json/+server.ts
···11+import { metadata } from '$lib/oauth';
22+import { json } from '@sveltejs/kit';
33+44+export const prerender = true;
55+66+export async function GET() {
77+ return json(metadata);
88+}
···11+{
22+ "extends": "./.svelte-kit/tsconfig.json",
33+ "compilerOptions": {
44+ "allowJs": true,
55+ "checkJs": true,
66+ "esModuleInterop": true,
77+ "forceConsistentCasingInFileNames": true,
88+ "resolveJsonModule": true,
99+ "skipLibCheck": true,
1010+ "sourceMap": true,
1111+ "strict": true,
1212+ "moduleResolution": "bundler"
1313+ }
1414+ // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1515+ // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1616+ //
1717+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
1818+ // from the referenced tsconfig.json - TypeScript does not merge them in
1919+}