Pastebin ass, github gist ass blog post

@thomas.advanced-eschatonics.com

Parsing MyST Markdown with TypeScript

So I put together this editor/preview component for the mystmd.org page like I've said in previous posts (some way to crossref sections in other posts?, also this editor is slow as christmas with all this tex in here lmfao)

So here's the react component I put together for my site

theme/src/components/MultiFileMySTDemo.tsx

import { ProjectBrowser, SaveProjectModal, RenderSuccessPopup } from './ProjectModal';
import { FileExplorer } from './FileExplorer';
import { CreativeCommonsIcon, AttributionIcon, ORCIDIcon, ApacheIcon } from './Icons';
import { CodeEditor } from './CodeEditor';
import { FrontmatterBlock } from './FrontmatterBlock';
import React, { useEffect, useState, useRef } from 'react';
import classnames from 'classnames';
import { ArticleProvider, GridSystemProvider } from '@myst-theme/providers';
import { MyST } from 'myst-to-react';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { markdown } from '@codemirror/lang-markdown';
import { yaml } from '@codemirror/lang-yaml';
import { css } from '@codemirror/lang-css';
import { lineNumbers, EditorView as EditorViewType } from '@codemirror/view';
import { oneDark } from '@codemirror/theme-one-dark';

/**
 * EXACTLY LIKE THE ORIGINAL - Real-time MyST parsing with debouncing
 * BUT now we combine multiple markdown files for parsing
 */
async function parseMarkdown(md: string) {
  // Keep the EXACT same parsing logic from CustomMySTDemo
  const { VFile } = await import('vfile');
  const { visit } = await import('unist-util-visit');
  const { unified } = await import('unified');
  const { mystParse } = await import('myst-parser');
  const {
    mathPlugin,
    footnotesPlugin,
    keysPlugin,
    htmlPlugin,
    reconstructHtmlPlugin,
    basicTransformationsPlugin,
    enumerateTargetsPlugin,
    resolveReferencesPlugin,
    WikiTransformer,
    GithubTransformer,
    DOITransformer,
    RRIDTransformer,
    RORTransformer,
    linksPlugin,
    ReferenceState,
    abbreviationPlugin,
    glossaryPlugin,
    joinGatesPlugin,
  } = await import('myst-transforms');
  const { mystToHtml } = await import('myst-to-html');
  const { buttonRole } = await import('myst-ext-button');
  const { cardDirective } = await import('myst-ext-card');
  const { gridDirectives } = await import('myst-ext-grid');
  const { tabDirectives } = await import('myst-ext-tabs');
  const { proofDirective } = await import('myst-ext-proof');
  const { exerciseDirectives } = await import('myst-ext-exercise');
  const { validatePageFrontmatter } = await import('myst-frontmatter');
  const { remove } = await import('unist-util-remove');
  const { load: yamlLoad } = await import('js-yaml');
  const { fileError, RuleId } = await import('myst-common');

  const vfile = new VFile();
  
  const parseMyst = (content: string) =>
    mystParse(content, {
      markdownit: { linkify: true },
      directives: [
        cardDirective,
        ...gridDirectives,
        ...tabDirectives,
        proofDirective,
        ...exerciseDirectives,
      ],
      roles: [buttonRole],
      vfile,
    });

  const mdast = parseMyst(md);
  
  const linkTransforms = [
    new WikiTransformer(),
    new GithubTransformer(),
    new DOITransformer(),
    new RRIDTransformer(),
    new RORTransformer(),
  ];

  // Frontmatter extraction (same as original)
  const firstParent = mdast.children[0]?.type === 'block' ? mdast.children[0] : mdast;
  const firstNode = firstParent.children?.[0];
  let frontmatterRaw = {};
  const firstIsYaml = firstNode?.type === 'code' && firstNode?.lang === 'yaml';
  if (firstIsYaml) {
    try {
      frontmatterRaw = (yamlLoad(firstNode.value) as Record<string, any>) || {};
      (firstNode as any).type = '__delete__';
    } catch (err) {
      fileError(vfile, 'Invalid YAML frontmatter', {
        note: (err as Error).message,
        ruleId: RuleId.frontmatterIsYaml,
      });
    }
  }
  
  const possibleNull = remove(mdast, '__delete__');
  if (possibleNull === null) {
    remove(mdast, { cascade: false }, '__delete__');
  }

  const validatedFrontmatter = validatePageFrontmatter(frontmatterRaw, {
    property: 'frontmatter',
    messages: {},
  });

  const references = {
    cite: { order: [], data: {} },
  };

  const state = new ReferenceState('', {
    frontmatter: validatedFrontmatter,
    vfile,
  });

  visit(mdast, (n) => {
    if (n.type === 'cite') {
      n.error = true;
    }
  });

  // Apply all transforms for full MyST functionality
  unified()
    .use(reconstructHtmlPlugin)
    .use(htmlPlugin)
    .use(basicTransformationsPlugin, { parser: parseMyst })
    .use(mathPlugin, { macros: validatedFrontmatter?.math ?? {} })
    .use(glossaryPlugin)
    .use(abbreviationPlugin, { abbreviations: validatedFrontmatter.abbreviations })
    .use(enumerateTargetsPlugin, { state })
    .use(linksPlugin, { transformers: linkTransforms })
    .use(footnotesPlugin)
    .use(joinGatesPlugin)
    .use(resolveReferencesPlugin, { state })
    .use(keysPlugin)
    .runSync(mdast as any, vfile);

  const html = mystToHtml(JSON.parse(JSON.stringify(mdast)));

  return { 
    mdast, 
    html, 
    frontmatter: validatedFrontmatter,
    references: { ...references, article: mdast }
  };
}

