Browse Source

Merges pull request #29

[WIP] New Publication Loader and Renderer

## Changes

- The new publication tree loader is used to dynamically load publication events.
- The Preview Svelte component is replaced by the PublicationSection component for read-only mode. Editing is not yet defined.
- Only part of a publication loads, initially. Scrolling within a publication causes additional sections of that publication to load.
    - This just-in-time loading currently works only in one direction. Users start at the top of a publication and scroll down.
    - Planned work will implement bookmarks to allow users to return to a saved point in the middle of a publication.
- The table of contents placeholder is removed.
- Tailwind utilities and components are used for styling within HTML generated from AsciiDoc sources.
- Asciidoctor extensions are scoped so as not to pollute the global scope.
master
Michael J 11 months ago
parent
commit
2cf4dccda2
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 62
      .cursor/rules/alexandria.mdc
  2. 3
      deno.lock
  3. 2
      package.json
  4. 141
      src/app.css
  5. 8
      src/app.d.ts
  6. 88
      src/lib/components/Publication.svelte
  7. 108
      src/lib/components/PublicationSection.svelte
  8. 39
      src/lib/snippets/PublicationSnippets.svelte
  9. 8
      src/routes/+layout.svelte
  10. 34
      src/routes/publication/+page.svelte
  11. 3
      src/routes/publication/+page.ts

62
.cursor/rules/alexandria.mdc

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
---
description:
globs:
alwaysApply: true
---
# Project Alexandria
You are senior full-stack software engineer with 20 years of experience writing web apps. You have been working with the Svelte web development framework for 8 years, since it was first released, and you currently are a leading expert on Svelte 5 and SvelteKit 2. Additionally, you are a pioneer developer on the Nostr protocol, and have developing production-quality Nostr apps for 4 years.
## Project Overview
Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays. It revolves around breaking long AsciiDoc documents into Nostr events, with each event containing a paragraph or so of text from the document. These individual content events are organized by index events into publications. An index contains an ordered list of references to other index events or content events, forming a tree.
### Reader Features
In reader mode, Alexandria loads a document tree from a root publication index event. The AsciiDoc text content of the various content events, along with headers specified by tags in the index events, is composed and rendered as a single document from the user's point of view.
### Tech Stack
Svelte components in Alexandria use TypeScript exclusively over plain JavaScript. Styles are defined via Tailwind 4 utility classes, and some custom utility classes are defined in [app.css](mdc:src/app.css). The app runs on Deno, but maintains compatibility with Node.js.
## General Guidelines
When responding to prompts, adhere to the following rules:
- Avoid making apologetic or conciliatory statements.
- Avoid verbose responses; be direct and to the point.
- Provide links to relevant documentation so that I can do further reading on the tools or techniques discussed and used in your responses.
- When I tell you a response is incorrect, avoid simply agreeing with me; think about the points raised and provide well-reasoned explanations for your subsequent responses.
- Avoid proposing code edits unless I specifically tell you to do so.
- When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily.
## Code Style
Observe the following style guidelines when writing code:
### General Guidance
- Use PascalCase names for Svelte 5 components and their files.
- Use snake_case names for plain TypeScript files.
- Use comments sparingly; code should be self-documenting.
### JavaScript/TypeScript
- Use an indentation size of 2 spaces.
- Use camelCase names for variables, classes, and functions.
- Give variables, classes, and functions descriptive names that reflect their content and purpose.
- Use Svelte 5 features, such as runes. Avoid using legacy Svelte 4 features.
- Write JSDoc comments for all functions.
- Use blocks enclosed by curly brackets when writing control flow expressions such as `for` and `while` loops, and `if` and `switch` statements.
- Begin `case` expressions in a `switch` statement at the same indentation level as the `switch` itself. Indent code within a `case` block.
- Limit line length to 100 characters; break statements across lines if necessary.
- Default to single quotes.
### HTML
- Use an indentation size of 2 spaces.
- Break long tags across multiple lines.
- Use Tailwind 4 utility classes for styling.
- Default to single quotes.

3
deno.lock

