diff --git a/apps/gnocchi/.vscode/tasks.json b/apps/gnocchi/.vscode/tasks.json new file mode 100644 index 00000000..54870a02 --- /dev/null +++ b/apps/gnocchi/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "app-dev", + "path": "web", + "problemMatcher": [], + "label": "npm: app-dev - web", + "detail": "vite --host --mode development" + } + ] +} diff --git a/apps/marginalia/.vscode/tasks.json b/apps/marginalia/.vscode/tasks.json new file mode 100644 index 00000000..edb179da --- /dev/null +++ b/apps/marginalia/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "app-dev", + "path": "web", + "problemMatcher": [], + "label": "npm: app-dev - web", + "detail": "vite" + } + ] +} diff --git a/apps/marginalia/web/src/components/text/Book.tsx b/apps/marginalia/web/src/components/text/Book.tsx index 2fc8edb8..f548bf77 100644 --- a/apps/marginalia/web/src/components/text/Book.tsx +++ b/apps/marginalia/web/src/components/text/Book.tsx @@ -1,5 +1,5 @@ -import { UsfmRenderer } from '@/components/usfm/UsfmRenderer.jsx'; import { useSuspenseQuery } from '@tanstack/react-query'; +import { UsfmNode } from '../usfm/nodes.jsx'; export interface BookProps { id: string; @@ -12,7 +12,11 @@ export function Book({ id }: BookProps) { return
Loading...
; } - return ; + return ( +
+ +
+ ); } function useBook(id: string) { diff --git a/apps/marginalia/web/src/components/usfm/Reference.tsx b/apps/marginalia/web/src/components/usfm/Reference.tsx new file mode 100644 index 00000000..e32c5b6e --- /dev/null +++ b/apps/marginalia/web/src/components/usfm/Reference.tsx @@ -0,0 +1,29 @@ +import { Button } from '@a-type/ui/components/button'; +import { + Popover, + PopoverArrow, + PopoverContent, + PopoverTrigger, +} from '@a-type/ui/components/popover'; +import { ReactNode } from 'react'; + +export interface ReferenceProps { + caller: string; + children: ReactNode; +} + +export function Reference({ caller, children }: ReferenceProps) { + return ( + + + + + + + {children} + + + ); +} diff --git a/apps/marginalia/web/src/components/usfm/UsfmParagraph.tsx b/apps/marginalia/web/src/components/usfm/UsfmParagraph.tsx deleted file mode 100644 index bd117867..00000000 --- a/apps/marginalia/web/src/components/usfm/UsfmParagraph.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ReactNode } from 'react'; -import classNames from 'classnames'; -import { UsfmParagraphData } from '@/components/usfm/types.js'; -import { isVerse } from '@/components/usfm/recognizers.js'; -import { UsfmVerse } from '@/components/usfm/UsfmVerse.jsx'; - -export interface UsfmParagraphProps { - content: UsfmParagraphData; -} - -export function UsfmParagraph({ content }: UsfmParagraphProps) { - const { continuation, indentation, opening, alignment, closure, embedded } = - content.formatting; - const lines = [...content.lines]; - const verses: ReactNode[] = []; - - while (lines.length > 0) { - const line = lines.shift()!; - if (isVerse(line)) { - verses.push(); - } else { - verses.push( - - {line} - , - ); - } - } - - return ( -

- {content.chapterStart && ( - - {content.chapter} - - )} - {verses} -

- ); -} diff --git a/apps/marginalia/web/src/components/usfm/UsfmRenderer.tsx b/apps/marginalia/web/src/components/usfm/UsfmRenderer.tsx deleted file mode 100644 index 234ec5a6..00000000 --- a/apps/marginalia/web/src/components/usfm/UsfmRenderer.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { UsfmParagraph } from '@/components/usfm/UsfmParagraph.jsx'; -import { - getParagraphFormatting, - isChapter, - isParagraph, -} from '@/components/usfm/recognizers.js'; -import { UsfmParagraphData } from '@/components/usfm/types.js'; - -export interface UsfmRendererProps { - root: string; -} - -export function UsfmRenderer({ root }: UsfmRendererProps) { - const bookName = root.match(/^\\h (.*)$/m)?.[1]; - const remaining = root.slice(root.indexOf('\\c 1')); - // is lines is not the right way to split the content? - // should split on verses instead? - const lines = remaining.split('\n'); - const paragraphs = useParagraphs(lines); - - return ( -
-

{bookName}

- {paragraphs.map((paragraph, index) => ( - - ))} -
- ); -} - -function useParagraphs(lines: string[]) { - let chapter = 1; - let chapterChanged = false; - const paragraphs: UsfmParagraphData[] = []; - let currentParagraph: UsfmParagraphData | null = null; - while (lines.length > 0) { - const line = lines.shift()!; - if (isParagraph(line)) { - if (currentParagraph && currentParagraph.lines.length > 0) { - paragraphs.push(currentParagraph); - } - currentParagraph = { - lines: [], - chapter, - chapterStart: chapterChanged, - formatting: getParagraphFormatting(line), - }; - chapterChanged = false; - } else if (isChapter(line)) { - const chapterMatch = line.match(/^\\c (\d+)/)?.[1]; - if (chapterMatch) { - chapter = parseInt(chapterMatch); - chapterChanged = true; - } - } else { - if (!currentParagraph) { - throw new Error('No current paragraph!'); - } - currentParagraph.lines.push(line); - } - } - if (currentParagraph?.lines?.length) { - paragraphs.push(currentParagraph); - } - return paragraphs; -} diff --git a/apps/marginalia/web/src/components/usfm/UsfmVerse.tsx b/apps/marginalia/web/src/components/usfm/UsfmVerse.tsx deleted file mode 100644 index acd9a88c..00000000 --- a/apps/marginalia/web/src/components/usfm/UsfmVerse.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { ReactNode } from 'react'; -import { Tooltip } from '@a-type/ui/components/tooltip'; -import { clsx } from '@a-type/ui'; - -export interface UsfmVerseProps { - line: string; -} - -export function UsfmVerse({ line }: UsfmVerseProps) { - const withoutMarker = line.slice(3); - // FIXME: - let [verseNumber, ...rest] = withoutMarker.split(' '); - let remaining = rest.join(' '); - - const words = useParsedWords(remaining); - - return ( - - {verseNumber}  - {words} - - ); -} - -function useParsedWords(source: string) { - let remaining = source; - const words: ReactNode[] = []; - while (remaining.length > 0) { - // FIXME: check beginning for marker to make \f work too.... - const nextMarker = remaining.match(/\\(\+?\w+)/); - if (nextMarker) { - const nextMarkerPosition = nextMarker.index || 0; - if (nextMarkerPosition > 0) { - words.push( - - {remaining.slice(0, nextMarkerPosition)} - , - ); - remaining = remaining.slice(nextMarkerPosition); - } else { - let nextMarkerName = nextMarker[1]; - let nextMarkerNested = false; - let searchForEndMarker = nextMarkerName; - // '+' indicates a nested marker but doesn't otherwise - // affect behavior. - if (nextMarkerName.startsWith('+')) { - nextMarkerName = nextMarkerName.slice(1); - // since the search is regex we have to escape the + - searchForEndMarker = '\\+' + nextMarkerName; - nextMarkerNested = true; - } - const nextMarkerContent = remaining.match( - new RegExp( - `\\\\${searchForEndMarker}(.*?)\\\\${searchForEndMarker}\\\*`, - ), - ); - if (!nextMarkerContent) { - throw new Error( - 'No end marker for ' + - searchForEndMarker + - ' while searching ' + - remaining, - ); - } - const [whole, content] = nextMarkerContent; - if (nextMarkerName.startsWith('f')) { - // just "f" is a footnote - // "f#" is a footnote with a number - // "fe" is an endnote - words.push(); - } else if (nextMarkerName === 'w') { - words.push( - , - ); - } else if (nextMarkerName === 'wj') { - // words of jesus - words.push( - , - ); - } else if (nextMarkerName.startsWith('x')) { - // cross-reference - words.push( - , - ); - } else { - console.warn(`Unknown marker: ${nextMarkerName}`); - words.push( - , - ); - } - - remaining = remaining.slice(remaining.indexOf(whole) + whole.length); - } - } else { - words.push({remaining}); - remaining = ''; - } - } - - return words; -} - -function UsfmWordGroup({ - content, - type, - className, -}: { - content: string; - type: string; - className?: string; -}) { - const words = useParsedWords(content); - return ( - - {words} - - ); -} - -function UsfmWord({ - word, - type, - className, - raw, -}: { - word: string; - type: string; - className?: string; - raw: string; -}) { - const [display] = word.split('|'); - - return ( - - {display} - - ); -} - -function UsfmFootnote({ content }: { content: string }) { - const textMatch = content.match(/\\ft(.*?)(\\?$)/); - const text = textMatch?.[1]?.trim() || ''; - if (!text) return null; - return ( - - * - - ); -} - -function UsfmCrossReference({ content }: { content: string }) { - const textMatch = content.match(/\\x(.*?)(\\?$)/); - const text = textMatch?.[1]?.trim() || ''; - if (!text) return null; - return ( - - * - - ); -} diff --git a/apps/marginalia/web/src/components/usfm/constants.ts b/apps/marginalia/web/src/components/usfm/constants.ts deleted file mode 100644 index 36012ce7..00000000 --- a/apps/marginalia/web/src/components/usfm/constants.ts +++ /dev/null @@ -1,17 +0,0 @@ -export const PARAGRAPH_MARKERS = [ - '\\p', - '\\m', - '\\po', - '\\pr', - '\\cls', - '\\pmo', - '\\pm', - '\\pmc', - '\\pmr', - '\\pi', - '\\mi', - '\\nb', - '\\pc', - '\\ph', - '\\b', -]; diff --git a/apps/marginalia/web/src/components/usfm/nodes.tsx b/apps/marginalia/web/src/components/usfm/nodes.tsx new file mode 100644 index 00000000..f921c1c9 --- /dev/null +++ b/apps/marginalia/web/src/components/usfm/nodes.tsx @@ -0,0 +1,331 @@ +import { ReactNode, JSX } from 'react'; +import { Reference } from './Reference.jsx'; + +export interface Node { + markers: string[]; + consume?: ( + line: string, + marker: string, + ) => { consumed: string; rest: string }; + render?: (consumed: string, marker: string) => JSX.Element | null; + multiline?: boolean; +} + +const MARKER = /\\(\S+)/; +// by default markers consume to either +// the end of the line, or an inverse marker +const DEFAULT_CONSUME = (line: string, marker: string) => { + // escape special characters in marker + marker = marker.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); + const inverse = new RegExp(`\\\\${marker}\\*`); + const inverseMatch = line.match(inverse); + if (inverseMatch?.index !== undefined) { + // remove both leading and inverse markers + const source = line.slice(marker.length + 1, inverseMatch.index); + return { + consumed: source, + rest: line.slice(inverseMatch.index + inverseMatch[0].length), + }; + } + + // otherwise, remove leading marker and return + // rest of line + return { consumed: line.slice(marker.length + 1), rest: '' }; +}; +const PARAGRAPH_CONSUME = (line: string, marker: string) => { + // paragraphs consume up to the next multiline marker + const nextMarker = new RegExp(`\\\\(${MULTILINE_MARKERS.join('|')})`); + const withoutMarker = line.slice(marker.length + 1); + const nextMatch = withoutMarker.match(nextMarker); + if (nextMatch?.index !== undefined) { + if (nextMatch.index === 0) { + debugger; + } + const source = withoutMarker.slice(0, nextMatch.index); + return { + consumed: source, + rest: withoutMarker.slice(nextMatch.index), + }; + } + + // otherwise, consume the whole line + return { consumed: withoutMarker, rest: '' }; +}; + +// invisible by default +const DEFAULT_RENDER = () => null; + +export function UsfmNode({ text }: { text: string }) { + let remaining = text; + let content: ReactNode[] = []; + let match: Node | undefined = undefined; + let prevRemaining = remaining; + do { + match = undefined; // reset + + const marker = MARKER.exec(remaining); + if (marker) { + // add any text before the marker + if (marker.index > 0) { + const leading = remaining.slice(0, marker.index); + remaining = remaining.slice(marker.index); + content.push({leading}); + } + // find node for marker + match = nodeMap.get(marker[1]) ?? DEFAULT_NODE; + + const consume = + match.consume ?? + (match.multiline ? PARAGRAPH_CONSUME : DEFAULT_CONSUME); + const render = match.render ?? DEFAULT_RENDER; + let matchAgainst; + let leftovers; + if (match.multiline) { + matchAgainst = remaining; + leftovers = ''; + } else { + const [line, ...otherLines] = remaining.split('\n'); + matchAgainst = line; + leftovers = otherLines.join('\n'); + } + const { consumed, rest } = consume(matchAgainst, marker[1]); + content.push(render(consumed, marker[1])); + remaining = rest + leftovers; + + if (prevRemaining === remaining) { + throw new Error( + 'infinite loop detected. current marker: ' + + marker[1] + + ' remaining: ' + + remaining, + ); + } + prevRemaining = remaining; + } else { + // add any text after markers. + content.push({remaining}); + } + } while (remaining.length > 0 && match !== undefined); + + return <>{content}; +} + +const DEFAULT_NODE: Node = { + markers: [], + consume: DEFAULT_CONSUME, + render: (consumed, marker) => ( + + [\{marker}] {consumed} + + ), +}; + +const chapter: Node = { + markers: ['c'], + render: (consumed) => { + const number = parseInt(consumed, 10); + return ( + + {number} + + ); + }, +}; + +const paragraph: Node = { + markers: ['p'], + render: (consumed) => { + return ( +
+ +
+ ); + }, + multiline: true, +}; + +const verse: Node = { + markers: ['v'], + render: (consumed) => { + const trimmed = consumed.trimStart(); + // first thing should be a number + const firstSpaceIndex = trimmed.indexOf(' '); + const number = parseInt(trimmed.slice(0, firstSpaceIndex), 10); + const rest = trimmed.slice(firstSpaceIndex + 1); + return ( + + {number} + + + ); + }, +}; + +const word: Node = { + markers: ['w', '+w'], + render: (consumed) => { + // remove strong's reference + const [word, strongs, ...extras] = consumed.split('|'); + if (extras.length > 0) { + console.error('unexpected extra data in word: ' + extras.join('|')); + } + return ( + + {word.trim()} + + ); + }, +}; + +const quote: Node = { + markers: ['q', 'q1', 'q2', 'q3', 'q4', 'q5'], + multiline: true, + render: (consumed, marker) => { + const level = parseInt(marker.slice(1), 10); + return ( +
+ +
+ ); + }, +}; + +const crossReference: Node = { + markers: ['x'], + render: (consumed) => { + // parse caller + const [caller, ...rest] = consumed.trim().split(' '); + + return ( + + + + ); + }, +}; + +const crossReferenceText: Node = { + markers: ['xt'], + render: (consumed) => { + return {consumed}; + }, +}; + +const majorTitle: Node = { + markers: ['mt1', 'mt2', 'mt3'], + render: (consumed, marker) => { + if (marker === 'mt1') { + return

{consumed}

; + } else if (marker === 'mt2') { + return

{consumed}

; + } + return {consumed}; + }, +}; + +const footnote: Node = { + markers: ['f'], + render: (consumed) => { + // parse caller + const [caller, ...rest] = consumed.trim().split(' '); + + return ( + + + + ); + }, +}; + +const footnoteText: Node = { + markers: ['ft'], + render: (consumed) => { + return {consumed}; + }, +}; + +const wordsOfJesus: Node = { + markers: ['wj'], + render: (consumed) => { + return ( + + + + ); + }, +}; + +// omitted +const toc: Node = { + markers: ['toc1', 'toc2', 'toc3'], +}; +const id: Node = { + markers: ['id', 'ide'], +}; +const heading: Node = { + markers: ['h'], +}; +const footnoteReference: Node = { + markers: ['fr'], + consume: (line) => { + // only consumes the marker and reference + const match = line.match(/\\fr \d+\D\d+/); + if (!match) { + // invalid reference? just consume the + // marker and return the rest + const rest = line.split('\\fr')[1]; + return { consumed: '', rest }; + } + return { consumed: match[0], rest: line.slice(match[0].length) }; + }, +}; +const crossReferenceReference: Node = { + markers: ['xo'], + consume: (line) => { + // only consumes the marker and reference + const match = line.match(/\\xo \d+\D\d+/); + if (!match) { + // invalid reference? just consume the + // marker and return the rest + const rest = line.split('\\xo')[1]; + return { consumed: '', rest }; + } + return { consumed: match[0], rest: line.slice(match[0].length) }; + }, +}; + +const nodes = [ + id, + chapter, + paragraph, + verse, + heading, + word, + quote, + crossReference, + toc, + majorTitle, + footnote, + footnoteReference, + footnoteText, + crossReferenceReference, + crossReferenceText, + wordsOfJesus, +]; + +const nodeMap = new Map(); +nodes.forEach((node) => { + node.markers.forEach((marker) => { + nodeMap.set(marker, node); + }); +}); + +const MULTILINE_MARKERS = nodes + .filter((node) => node.multiline) + .flatMap((node) => node.markers); diff --git a/apps/marginalia/web/src/components/usfm/recognizers.ts b/apps/marginalia/web/src/components/usfm/recognizers.ts deleted file mode 100644 index c9980b25..00000000 --- a/apps/marginalia/web/src/components/usfm/recognizers.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { PARAGRAPH_MARKERS } from '@/components/usfm/constants.js'; -import { UsfmParagraphFormatting } from '@/components/usfm/types.js'; - -export function isParagraph(line: string) { - return PARAGRAPH_MARKERS.some((marker) => line.startsWith(marker)); -} - -export function isChapter(line: string) { - return line.startsWith('\\c'); -} - -export function isVerse(line: string) { - return line.startsWith('\\v'); -} - -export function getParagraphFormatting(line: string): UsfmParagraphFormatting { - let indentation = 0; - if (line.startsWith('\\pi') || line.startsWith('\\ph')) { - // TODO: parse the number of spaces. - indentation = 1; - } - - return { - continuation: line.startsWith('\\m'), - indentation, - opening: line.startsWith('\\po') || line.startsWith('\\pmo'), - alignment: line.startsWith('\\pr') - ? 'right' - : line.startsWith('\\pc') - ? 'center' - : 'left', - closure: line.startsWith('\\cls') || line.startsWith('\\pmc'), - embedded: line.startsWith('\\pm'), - }; -} diff --git a/apps/marginalia/web/src/components/usfm/types.ts b/apps/marginalia/web/src/components/usfm/types.ts deleted file mode 100644 index 0c249a43..00000000 --- a/apps/marginalia/web/src/components/usfm/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface UsfmParagraphData { - chapter: number; - chapterStart: boolean; - lines: string[]; - formatting: UsfmParagraphFormatting; -} - -export interface UsfmParagraphFormatting { - continuation?: boolean; - indentation?: number; - opening?: boolean; - alignment?: 'left' | 'center' | 'right'; - closure?: boolean; - embedded?: boolean; -} diff --git a/apps/shopping/.vscode/tasks.json b/apps/shopping/.vscode/tasks.json new file mode 100644 index 00000000..edb179da --- /dev/null +++ b/apps/shopping/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "app-dev", + "path": "web", + "problemMatcher": [], + "label": "npm: app-dev - web", + "detail": "vite" + } + ] +} diff --git a/apps/trip-tick/.vscode/tasks.json b/apps/trip-tick/.vscode/tasks.json new file mode 100644 index 00000000..edb179da --- /dev/null +++ b/apps/trip-tick/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "app-dev", + "path": "web", + "problemMatcher": [], + "label": "npm: app-dev - web", + "detail": "vite" + } + ] +} diff --git a/biscuits.code-workspace b/biscuits.code-workspace new file mode 100644 index 00000000..e8bcc43b --- /dev/null +++ b/biscuits.code-workspace @@ -0,0 +1,44 @@ +{ + "folders": [ + { + "path": "server", + "name": "💽 Server", + }, + { + "path": "web", + "name": "🌐 Web", + }, + { + "path": "packages", + "name": "📦 Packages", + }, + { + "path": "apps/gnocchi", + "name": "🍝 Gnocchi", + }, + { + "path": "apps/trip-tick", + "name": "🚗 Trip Tick", + }, + { + "path": "apps/shopping", + "name": "🛒 Shopping", + }, + { + "path": "apps/marginalia", + "name": "📚 Marginalia", + }, + { + "path": "scripts", + "name": "📃 Scripts", + }, + { + "path": "cdk", + "name": "🛠️ CDK", + }, + { + "path": "blog", + "name": "📝 Blog", + }, + ], +} diff --git a/server/.vscode/tasks.json b/server/.vscode/tasks.json new file mode 100644 index 00000000..4162a530 --- /dev/null +++ b/server/.vscode/tasks.json @@ -0,0 +1,34 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "problemMatcher": [], + "label": "npm: dev", + "detail": "run-p dev:*" + }, + { + "type": "npm", + "script": "write-schema", + "problemMatcher": [], + "label": "npm: write-schema", + "detail": "env-cmd tsx watch --conditions=development ./scripts/writeSchema.ts", + "runOptions": { + "runOn": "folderOpen", + "instanceLimit": 1, + "reevaluateOnRerun": true + }, + "hide": true, + "isBackground": true, + "presentation": { + "echo": true, + "reveal": "never", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + } + ] +} diff --git a/web/.vscode/tasks.json b/web/.vscode/tasks.json new file mode 100644 index 00000000..3752d459 --- /dev/null +++ b/web/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "problemMatcher": [], + "label": "npm: dev", + "detail": "vite --host --mode=development" + } + ] +}