FeaturesTemplatesShowcaseTownie
AI
BlogDocsPricing
Log inSign up
lightweight
lightweightglimpse2-runbook-test
Remix of lightweight/glimpse2-runbook
Public
Like
glimpse2-runbook-test
Home
Code
7
_townie
13
backend
7
frontend
1
shared
1
.vtignore
deno.json
H
main.tsx
Branches
3
Pull requests
Remixes
History
Environment variables
5
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
_townie
/
11-html.md
Code
/
_townie
/
11-html.md
Search
9/3/2025
Viewing readonly version of main branch: v36
View latest version
11-html.md

Complete Instructions: Implementing Notion HTML Renderer for Glimpse Pages

Context

The /glimpse/:id endpoint currently displays Notion data as JSON in a <pre> tag. The goal is to render this data as properly formatted HTML while maintaining the existing architecture and caching system.

Current Architecture Understanding

  • Backend fetches Notion data via getGlimpseData() in /backend/controllers/glimpse.controller.ts
  • Data structure includes main page + glimpseContent array of related pages
  • Each glimpse content item has: {id, properties, blocks} where blocks is the direct array (not blocks.results)
  • Frontend uses React with server-side data injection via window.__INITIAL_DATA__
  • Current display: GlimpseView.tsx renders JSON in <pre> tag

Implementation Steps

1. Create NotionRenderer Component

Create /frontend/components/NotionRenderer.tsx:

/** @jsxImportSource https://esm.sh/react@18.2.0 */
import React from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
import { BlockRenderer } from "./BlockRenderer.tsx";

interface NotionRendererProps {
  data: any;
}

export function NotionRenderer({ data }: NotionRendererProps) {
  if (!data) {
    return <div className="text-gray-500">No data available</div>;
  }

  // Handle both direct data and wrapped service response
  const mainPage = data.data || data;
  const glimpseContent = mainPage.glimpseContent || [];

  return (
    <div className="space-y-8">
      {/* Main Page Header */}
      <div className="border-b pb-6">
        <h1 className="text-2xl font-bold text-gray-900 mb-4">
          {getPageTitle(mainPage)}
        </h1>
        {/* Only show Name property if it exists and is not already shown as title */}
        {mainPage.properties && (
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            {Object.entries(mainPage.properties)
              .filter(([key, value]) => key === 'Name' && (value as any)?.type !== 'title')
              .map(([key, value]) => (
                <PropertyRenderer key={key} name={key} property={value} />
              ))}
          </div>
        )}
      </div>

      {/* Glimpse Content */}
      {glimpseContent.length > 0 ? (
        <div className="space-y-8">
          <h2 className="text-xl font-semibold text-gray-800">
            Content ({glimpseContent.length} pages)
          </h2>
          {glimpseContent.map((contentPage: any, index: number) => (
            <ContentPageRenderer key={contentPage.id || index} page={contentPage} />
          ))}
        </div>
      ) : (
        <div className="text-gray-500 italic">
          No glimpse content available for this page.
        </div>
      )}
    </div>
  );
}

function getPageTitle(page: any): string {
  // Try to extract title from properties
  if (page.properties) {
    const titleProp = Object.values(page.properties).find((prop: any) => prop.type === 'title');
    if (titleProp && (titleProp as any).title?.[0]?.plain_text) {
      return (titleProp as any).title[0].plain_text;
    }
  }

  // Fallback to page object title if available
  if (page.object === 'page' && page.properties?.title?.title?.[0]?.plain_text) {
    return page.properties.title.title[0].plain_text;
  }

  return 'Untitled';
}