@ -2887,10 +2887,11 @@ @@ -2887,10 +2887,11 @@
"npm:@sveltejs/adapter-auto@3",
"npm:@sveltejs/adapter-node@^5.2.12",
"npm:@sveltejs/adapter-static@3",
"npm:@sveltejs/kit@2",
"npm:@sveltejs/kit@^2.16.0",
"npm:@sveltejs/vite-plugin-svelte@4",
"npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5",
"npm:@types/d3@^7.4.3",
"npm:@types/he@1.2",
"npm:@types/node@22",
"npm:asciidoctor@3.0",

2
package.json

@ -29,7 +29,7 @@ @@ -29,7 +29,7 @@
"@sveltejs/adapter-auto": "3.x",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "3.x",
"@sveltejs/kit": "2.x",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "4.x",
"@types/d3": "^7.4.3",
"@types/he": "1.2.x",

141
src/app.css

@ -186,11 +186,6 @@ @@ -186,11 +186,6 @@
@apply bg-gray-200 dark:bg-gray-700;
}
/* Unordered list */
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
/* Network visualization */
.network-link-leather {
@apply stroke-gray-400 fill-gray-400;
@ -203,6 +198,47 @@ @@ -203,6 +198,47 @@
}
}
/* Utilities can be applied via the @apply directive. */
@layer utilities {
.h-leather {
@apply text-gray-800 dark:text-gray-300 pt-4;
}
.h1-leather {
@apply text-4xl font-bold;
}
.h2-leather {
@apply text-3xl font-bold;
}
.h3-leather {
@apply text-2xl font-bold;
}
.h4-leather {
@apply text-xl font-bold;
}
.h5-leather {
@apply text-lg font-semibold;
}
.h6-leather {
@apply text-base font-semibold;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {
@apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500;
}
.link {
@apply underline cursor-pointer hover:text-primary-400 dark:hover:text-primary-500;
}
}
@layer components {
/* Legend */
.leather-legend {
@ -223,4 +259,99 @@ @@ -223,4 +259,99 @@
.leather-legend button {
@apply dark:text-white;
}
/* Rendered publication content */
.publication-leather {
@apply flex flex-col space-y-4;
h1, h2, h3, h4, h5, h6 {
@apply h-leather;
}
h1 {
@apply h1-leather;
}
h2 {
@apply h2-leather;
}
h3 {
@apply h3-leather;
}
h4 {
@apply h4-leather;
}
h5 {
@apply h5-leather;
}
h6 {
@apply h6-leather;
}
div {
@apply flex flex-col space-y-4;
}
.olist {
@apply flex flex-col space-y-4;
ol {
@apply ol-leather list-decimal px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
}
}
}
.ulist {
@apply flex flex-col space-y-4;
ul {
@apply ul-leather list-disc px-6 flex flex-col space-y-2;
li {
.paragraph {
@apply py-2;
}
}
}
}
a {
@apply link;
}
.imageblock {
@apply flex flex-col items-center;
.title {
@apply text-sm text-center;
}
}
.stemblock {
@apply bg-gray-100 dark:bg-gray-900 p-4 rounded-lg;
}
table {
@apply w-full overflow-x-auto;
caption {
@apply text-sm;
}
thead, tbody {
th, td {
@apply border border-gray-200 dark:border-gray-700;
}
}
}
}
}

8
src/app.d.ts vendored

@ -1,14 +1,18 @@ @@ -1,14 +1,18 @@
// See https://kit.svelte.dev/docs/types#app
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk";
import Pharos from "./lib/parser.ts";
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface PageData {
ndk?: NDK;
parser?: Pharos;
waitable?: Promise<any>;
publicationType?: string;
indexEvent?: NDKEvent;
url?: URL;
}
// interface Platform {}
}

88
src/lib/components/Publication.svelte