// Backend API function
async function renderProject(files: Record<string, string>) {
  const response = await fetch('http://localhost:8000/render', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ files }),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  }

  return await response.json();
}

// Save project function (imported from ProjectModal)
async function saveProject(name: string, description: string, files: Record<string, string>, projectId?: string) {
  const response = await fetch('http://localhost:8000/projects', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ id: projectId, name, description, files }),
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
  }

  return await response.json();
}

// MAIN COMPONENT - CLEAN STATE MANAGEMENT
export default function MultiFileMySTDemo({
  files: initialFiles,
  onFilesChange,
  className,
}: {
  files: Record<string, string>;
  onFilesChange: (files: Record<string, string>) => void;
  className?: string;
}) {
  // SIMPLE STATE - NO COMPLEX REFS OR FLAGS
  const [files, setFiles] = useState(initialFiles);
  const [activeFile, setActiveFile] = useState('index.md');
  const [currentProject, setCurrentProject] = useState<any>(null);
  
  // Add file explorer collapse state
  const [isFileExplorerCollapsed, setIsFileExplorerCollapsed] = useState(false);
  
  // UI state
  const [showSaveModal, setShowSaveModal] = useState(false);
  const [showProjectBrowser, setShowProjectBrowser] = useState(false);
  const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');

  // MyST parsing state
  const [mdast, setMdast] = useState<any>(null);
  const [frontmatter, setFrontmatter] = useState<any>({});
  const [references, setReferences] = useState<any>({});
  const [lastValidParseResult, setLastValidParseResult] = useState<{
    mdast: any;
    frontmatter: any;
    references: any;
    content: string;
  } | null>(null);
  const [isParsingRef, setIsParsingRef] = useState<{ current: boolean }>({ current: false });

  // Render state
  const [renderResult, setRenderResult] = useState<any>(null);
  const [isRendering, setIsRendering] = useState(false);
  const [renderError, setRenderError] = useState<string>('');
  const [showRenderPopup, setShowRenderPopup] = useState(false);

  // Keyboard shortcut handler for Ctrl+S
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if ((event.ctrlKey || event.metaKey) && event.key === 's') {
        event.preventDefault();
        handleQuickSave();
      }
    };

    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [files, currentProject]);

  // Quick save function (auto-save without modal)
  const handleQuickSave = async () => {
    if (saveStatus === 'saving') return;

    setSaveStatus('saving');

    try {
      let projectName = currentProject?.name;
      let projectDescription = currentProject?.description || '';
      let projectId = currentProject?.id;

      if (!currentProject) {
        const timestamp = new Date().toLocaleString();
        projectName = `MyST Project - ${timestamp}`;
        projectDescription = 'Auto-saved project';
      }

      const result = await saveProject(projectName, projectDescription, files, projectId);
      setCurrentProject(result);
      setSaveStatus('saved');

      setTimeout(() => setSaveStatus('idle'), 2000);
    } catch (error) {
      console.error('Quick save failed:', error);
      setSaveStatus('error');
      setTimeout(() => setSaveStatus('idle'), 3000);
    }
  };

  // Helper function
  const isMarkdownFile = (filename: string) => filename.endsWith('.md');
  
  // Get the current file content if it's markdown
  const activeFileContent = files[activeFile] || '';
  const shouldShowPreview = isMarkdownFile(activeFile);

  // Parse ONLY the active markdown file content - with graceful fallback
  useEffect(() => {
    if (!shouldShowPreview || !activeFileContent.trim()) {
      setMdast(null);
      setFrontmatter({});
      setReferences({});
      setLastValidParseResult(null);
      return;
    }

    const timeoutId = setTimeout(() => {
      if (isParsingRef.current) return;
      
      isParsingRef.current = true;
      parseMarkdown(activeFileContent)
        .then((result) => {
          setMdast(result.mdast);
          setFrontmatter(result.frontmatter);
          setReferences(result.references);
          setLastValidParseResult({
            mdast: result.mdast,
            frontmatter: result.frontmatter,
            references: result.references,
            content: activeFileContent
          });
        })
        .catch((error) => {
          console.error('Parse error:', error);
          if (lastValidParseResult && lastValidParseResult.content !== activeFileContent) {
            // Keep showing last valid result
          } else {
            setMdast(null);
            setFrontmatter({});
            setReferences({});
          }
        })
        .finally(() => {
          isParsingRef.current = false;
        });
    }, 300);

    return () => clearTimeout(timeoutId);
  }, [activeFileContent, shouldShowPreview, activeFile]);

  // Reset last valid state when switching files
  useEffect(() => {
    setLastValidParseResult(null);
  }, [activeFile]);

  const handleFileChange = (filename: string, content: string) => {
    const newFiles = { ...files, [filename]: content };
    setFiles(newFiles);
    onFilesChange(newFiles);
  };

  const handleFileAdd = (filename: string) => {
    const newFiles = { ...files, [filename]: '' };
    setFiles(newFiles);
    onFilesChange(newFiles);
  };

  const handleFileDelete = (filename: string) => {
    const newFiles = { ...files };
    delete newFiles[filename];
    setFiles(newFiles);
    onFilesChange(newFiles);
    
    if (activeFile === filename) {
      setActiveFile('index.md');
    }
  };

  const handleRender = async () => {
    setIsRendering(true);
    setRenderError('');
    
    try {
      const result = await renderProject(files);
      setRenderResult(result);
      if (result.status === 'success') {
        setShowRenderPopup(true);
      }
    } catch (error) {
      setRenderError(error instanceof Error ? error.message : 'Unknown error');
    } finally {
      setIsRendering(false);
    }
  };

  const handleProjectSave = (project: any) => {
    setCurrentProject(project);
  };

  // CLEAN PROJECT LOADING - FORCE EVERYTHING
  const handleProjectLoad = (project: any) => {
    console.log('Loading project:', project);
    console.log('Current files before:', Object.keys(files));
    
    // Get ONLY the project files
    const loadedFiles = project.files || {};
    console.log('Loading files:', Object.keys(loadedFiles));
    
    // Force clear everything first
    setFiles({});
    setCurrentProject(null);
    setActiveFile('');
    setMdast(null);
    setFrontmatter({});
    setReferences({});
    setLastValidParseResult(null);
    
    // Then set the new stuff on next tick
    setTimeout(() => {
      setFiles(loadedFiles);
      setCurrentProject(project);
      onFilesChange(loadedFiles);
      
      // Set active file - make sure it exists
      const fileNames = Object.keys(loadedFiles);
      console.log('Available files:', fileNames);
      
      let targetFile = 'index.md';
      if (!loadedFiles['index.md'] && fileNames.length > 0) {
        targetFile = fileNames.find(f => f.endsWith('.md')) || fileNames[0];
      }
      
      console.log('Setting active file to:', targetFile);
      setActiveFile(targetFile);
    }, 50);
  };

  const handleNewProject = () => {
    const defaultFiles = {
      "index.md": `---
title: New MyST Project
subtitle: A fresh start with MyST Markdown
---

# Welcome to MyST

Start building your project here!
`,
      "myst.yml": `version: 1
project:
  title: My MyST Project
  
site:
  template: book-theme
`
    };
    
    setFiles(defaultFiles);
    onFilesChange(defaultFiles);
    setCurrentProject(null);
    setActiveFile('index.md');
  };

  // Get save button display based on status
  const getSaveButtonContent = () => {
    switch (saveStatus) {
      case 'saving':
        return '💾 Saving...';
      case 'saved':
        return '✅ Saved';
      case 'error':
        return '❌ Error';
      default:
        return '💾 Save';
    }
  };

  const getSaveButtonClass = () => {
    switch (saveStatus) {
      case 'saving':
        return 'bg-yellow-600 hover:bg-yellow-700';
      case 'saved':
        return 'bg-green-600 hover:bg-green-700';
      case 'error':
        return 'bg-red-600 hover:bg-red-700';
      default:
        return 'bg-blue-600 hover:bg-blue-700';
    }
  };

  return (
    <div className={classnames('flex h-full max-h-full overflow-hidden', className)} style={{ height: '75vh', minHeight: '400px', maxHeight: '1400px' }}>
      {/* File Explorer - always rendered, but collapsed or expanded */}
      <FileExplorer
        files={files}
        activeFile={activeFile}
        onFileSelect={setActiveFile}
        onFileAdd={handleFileAdd}
        onFileDelete={handleFileDelete}
        isCollapsed={isFileExplorerCollapsed}
        onToggleCollapse={() => setIsFileExplorerCollapsed(!isFileExplorerCollapsed)}
      />

      {/* Editor - always 50% of remaining space */}
      <div className="flex-1 flex flex-col overflow-hidden min-h-0">
        <div className="flex items-center justify-between px-3 py-2 bg-gray-800 border-b border-gray-700 flex-shrink-0">
          <div className="flex items-center gap-2">
            {/* Remove the toggle button from here since it's now in FileExplorer */}
            <span className="text-sm text-gray-300">
              {currentProject ? currentProject.name : 'MyST Sandbox'}
            </span>
            {currentProject && (
              <span className="text-xs text-blue-400 bg-blue-900 px-2 py-1 rounded">saved</span>
            )}
            <span className="text-xs text-gray-500">Ctrl+S to save</span>
          </div>
          
          <div className="flex gap-2">
            <button
              onClick={() => setShowProjectBrowser(true)}
              className="px-3 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700"
              title="Browse Projects"
            >
              📁 Projects
            </button>
            <button
              onClick={handleNewProject}
              className="px-3 py-1 text-xs rounded bg-purple-600 text-white hover:bg-purple-700"
              title="New Project"
            >
              + New
            </button>
            <button
              onClick={() => setShowSaveModal(true)}
              disabled={saveStatus === 'saving'}
              className={`px-3 py-1 text-xs rounded text-white ${getSaveButtonClass()} disabled:opacity-50 disabled:cursor-not-allowed`}
            >
              {getSaveButtonContent()}
            </button>
            {currentProject && (
              <a
                href={`/project/${currentProject.id}`}
                target="_blank"
                rel="noopener noreferrer"
                className="px-3 py-1 text-xs rounded bg-indigo-600 text-white hover:bg-indigo-700"
              >
                🌐 View Live
              </a>
            )}
            <button
              onClick={handleRender}
              disabled={isRendering}
              className="px-3 py-1 text-xs rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
            >
              {isRendering ? 'Rendering...' : '🚀 Export Static'}
            </button>
          </div>
        </div>
        
        <CodeEditor
          filename={activeFile}
          content={activeFileContent}
          onChange={(content) => handleFileChange(activeFile, content)}
        />
      </div>

      {/* MyST Preview - always 50% of remaining space */}
      <div className="w-1/2 border-l border-gray-700 flex flex-col overflow-hidden min-h-0">
        <div className="px-3 py-2 bg-gray-800 border-b border-gray-700 flex-shrink-0">
          <span className="text-sm text-gray-300">
            MyST Preview {shouldShowPreview ? `- ${activeFile}` : ''}
          </span>
        </div>
        
        <div className="flex-1 overflow-auto p-4 myst min-h-0">
          {renderError && (
            <div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
              <strong>Render Error:</strong> {renderError}
            </div>
          )}
          
          {shouldShowPreview ? (
            mdast ? (
              <ArticleProvider references={references} frontmatter={frontmatter}>
                <GridSystemProvider>
                  <FrontmatterBlock frontmatter={frontmatter} />
                  <MyST ast={references.article || mdast} />
                </GridSystemProvider>
              </ArticleProvider>
            ) : (
              <div className="text-gray-400 text-center">
                {activeFileContent.trim() ? 'Parsing MyST...' : 'Start typing to see preview'}
              </div>
            )
          ) : (
            <div className="text-gray-500 text-center mt-16">
              <div className="text-6xl mb-4">📝</div>
              <div className="text-lg mb-2">Select a Markdown file to preview</div>
              <div className="text-sm opacity-75">Current: {activeFile}</div>
            </div>
          )}
        </div>
      </div>

      {/* Modals */}
      {showSaveModal && (
        <SaveProjectModal
          files={files}
          currentProject={currentProject}
          onSave={handleProjectSave}
          onClose={() => setShowSaveModal(false)}
        />
      )}

      {showProjectBrowser && (
        <ProjectBrowser
          currentProjectId={currentProject?.id}
          onLoadProject={handleProjectLoad}
          onClose={() => setShowProjectBrowser(false)}
        />
      )}

      {/* Render Success Popup */}
      {showRenderPopup && renderResult?.status === 'success' && (
        <RenderSuccessPopup
          siteUrl={renderResult.site_url}
          onClose={() => setShowRenderPopup(false)}
        />
      )}
    </div>
  );
}

