Skip to content

Commit 6f22028

Browse files
committed
Add homepage preview toggle and plugin callout
1 parent 027d9fd commit 6f22028

3 files changed

Lines changed: 388 additions & 46 deletions

File tree

app/page.tsx

Lines changed: 349 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
'use client';
22

3+
import { useMemo, useState } from 'react';
34
import Link from 'next/link';
5+
import {
6+
parse,
7+
type ContentBlock,
8+
type Image as OSFImage,
9+
type Link as OSFLink,
10+
type MetaBlock,
11+
type OSFDocument,
12+
type TextRun
13+
} from 'omniscript-parser';
414

515
import Terminal from '@/components/Terminal'
616
import CodeBlock from '@/components/CodeBlock'
7-
import { FileText, Robot, ArrowsClockwise, ChartBar, PaintBrush, Lightning, FilePdf, PresentationChart, FileXls } from 'phosphor-react';
17+
import { FileText, Robot, ArrowsClockwise, ChartBar, PaintBrush, Lightning, FilePdf, PresentationChart, FileXls, PuzzlePiece } from 'phosphor-react';
818

919
export default function Home() {
1020
const exampleOSF = `@meta {
@@ -50,6 +60,17 @@ export default function Home() {
5060
5161
@include { path: "./sections/financial-details.osf"; }`
5262

63+
const [exampleView, setExampleView] = useState<'editor' | 'preview'>('editor');
64+
const examplePreview = useMemo(() => {
65+
try {
66+
const document = parse(exampleOSF);
67+
return generatePreviewHTML(document);
68+
} catch (error) {
69+
const message = error instanceof Error ? error.message : 'Preview error';
70+
return `<div class="text-red-600 font-mono">Preview error: ${escapeHTML(message)}</div>`;
71+
}
72+
}, [exampleOSF]);
73+
5374
return (
5475
<div className="min-h-screen">
5576
{/* Navigation */}
@@ -188,6 +209,34 @@ export default function Home() {
188209
</div>
189210
</section>
190211

212+
{/* Claude Code Plugin */}
213+
<section className="py-20 bg-noir-white">
214+
<div className="container-noir">
215+
<div className="border-2 border-noir-black p-8 bg-yellow-50">
216+
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
217+
<div>
218+
<div className="flex items-center gap-3 mb-3">
219+
<PuzzlePiece size={32} weight="duotone" />
220+
<h3 className="font-mono text-2xl font-bold">Claude Code Plugin</h3>
221+
</div>
222+
<p className="font-mono text-body-sm text-gray-700 max-w-2xl">
223+
Install the OmniScript Claude Code plugin to generate, lint, and export OSF directly
224+
inside Claude. Perfect for rapid document workflows and agent automation.
225+
</p>
226+
</div>
227+
<a
228+
href="https://github.com/OmniScriptOSF/omniscript-claude-plugin"
229+
className="btn-secondary"
230+
target="_blank"
231+
rel="noopener noreferrer"
232+
>
233+
View Plugin →
234+
</a>
235+
</div>
236+
</div>
237+
</div>
238+
</section>
239+
191240
{/* Example Section */}
192241
<section className="py-24 bg-noir-white">
193242
<div className="container-noir">
@@ -199,11 +248,48 @@ export default function Home() {
199248
</p>
200249

201250
<div className="max-w-4xl mx-auto">
202-
<CodeBlock
203-
code={exampleOSF}
204-
title="business-report.osf"
205-
showLineNumbers={true}
206-
/>
251+
<div className="flex items-center justify-between mb-4">
252+
<div className="font-mono text-sm text-gray-500">Example OSF</div>
253+
<div className="inline-flex border-2 border-black bg-white">
254+
<button
255+
type="button"
256+
onClick={() => setExampleView('editor')}
257+
className={`px-4 py-2 font-mono text-xs uppercase tracking-wide ${
258+
exampleView === 'editor'
259+
? 'bg-black text-white'
260+
: 'bg-white text-black hover:bg-gray-100'
261+
}`}
262+
>
263+
Editor
264+
</button>
265+
<button
266+
type="button"
267+
onClick={() => setExampleView('preview')}
268+
className={`px-4 py-2 font-mono text-xs uppercase tracking-wide ${
269+
exampleView === 'preview'
270+
? 'bg-black text-white'
271+
: 'bg-white text-black hover:bg-gray-100'
272+
}`}
273+
>
274+
Preview
275+
</button>
276+
</div>
277+
</div>
278+
279+
{exampleView === 'editor' ? (
280+
<CodeBlock
281+
code={exampleOSF}
282+
title="business-report.osf"
283+
showLineNumbers={true}
284+
/>
285+
) : (
286+
<div className="border-2 border-black bg-white p-6">
287+
<div
288+
className="prose max-w-none"
289+
dangerouslySetInnerHTML={{ __html: examplePreview }}
290+
/>
291+
</div>
292+
)}
207293

208294
<div className="mt-12 grid md:grid-cols-3 gap-4">
209295
<div className="card text-center">
@@ -329,3 +415,260 @@ export default function Home() {
329415
</div>
330416
)
331417
}
418+
419+
function generatePreviewHTML(doc: OSFDocument): string {
420+
let html = '';
421+
const metaBlock = doc.blocks.find((block): block is MetaBlock => block.type === 'meta');
422+
423+
if (metaBlock) {
424+
html += '<div class="mb-6 pb-4 border-b-2 border-gray-200">';
425+
html += `<h1 class="text-3xl font-bold mb-2">${escapeHTML(String(metaBlock.props.title || 'Untitled'))}</h1>`;
426+
if (metaBlock.props.author) {
427+
html += `<p class="text-gray-500">By ${escapeHTML(String(metaBlock.props.author))}</p>`;
428+
}
429+
if (metaBlock.props.date) {
430+
html += `<p class="text-gray-500">${escapeHTML(String(metaBlock.props.date))}</p>`;
431+
}
432+
html += '</div>';
433+
}
434+
435+
for (const block of doc.blocks) {
436+
switch (block.type) {
437+
case 'doc':
438+
html += '<div class="mb-6">';
439+
html += convertMarkdownToHTML(block.content);
440+
html += '</div>';
441+
break;
442+
case 'slide':
443+
html += '<div class="mb-6 p-4 border-2 border-gray-200 bg-gray-50">';
444+
html += `<h2 class="text-xl font-bold mb-3">${escapeHTML(block.title || 'Slide')}</h2>`;
445+
if (block.content) {
446+
html += renderSlideContentHTML(block.content);
447+
} else if (block.bullets && block.bullets.length > 0) {
448+
html += '<ul class="list-disc pl-6 my-2">';
449+
html += block.bullets.map((item) => `<li>${escapeHTML(item)}</li>`).join('');
450+
html += '</ul>';
451+
}
452+
html += '</div>';
453+
break;
454+
case 'table':
455+
html += '<div class="mb-6 overflow-x-auto">';
456+
if (block.caption) {
457+
html += `<p class="text-sm text-gray-500 italic mb-2">${escapeHTML(block.caption)}</p>`;
458+
}
459+
html += '<table class="min-w-full border border-gray-200 text-sm">';
460+
html += '<thead class="bg-gray-100"><tr>';
461+
block.headers.forEach((header) => {
462+
html += `<th class="px-3 py-2 text-left font-semibold border border-gray-200">${escapeHTML(
463+
header
464+
)}</th>`;
465+
});
466+
html += '</tr></thead><tbody>';
467+
block.rows.forEach((row, rowIndex) => {
468+
const rowClass =
469+
block.style === 'striped' && rowIndex % 2 === 1 ? ' bg-gray-50' : '';
470+
html += `<tr class="${rowClass}">`;
471+
row.cells.forEach((cell) => {
472+
html += `<td class="px-3 py-2 border border-gray-200">${escapeHTML(
473+
cell.text
474+
)}</td>`;
475+
});
476+
html += '</tr>';
477+
});
478+
html += '</tbody></table></div>';
479+
break;
480+
default:
481+
break;
482+
}
483+
}
484+
485+
return html;
486+
}
487+
488+
function convertMarkdownToHTML(text: string): string {
489+
const lines = text.split(/\r?\n/);
490+
let html = '';
491+
let paragraph: string[] = [];
492+
let listItems: string[] = [];
493+
let blockquoteLines: string[] = [];
494+
495+
const flushParagraph = () => {
496+
if (paragraph.length > 0) {
497+
html += `<p class="my-2">${renderInlineMarkdown(paragraph.join(' '))}</p>`;
498+
paragraph = [];
499+
}
500+
};
501+
502+
const flushList = () => {
503+
if (listItems.length > 0) {
504+
html += `<ul class="list-disc pl-6 my-2">${listItems
505+
.map((item) => `<li>${renderInlineMarkdown(item)}</li>`)
506+
.join('')}</ul>`;
507+
listItems = [];
508+
}
509+
};
510+
511+
const flushBlockquote = () => {
512+
if (blockquoteLines.length > 0) {
513+
html += `<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-3">${blockquoteLines
514+
.map((line) => `<p>${renderInlineMarkdown(line)}</p>`)
515+
.join('')}</blockquote>`;
516+
blockquoteLines = [];
517+
}
518+
};
519+
520+
for (const rawLine of lines) {
521+
const line = rawLine.trim();
522+
if (!line) {
523+
flushParagraph();
524+
flushList();
525+
flushBlockquote();
526+
continue;
527+
}
528+
529+
const headingMatch = /^(#{1,3})\s+(.+)$/.exec(line);
530+
if (headingMatch) {
531+
flushParagraph();
532+
flushList();
533+
flushBlockquote();
534+
const level = headingMatch[1].length;
535+
const size = level === 1 ? 'text-2xl' : level === 2 ? 'text-xl' : 'text-lg';
536+
html += `<h${level} class="${size} font-bold mt-4 mb-2">${renderInlineMarkdown(
537+
headingMatch[2]
538+
)}</h${level}>`;
539+
continue;
540+
}
541+
542+
const listMatch = /^[-*]\s+(.+)$/.exec(line);
543+
if (listMatch) {
544+
flushParagraph();
545+
flushBlockquote();
546+
listItems.push(listMatch[1]);
547+
continue;
548+
}
549+
550+
const quoteMatch = /^>\s?(.+)$/.exec(line);
551+
if (quoteMatch) {
552+
flushParagraph();
553+
flushList();
554+
blockquoteLines.push(quoteMatch[1]);
555+
continue;
556+
}
557+
558+
if (listItems.length > 0) {
559+
flushList();
560+
}
561+
if (blockquoteLines.length > 0) {
562+
flushBlockquote();
563+
}
564+
paragraph.push(line);
565+
}
566+
567+
flushParagraph();
568+
flushList();
569+
flushBlockquote();
570+
571+
return html;
572+
}
573+
574+
function renderSlideContentHTML(contentBlocks: ContentBlock[]): string {
575+
let html = '';
576+
577+
for (const block of contentBlocks) {
578+
if (block.type === 'unordered_list') {
579+
html += '<ul class="list-disc pl-6 my-2">';
580+
for (const item of block.items) {
581+
html += `<li>${renderRuns(item.content)}</li>`;
582+
}
583+
html += '</ul>';
584+
} else if (block.type === 'ordered_list') {
585+
html += '<ol class="list-decimal pl-6 my-2">';
586+
for (const item of block.items) {
587+
html += `<li>${renderRuns(item.content)}</li>`;
588+
}
589+
html += '</ol>';
590+
} else if (block.type === 'blockquote') {
591+
html += '<blockquote class="border-l-4 border-gray-300 pl-4 italic text-gray-600 my-3">';
592+
for (const paragraph of block.content) {
593+
html += `<p>${renderRuns(paragraph.content)}</p>`;
594+
}
595+
html += '</blockquote>';
596+
} else if (block.type === 'paragraph') {
597+
const rawText = runsToText(block.content).trim();
598+
const headingMatch = /^(#{1,3})\s+(.+)$/.exec(rawText);
599+
if (headingMatch) {
600+
const level = headingMatch[1].length;
601+
const size = level === 1 ? 'text-2xl' : level === 2 ? 'text-xl' : 'text-lg';
602+
html += `<h${level} class="${size} font-bold mt-3 mb-2">${renderInlineMarkdown(
603+
headingMatch[2]
604+
)}</h${level}>`;
605+
} else {
606+
html += `<p class="my-2">${renderRuns(block.content)}</p>`;
607+
}
608+
}
609+
}
610+
611+
return html;
612+
}
613+
614+
function renderRuns(runs: TextRun[]): string {
615+
return runs
616+
.map((run) => {
617+
if (typeof run === 'string') {
618+
return escapeHTML(run);
619+
}
620+
if (isLinkRun(run)) {
621+
const text = escapeHTML(run.text || '');
622+
const url = escapeHTML(run.url || '#');
623+
return `<a class="underline text-blue-600" href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`;
624+
}
625+
if (isImageRun(run)) {
626+
const alt = escapeHTML(run.alt || '');
627+
const url = escapeHTML(run.url || '');
628+
return `<img class="inline-block max-h-40" src="${url}" alt="${alt}" />`;
629+
}
630+
let content = escapeHTML(run.text || '');
631+
if (run.bold) content = `<strong>${content}</strong>`;
632+
if (run.italic) content = `<em>${content}</em>`;
633+
if (run.underline) content = `<span class="underline">${content}</span>`;
634+
if (run.strike) content = `<span class="line-through">${content}</span>`;
635+
return content;
636+
})
637+
.join('');
638+
}
639+
640+
function runsToText(runs: TextRun[]): string {
641+
return runs
642+
.map((run) => {
643+
if (typeof run === 'string') return run;
644+
if (isLinkRun(run)) return run.text || '';
645+
if (isImageRun(run)) return run.alt || '';
646+
return run.text || '';
647+
})
648+
.join('');
649+
}
650+
651+
function isLinkRun(run: TextRun): run is OSFLink {
652+
return typeof run === 'object' && run !== null && 'type' in run && run.type === 'link';
653+
}
654+
655+
function isImageRun(run: TextRun): run is OSFImage {
656+
return typeof run === 'object' && run !== null && 'type' in run && run.type === 'image';
657+
}
658+
659+
function renderInlineMarkdown(text: string): string {
660+
let html = escapeHTML(text);
661+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
662+
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
663+
html = html.replace(/`(.+?)`/g, '<code class="bg-gray-100 px-1 rounded">$1</code>');
664+
return html;
665+
}
666+
667+
function escapeHTML(text: string): string {
668+
return text
669+
.replace(/&/g, '&amp;')
670+
.replace(/</g, '&lt;')
671+
.replace(/>/g, '&gt;')
672+
.replace(/"/g, '&quot;')
673+
.replace(/'/g, '&#039;');
674+
}

0 commit comments

Comments
 (0)