@ -9,24 +9,65 @@ @@ -9,24 +9,65 @@
TextPlaceholder,
Tooltip,
} from "flowbite-svelte";
import { onMount } from "svelte";
import { getContext, onMount } from "svelte";
import { BookOutline } from "flowbite-svelte-icons";
import Preview from "./Preview.svelte";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/state";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import PublicationSection from "./PublicationSection.svelte";
import type { PublicationTree } from "$lib/data_structures/publication_tree";
let { rootId, publicationType, indexEvent } = $props<{
rootId: string,
let { rootAddress, publicationType, indexEvent } = $props<{
rootAddress: string,
publicationType: string,
indexEvent: NDKEvent
}>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
const publicationTree = getContext('publicationTree') as PublicationTree;
// #region Loading
// TODO: Test load handling.
let leaves = $state<NDKEvent[]>([]);
let isLoading = $state<boolean>(false);
let lastElementRef = $state<HTMLElement | null>(null);
let observer: IntersectionObserver;
async function loadMore(count: number) {
isLoading = true;
for (let i = 0; i < count; i++) {
const nextItem = await publicationTree.next();
if (leaves.includes(nextItem.value) || nextItem.done) {
isLoading = false;
return;
}
leaves.push(nextItem.value);
}
isLoading = false;
}
function setLastElementRef(el: HTMLElement, i: number) {
if (i === leaves.length - 1) {
lastElementRef = el;
}
}
$effect(() => {
if (!lastElementRef) {
return;
}
observer.observe(lastElementRef!);
return () => observer.unobserve(lastElementRef!);
});
// #endregion
// #region ToC
const tocBreakpoint = 1140;
let activeHash = $state(page.url.hash);
@ -81,28 +122,40 @@ @@ -81,28 +122,40 @@
}
}
// #endregion
onMount(() => {
// Always check whether the TOC sidebar should be visible.
setTocVisibilityOnResize();
window.addEventListener("hashchange", scrollToElementWithOffset);
// Also handle the case where the user lands on the page with a hash in the URL
scrollToElementWithOffset();
window.addEventListener("resize", setTocVisibilityOnResize);
window.addEventListener("click", hideTocOnClick);
// Set up the intersection observer.
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading) {
loadMore(4);
}
});
}, { threshold: 0.5 });
loadMore(8);
return () => {
window.removeEventListener("hashchange", scrollToElementWithOffset);
window.removeEventListener("resize", setTocVisibilityOnResize);
window.removeEventListener("click", hideTocOnClick);
observer.disconnect();
};
});
</script>
{#if showTocButton && !showToc}
<Button
<!-- <Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
@ -112,9 +165,9 @@ @@ -112,9 +165,9 @@
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip>
<Tooltip>Show Table of Contents</Tooltip> -->
{/if}
<!-- TODO: Get TOC from parser. -->
<!-- TODO: Use loader to build ToC. -->
<!-- {#if showToc}
<Sidebar class='sidebar-leather fixed top-20 left-0 px-4 w-60' {activeHash}>
<SidebarWrapper>
@ -131,7 +184,14 @@ @@ -131,7 +184,14 @@
</Sidebar>
{/if} -->
<div class="flex flex-col space-y-4 max-w-2xl">
<Preview {rootId} {publicationType} />
{#each leaves as leaf, i}
<PublicationSection
rootAddress={rootAddress}
leaves={leaves}
address={leaf.tagAddress()}
ref={(el) => setLastElementRef(el, i)}
/>
{/each}
</div>
<style>

108
src/lib/components/PublicationSection.svelte

@ -0,0 +1,108 @@ @@ -0,0 +1,108 @@
<script lang='ts'>
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { TextPlaceholder } from "flowbite-svelte";
import { getContext } from "svelte";
import type { Asciidoctor, Document } from "asciidoctor";
let {
address,
rootAddress,
leaves,
ref,
}: {
address: string,
rootAddress: string,
leaves: NDKEvent[],
ref: (ref: HTMLElement) => void,
} = $props();
const publicationTree: PublicationTree = getContext('publicationTree');
const asciidoctor: Asciidoctor = getContext('asciidoctor');
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(address));
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () =>
await publicationTree.getEvent(rootAddress));
let publicationType: Promise<string | undefined> = $derived.by(async () =>
(await rootEvent)?.getMatchingTags('type')[0]?.[1]);
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () =>
await publicationTree.getHierarchy(address));
let leafTitle: Promise<string | undefined> = $derived.by(async () =>
(await leafEvent)?.getMatchingTags('title')[0]?.[1]);
let leafContent: Promise<string | Document> = $derived.by(async () =>
asciidoctor.convert((await leafEvent)?.content ?? ''));
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
const index = leaves.findIndex(leaf => leaf.tagAddress() === address);
if (index === 0) {
return null;
}
return leaves[index - 1];
});
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => {
if (!previousLeafEvent) {
return null;
}
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress());
});
let divergingBranches = $derived.by(async () => {
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]);
const branches: [NDKEvent, number][] = [];
if (!previousLeafHierarchyValue) {
for (let i = 0; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
}
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length);
// Find the first diverging node.
let divergingIndex = 0;
while (
divergingIndex < minLength &&
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress()
) {
divergingIndex++;
}
// Add all branches from the first diverging node to the current leaf.
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) {
branches.push([leafHierarchyValue[i], i]);
}
return branches;
});
let sectionRef: HTMLElement;
$effect(() => {
if (!sectionRef) {
return;
}
ref(sectionRef);
});
</script>
<!-- TODO: Correctly handle events that are the start of a content section. -->
<section bind:this={sectionRef} class='publication-leather'>
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])}
<TextPlaceholder size='xxl' />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- TODO: Ensure we render all headings, not just the first one. -->
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(branch.getMatchingTags('title')[0]?.[1] ?? '', depth)}
{/each}
{#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)}
{/if}
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)}
{/await}
</section>