Now all of this is super important but the main thing I want to harp on is this right here

  const parseMyst = (content: string) =>
    mystParse(content, {
      markdownit: { linkify: true },
      directives: [
        cardDirective,
        ...gridDirectives,
        ...tabDirectives,
        proofDirective,
        ...exerciseDirectives,
      ],
      roles: [buttonRole],
      vfile,
    });

  const mdast = parseMyst(md);
  
  const linkTransforms = [
    new WikiTransformer(),
    new GithubTransformer(),
    new DOITransformer(),
    new RRIDTransformer(),
    new RORTransformer(),
  ];

  // Frontmatter extraction (same as original)
  const firstParent = mdast.children[0]?.type === 'block' ? mdast.children[0] : mdast;
  const firstNode = firstParent.children?.[0];
  let frontmatterRaw = {};
  const firstIsYaml = firstNode?.type === 'code' && firstNode?.lang === 'yaml';
  if (firstIsYaml) {
    try {
      frontmatterRaw = (yamlLoad(firstNode.value) as Record<string, any>) || {};
      (firstNode as any).type = '__delete__';
    } catch (err) {
      fileError(vfile, 'Invalid YAML frontmatter', {
        note: (err as Error).message,
        ruleId: RuleId.frontmatterIsYaml,
      });
    }
  }
  
  const possibleNull = remove(mdast, '__delete__');
  if (possibleNull === null) {
    remove(mdast, { cascade: false }, '__delete__');
  }

  const validatedFrontmatter = validatePageFrontmatter(frontmatterRaw, {
    property: 'frontmatter',
    messages: {},
  });

  const references = {
    cite: { order: [], data: {} },
  };

  const state = new ReferenceState('', {
    frontmatter: validatedFrontmatter,
    vfile,
  });

  visit(mdast, (n) => {
    if (n.type === 'cite') {
      n.error = true;
    }
  });

  // Apply all transforms for full MyST functionality
  unified()
    .use(reconstructHtmlPlugin)
    .use(htmlPlugin)
    .use(basicTransformationsPlugin, { parser: parseMyst })
    .use(mathPlugin, { macros: validatedFrontmatter?.math ?? {} })
    .use(glossaryPlugin)
    .use(abbreviationPlugin, { abbreviations: validatedFrontmatter.abbreviations })
    .use(enumerateTargetsPlugin, { state })
    .use(linksPlugin, { transformers: linkTransforms })
    .use(footnotesPlugin)
    .use(joinGatesPlugin)
    .use(resolveReferencesPlugin, { state })
    .use(keysPlugin)
    .runSync(mdast as any, vfile);

  const html = mystToHtml(JSON.parse(JSON.stringify(mdast)));

  return { 
    mdast, 
    html, 
    frontmatter: validatedFrontmatter,
    references: { ...references, article: mdast }
  };
}

If you put in the time to do this then you'll get really good looking myst out the backend. Hope is that typst continues to upgrade their support for typst watch --format html for crossreferences in the future, but with myst we can do really cool things with publishing scientific content that it's just like why not? If people write in vanilla markdown they'll get vanilla markdown. Why not let me have all that functionality?

I know why! Because it's a lot of work. Well I've been busting my hump with claude to get up to speed on the state of rendering markdown in html over past few weeks and it's been an enriching experience.

Fuck the posts! I want the machines that are making 'em hahaha.

thomas.advanced-eschatonics.com
Thomas Wood

@thomas.advanced-eschatonics.com

AI scientist, roboticist, farmer, and political economist. Governments structure markets. IP is theft. @phytomech.com is my alt.

https://odellus.github.io

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)