function PropertyRenderer({ name, property }: { name: string; property: any }) {
  if (!property || property.type === 'title') return null;

  const renderPropertyValue = () => {
    switch (property.type) {
      case 'rich_text':
        return property.rich_text?.map((text: any, i: number) => (
          <span key={i}>{text.plain_text}</span>
        )).join('') || '';
      case 'number':
        return property.number?.toString() || '';
      case 'select':
        return property.select?.name || '';
      case 'multi_select':
        return property.multi_select?.map((item: any) => item.name).join(', ') || '';
      case 'date':
        return property.date?.start || '';
      case 'checkbox':
        return property.checkbox ? '✓' : '✗';
      case 'url':
        return property.url ? (
          <a href={property.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
            {property.url}
          </a>
        ) : '';
      case 'email':
        return property.email ? (
          <a href={`mailto:${property.email}`} className="text-blue-600 hover:underline">
            {property.email}
          </a>
        ) : '';
      case 'phone_number':
        return property.phone_number || '';
      case 'relation':
        return `${property.relation?.length || 0} related items`;
      default:
        return JSON.stringify(property);
    }
  };

  const value = renderPropertyValue();
  if (!value) return null;

  return (
    <div className="bg-gray-50 p-3 rounded">
      <dt className="text-sm font-medium text-gray-600">{name}</dt>
      <dd className="text-sm text-gray-900 mt-1">{value}</dd>
    </div>
  );
}

function ContentPageRenderer({ page }: { page: any }) {
  const title = getPageTitle(page);
  // CRITICAL: blocks are directly on page object, not page.blocks.results
  const blocks = page.blocks || [];

  return (
    <div className="border rounded-lg p-6 bg-white">
      {title !== 'Untitled' && (
        <h3 className="text-lg font-medium text-gray-900 mb-4">{title}</h3>
      )}

      {/* Render blocks - NO page properties for content pages */}
      {blocks.length > 0 ? (
        <div className="prose max-w-none">
          <BlockGroupRenderer blocks={blocks} />
        </div>
      ) : (
        <div className="text-gray-500 italic text-sm">
          No content blocks found for this page.
        </div>
      )}
    </div>
  );
}

function BlockGroupRenderer({ blocks }: { blocks: any[] }) {
  const groupedBlocks: any[] = [];
  let currentGroup: any = null;

  for (let i = 0; i < blocks.length; i++) {
    const block = blocks[i];

    if (block.type === 'numbered_list_item') {
      if (!currentGroup || currentGroup.type !== 'numbered_list') {
        // Start a new numbered list group
        currentGroup = {
          type: 'numbered_list',
          items: [block]
        };
        groupedBlocks.push(currentGroup);
      } else {
        // Add to existing numbered list group
        currentGroup.items.push(block);
      }
    } else if (block.type === 'bulleted_list_item') {
      if (!currentGroup || currentGroup.type !== 'bulleted_list') {
        // Start a new bulleted list group
        currentGroup = {
          type: 'bulleted_list',
          items: [block]
        };
        groupedBlocks.push(currentGroup);
      } else {
        // Add to existing bulleted list group
        currentGroup.items.push(block);
      }
    } else {
      // Non-list item, reset current group
      currentGroup = null;
      groupedBlocks.push(block);
    }
  }

  return (
    <>
      {groupedBlocks.map((item, index) => {
        if (item.type === 'numbered_list') {
          return (
            <ol key={index} className="space-y-2 mb-4 ml-4" style={{
              listStyleType: 'decimal',
              listStylePosition: 'outside',
              paddingLeft: '1.5rem'
            }}>
              {item.items.map((block: any, itemIndex: number) => (
                <li key={block.id || itemIndex} className="pl-2">
                  <BlockRenderer block={block} listContext={{ type: 'numbered', index: itemIndex }} />
                </li>
              ))}
            </ol>
          );
        } else if (item.type === 'bulleted_list') {
          return (
            <ul key={index} className="space-y-2 mb-4 ml-4" style={{
              listStyleType: 'disc',
              listStylePosition: 'outside',
              paddingLeft: '1.5rem'
            }}>
              {item.items.map((block: any, itemIndex: number) => (
                <li key={block.id || itemIndex} className="pl-2">
                  <BlockRenderer block={block} listContext={{ type: 'bulleted', index: itemIndex }} />
                </li>
              ))}
            </ul>
          );
        } else {
          return <BlockRenderer key={item.id || index} block={item} />;
        }
      })}
    </>
  );
}

2. Create BlockRenderer Component

Create /frontend/components/BlockRenderer.tsx:

/** @jsxImportSource https://esm.sh/react@18.2.0 */
import React from "https://esm.sh/react@18.2.0?deps=react@18.2.0";

interface BlockRendererProps {
  block: any;
  listContext?: {
    type: 'numbered' | 'bulleted';
    index: number;
  };
}

// Helper function to group blocks for nested lists
function groupBlocks(blocks: any[]): any[] {
  const groupedBlocks: any[] = [];
  let currentGroup: any = null;

  for (let i = 0; i < blocks.length; i++) {
    const block = blocks[i];

    if (block.type === 'numbered_list_item') {
      if (!currentGroup || currentGroup.type !== 'numbered_list') {
        currentGroup = {
          type: 'numbered_list',
          items: [block]
        };
        groupedBlocks.push(currentGroup);
      } else {
        currentGroup.items.push(block);
      }
    } else if (block.type === 'bulleted_list_item') {
      if (!currentGroup || currentGroup.type !== 'bulleted_list') {
        currentGroup = {
          type: 'bulleted_list',
          items: [block]
        };
        groupedBlocks.push(currentGroup);
      } else {
        currentGroup.items.push(block);
      }
    } else {
      currentGroup = null;
      groupedBlocks.push(block);
    }
  }

  return groupedBlocks;
}

// Helper component to render grouped blocks
function NestedBlockRenderer({ blocks }: { blocks: any[] }) {
  const groupedBlocks = groupBlocks(blocks);

  return (
    <>
      {groupedBlocks.map((item, index) => {
        if (item.type === 'numbered_list') {
          return (
            <ol key={index} className="space-y-1 mt-2 ml-4" style={{
              listStyleType: 'decimal',
              listStylePosition: 'outside',
              paddingLeft: '1.5rem'
            }}>
              {item.items.map((block: any, itemIndex: number) => (
                <li key={block.id || itemIndex} className="pl-2">
                  <BlockRenderer block={block} listContext={{ type: 'numbered', index: itemIndex }} />
                </li>
              ))}
            </ol>
          );
        } else if (item.type === 'bulleted_list') {
          return (
            <ul key={index} className="space-y-1 mt-2 ml-4" style={{
              listStyleType: 'disc',
              listStylePosition: 'outside',
              paddingLeft: '1.5rem'
            }}>
              {item.items.map((block: any, itemIndex: number) => (
                <li key={block.id || itemIndex} className="pl-2">
                  <BlockRenderer block={block} listContext={{ type: 'bulleted', index: itemIndex }} />
                </li>
              ))}
            </ul>
          );
        } else {
          return <BlockRenderer key={item.id || index} block={item} />;
        }
      })}
    </>
  );
}

export function BlockRenderer({ block, listContext }: BlockRendererProps) {
  if (!block || !block.type) {
    return null;
  }

  const blockData = block[block.type] || {};
  const children = block.children || [];

  const renderChildren = () => {
    if (children.length === 0) return null;
    return (
      <div className="ml-4 mt-2">
        {children.map((child: any, index: number) => (
          <BlockRenderer key={child.id || index} block={child} />
        ))}
      </div>
    );
  };

  switch (block.type) {
    case 'paragraph':
      return (
        <div className="mb-4">
          <RichTextRenderer richText={blockData?.rich_text || []} />
          {renderChildren()}
        </div>
      );

    case 'heading_1':
      return (
        <div className="mb-6">
          <h1 className="text-2xl font-bold text-gray-900 mb-2">
            <RichTextRenderer richText={blockData?.rich_text || []} />
          </h1>
          {renderChildren()}
        </div>
      );

    case 'heading_2':
      return (
        <div className="mb-5">
          <h2 className="text-xl font-semibold text-gray-900 mb-2">
            <RichTextRenderer richText={blockData?.rich_text || []} />
          </h2>
          {renderChildren()}
        </div>
      );

    case 'heading_3':
      return (
        <div className="mb-4">
          <h3 className="text-lg font-medium text-gray-900 mb-2">
            <RichTextRenderer richText={blockData?.rich_text || []} />
          </h3>
          {renderChildren()}
        </div>
      );

    case 'bulleted_list_item':
      if (listContext?.type === 'bulleted') {
        // When rendered inside <ul>, render content inline to avoid line breaks after bullets
        return (
          <>
            <RichTextRenderer richText={blockData?.rich_text || []} />
            {children.length > 0 && (
              <NestedBlockRenderer blocks={children} />
            )}
          </>
        );
      }
      // Fallback for standalone bulleted list items
      return (
        <div className="mb-2">
          <div className="flex items-start">
            <span className="mr-2 mt-2 w-1 h-1 bg-gray-600 rounded-full flex-shrink-0"></span>
            <div className="flex-1">
              <RichTextRenderer richText={blockData?.rich_text || []} />
              {renderChildren()}
            </div>
          </div>
        </div>
      );

    case 'numbered_list_item':
      if (listContext?.type === 'numbered') {
        // When rendered inside <ol>, render content inline to avoid line breaks after numbers
        return (
          <>
            <RichTextRenderer richText={blockData?.rich_text || []} />
            {children.length > 0 && (
              <NestedBlockRenderer blocks={children} />
            )}
          </>
        );
      }
      // Fallback for standalone numbered list items
      return (
        <div className="mb-2">
          <div className="flex items-start">
            <span className="mr-2 text-gray-600 font-medium min-w-[1.5rem]">1.</span>
            <div className="flex-1">
              <RichTextRenderer richText={blockData?.rich_text || []} />
              {renderChildren()}
            </div>
          </div>
        </div>
      );

    case 'to_do':
      return (
        <div className="mb-2">
          <div className="flex items-start">
            <input
              type="checkbox"
              checked={blockData?.checked || false}
              readOnly
              className="mr-2 mt-1"
            />
            <div className="flex-1">
              <RichTextRenderer richText={blockData?.rich_text || []} />
              {renderChildren()}
            </div>
          </div>
        </div>
      );

    case 'quote':
      return (
        <div className="mb-4">
          <blockquote className="border-l-4 border-gray-300 pl-4 italic text-gray-700">
            <RichTextRenderer richText={blockData?.rich_text || []} />
          </blockquote>
          {renderChildren()}
        </div>
      );

    case 'code':
      return (
        <div className="mb-4">
          <pre className="bg-gray-100 border rounded p-4 overflow-x-auto">
            <code className={`language-${blockData?.language || 'text'}`}>
              <RichTextRenderer richText={blockData?.rich_text || []} />
            </code>
          </pre>
          {renderChildren()}
        </div>
      );

    case 'callout':
      const renderCalloutIcon = () => {
        if (!blockData?.icon) return '💡';

        if (blockData.icon.type === 'emoji') {
          return blockData.icon.emoji;
        } else if (blockData.icon.type === 'external' && blockData.icon.external?.url) {
          return (
            <img
              src={blockData.icon.external.url}
              alt="Callout icon"
              className="w-5 h-5"
            />
          );
        } else if (blockData.icon.type === 'file' && blockData.icon.file?.url) {
          return (
            <img
              src={blockData.icon.file.url}
              alt="Callout icon"
              className="w-5 h-5"
            />
          );
        }

        return '💡';
      };

      return (
        <div className="mb-4">
          <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
            <div className="flex items-start">
              <span className="mr-2 text-lg flex-shrink-0 flex items-center">
                {renderCalloutIcon()}
              </span>
              <div className="flex-1">
                <RichTextRenderer richText={blockData?.rich_text || []} />
                {/* CRITICAL: Render children INSIDE callout container */}
                {children.length > 0 && (
                  <div className="mt-3">
                    {children.map((child: any, index: number) => (
                      <BlockRenderer key={child.id || index} block={child} />
                    ))}
                  </div>
                )}
              </div>
            </div>
          </div>
        </div>
      );

    case 'divider':
      return (
        <div className="mb-6">
          <hr className="border-gray-300" />
          {renderChildren()}
        </div>
      );

    case 'image':
      const imageUrl = blockData?.file?.url || blockData?.external?.url;
      const caption = blockData?.caption || [];
      return (
        <div className="mb-4">
          {imageUrl && (
            <img
              src={imageUrl}
              alt={caption.length > 0 ? caption[0].plain_text : 'Image'}
              className="max-w-full h-auto rounded"
            />
          )}
          {caption.length > 0 && (
            <p className="text-sm text-gray-600 mt-2 italic">
              <RichTextRenderer richText={caption} />
            </p>
          )}
          {renderChildren()}
        </div>
      );

    case 'video':
      const videoUrl = blockData?.file?.url || blockData?.external?.url;
      return (
        <div className="mb-4">
          {videoUrl && (
            <video controls className="max-w-full h-auto rounded">
              <source src={videoUrl} />
              Your browser does not support the video tag.
            </video>
          )}
          {renderChildren()}
        </div>
      );

    case 'embed':
      return (
        <div className="mb-4">
          <div className="bg-gray-100 border rounded p-4">
            <p className="text-sm text-gray-600">
              Embedded content: <a href={blockData?.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">{blockData?.url}</a>
            </p>
          </div>
          {renderChildren()}
        </div>
      );

    case 'bookmark':
      return (
        <div className="mb-4">
          <a
            href={blockData?.url}
            target="_blank"
            rel="noopener noreferrer"
            className="block border rounded-lg p-4 hover:bg-gray-50 transition-colors"
          >
            <div className="text-blue-600 hover:underline font-medium">
              {blockData?.caption?.[0]?.plain_text || blockData?.url}
            </div>
            <div className="text-sm text-gray-500 mt-1">{blockData?.url}</div>
          </a>
          {renderChildren()}
        </div>
      );

    case 'table':
      return (
        <div className="mb-4 overflow-x-auto">
          <table className="min-w-full border border-gray-300">
            <tbody>
              {children.map((row: any, rowIndex: number) => (
                <tr key={row.id || rowIndex} className="border-b border-gray-300">
                  {row.table_row?.cells?.map((cell: any, cellIndex: number) => (
                    <td key={cellIndex} className="border-r border-gray-300 p-2">
                      <RichTextRenderer richText={cell || []} />
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      );

    case 'table_row':
      return null;

    default:
      return (
        <div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
          <p className="text-sm text-yellow-800">
            Unsupported block type: <code className="font-mono">{block.type}</code>
          </p>
          {blockData?.rich_text && (
            <div className="mt-2">
              <RichTextRenderer richText={blockData.rich_text} />
            </div>
          )}
          {renderChildren()}
        </div>
      );
  }
}

function RichTextRenderer({ richText }: { richText: any[] }) {
  if (!richText || richText.length === 0) {
    return null;
  }

  return (
    <>
      {richText.map((text, index) => {
        let element = <span key={index}>{text.plain_text}</span>;

        if (text.annotations) {
          if (text.annotations.bold) {
            element = <strong key={index}>{element}</strong>;
          }
          if (text.annotations.italic) {
            element = <em key={index}>{element}</em>;
          }
          if (text.annotations.strikethrough) {
            element = <del key={index}>{element}</del>;
          }
          if (text.annotations.underline) {
            element = <u key={index}>{element}</u>;
          }
          if (text.annotations.code) {
            element = <code key={index} className="bg-gray-100 px-1 py-0.5 rounded text-sm font-mono">{text.plain_text}</code>;
          }
          if (text.annotations.color && text.annotations.color !== 'default') {
            const colorClass = getColorClass(text.annotations.color);
            element = <span key={index} className={colorClass}>{element}</span>;
          }
        }

        if (text.href) {
          element = (
            <a
              key={index}
              href={text.href}
              target="_blank"
              rel="noopener noreferrer"
              className="text-blue-600 hover:underline"
            >
              {element}
            </a>
          );
        }

        return element;
      })}
    </>
  );
}

function getColorClass(color: string): string {
  const colorMap: { [key: string]: string } = {
    'gray': 'text-gray-600',
    'brown': 'text-amber-700',
    'orange': 'text-orange-600',
    'yellow': 'text-yellow-600',
    'green': 'text-green-600',
    'blue': 'text-blue-600',
    'purple': 'text-purple-600',
    'pink': 'text-pink-600',
    'red': 'text-red-600',
    'gray_background': 'bg-gray-100',
    'brown_background': 'bg-amber-100',
    'orange_background': 'bg-orange-100',
    'yellow_background': 'bg-yellow-100',
    'green_background': 'bg-green-100',
    'blue_background': 'bg-blue-100',
    'purple_background': 'bg-purple-100',
    'pink_background': 'bg-pink-100',
    'red_background': 'bg-red-100',
  };

  return colorMap[color] || '';
}

3. Update GlimpseView Component

Modify /frontend/components/GlimpseView.tsx:

// Add import
import React, { useState } from "https://esm.sh/react@18.2.0?deps=react@18.2.0";
import { NotionRenderer } from "./NotionRenderer.tsx";

// Add state to component
export function GlimpseView({ data, userEmail }: GlimpseViewProps) {
  const [showDebug, setShowDebug] = useState(false);

// Replace main content section
        <main className="flex-1 p-6 overflow-auto">
          <div className="max-w-full">
            {/* Debug toggle */}
            <div className="mb-4 flex items-center gap-4">
              <button
                onClick={() => setShowDebug(!showDebug)}
                className="px-3 py-1 text-sm bg-gray-100 hover:bg-gray-200 rounded border"
              >
                {showDebug ? 'Hide' : 'Show'} Raw JSON
              </button>
            </div>

            {showDebug ? (
              <div>
                <h2 className="text-lg font-medium text-gray-900 mb-4">Raw Glimpse Data</h2>
                <pre className="bg-white border rounded-lg p-4 text-sm overflow-auto whitespace-pre-wrap break-words">
                  {JSON.stringify(data, null, 2)}
                </pre>
              </div>
            ) : (
              <NotionRenderer data={data} />
            )}
          </div>
        </main>

4. Update Documentation

Update /frontend/README.md to document the new HTML rendering functionality and supported block types.

Critical Implementation Notes

  1. Data Structure: The service returns item.blocks.results which the controller assigns to blocks property, so the frontend receives glimpseContent[].blocks as a direct array (not nested under a results property)"
  2. Callout Children: Must render children INSIDE the callout container, not after it
  3. Icon Handling: Support emoji, external, and file icon types for callouts
  4. Property Filtering: Only show "Name" property for main page, no properties for content pages
  5. Rich Text: Handle all formatting (bold, italic, colors, links) and nested structures
  6. Error Handling: Provide fallbacks for missing data and unsupported block types
  7. List Context: Pass listContext parameter to BlockRenderer for proper list item rendering
  8. List Formatting: Use listStylePosition: 'outside' to prevent line breaks after numbers/bullets
  9. Nested List Rendering: List item content must render as React Fragments (<>) when inside grouped lists to avoid block-level line breaks
  10. List Grouping: Consecutive list items must be grouped into proper <ol> and <ul> elements for correct browser numbering
  11. Nested List Helpers: Use NestedBlockRenderer component for handling nested lists within list items
  12. Browser-Native Numbering: Leverage browser's automatic list numbering with semantic HTML elements

Expected Result

  • /glimpse/:id renders formatted HTML instead of JSON
  • Debug toggle allows switching between HTML and JSON views
  • All major Notion block types render correctly with proper styling
  • Nested content (especially in callouts) is properly contained
  • Custom icons display correctly in callouts
  • Clean layout with minimal metadata display
  • Proper list numbering with browser-native <ol> and <ul> elements
  • No line breaks after list numbers or bullets
  • Correct nested list numbering (nested lists start from 1)
  • Proper indentation for nested lists
Go to top
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Product
FeaturesPricing
Developers
DocsStatusAPI ExamplesNPM Package Examples
Explore
ShowcaseTemplatesNewest ValsTrending ValsNewsletter
Company
AboutBlogCareersBrandhi@val.town
Terms of usePrivacy policyAbuse contact
© 2025 Val Town, Inc.