39
src/lib/snippets/PublicationSnippets.svelte

@ -5,41 +5,16 @@ @@ -5,41 +5,16 @@
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class='h-leather'>
{title}
</h6>
{/if}
</svelte:element>
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
<section class='whitespace-normal publication-leather'>
{@html content}
</P>
{/if}
</section>
{/snippet}

8
src/routes/+layout.svelte

@ -3,6 +3,8 @@ @@ -3,6 +3,8 @@
import Navigation from "$lib/components/Navigation.svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
// Compute viewport height.
$: displayHeight = window.innerHeight;
@ -42,5 +44,11 @@ @@ -42,5 +44,11 @@
<div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' />
<Alert rounded={false} class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left'>
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' />
<span class='font-medium'>
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable.
</span>
</Alert>
<slot />
</div>

34
src/routes/publication/+page.svelte

@ -1,25 +1,21 @@ @@ -1,25 +1,21 @@
<script lang="ts">
import Article from "$lib/components/Publication.svelte";
import Publication from "$lib/components/Publication.svelte";
import { TextPlaceholder } from "flowbite-svelte";
import type { PageData } from "./$types";
import { onDestroy } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/stores";
// Extend the PageData type with the properties we need
interface ExtendedPageData extends PageData {
waitable: Promise<any>;
publicationType: string;
indexEvent: NDKEvent;
parser: any;
}
let { data } = $props<{ data: ExtendedPageData }>();
import type { PageProps } from "./$types";
import { onDestroy, setContext } from "svelte";
import { PublicationTree } from "$lib/data_structures/publication_tree";
import Processor from "asciidoctor";
let { data }: PageProps = $props();
const publicationTree = new PublicationTree(data.indexEvent, data.ndk);
setContext('publicationTree', publicationTree);
setContext('asciidoctor', Processor());
// Get publication metadata for OpenGraph tags
let title = $derived(data.indexEvent?.getMatchingTags('title')[0]?.[1] || data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || 'Alexandria Publication');
let currentUrl = $page.url.href;
let currentUrl = data.url?.href ?? '';
// Get image and summary from the event tags if available
// If image unavailable, use the Alexandria default pic.
@ -53,8 +49,8 @@ @@ -53,8 +49,8 @@
{#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
{:then}
<Article
rootId={data.parser.getRootIndexId()}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>

3
src/routes/publication/+page.ts

@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit'; @@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk.ts';
import { getActiveRelays } from '$lib/ndk';
/**
* Decodes an naddr identifier and returns a filter object
@ -103,5 +103,6 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom @@ -103,5 +103,6 @@ export const load: Load = async ({ url, parent }: { url: URL; parent: () => Prom
waitable: fetchPromise,
publicationType,
indexEvent,
url,
};
};

Loading…
Cancel
Save