Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions frontend/apps/web/app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from './instrumentation.server'
import {resolveResource} from './loaders'
import {logDebug} from './logger'
import {documentToMarkdown} from './markdown.server'
import {ParsedRequest, parseRequest} from './request'
import {
applyConfigSubscriptions,
Expand Down Expand Up @@ -263,6 +264,115 @@ function uriEncodedAuthors(authors: string[]) {
return authors.map((author) => encodeURIComponent(`hm://${author}`)).join(',')
}

/**
* Handle requests with .md extension - return raw markdown
* This enables bots and agents to easily consume SHM content without
* installing CLI tools or parsing HTML/React.
*
* Usage: GET https://hyper.media/hm/z6Mk.../path.md
* Returns: text/markdown with the document content
*/
async function handleMarkdownRequest(
parsedRequest: ParsedRequest,
hostname: string
): Promise<Response> {
const {url, pathParts} = parsedRequest

try {
// Strip .md extension from the last path part
const lastPart = pathParts[pathParts.length - 1]
const strippedPath = [...pathParts.slice(0, -1)]
if (lastPart && lastPart.endsWith('.md')) {
strippedPath.push(lastPart.slice(0, -3))
}

// Get service config to resolve account
const serviceConfig = await getConfig(hostname)
const originAccountId = serviceConfig?.registeredAccountUid

// Build the resource ID
let resourceId: ReturnType<typeof hmId> | null = null
const version = url.searchParams.get('v')
const latest = url.searchParams.get('l') === ''

if (strippedPath.length === 0) {
if (originAccountId) {
resourceId = hmId(originAccountId, {path: [], version, latest})
}
} else if (strippedPath[0] === 'hm') {
resourceId = hmId(strippedPath[1], {
path: strippedPath.slice(2),
version,
latest,
})
} else if (originAccountId) {
resourceId = hmId(originAccountId, {path: strippedPath, version, latest})
}

if (!resourceId) {
return new Response('# Not Found\n\nCould not resolve resource ID.', {
status: 404,
headers: {'Content-Type': 'text/markdown; charset=utf-8'},
})
}

// Fetch the resource
const resource = await resolveResource(resourceId)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this fn doing a discovery? I note that the .md pages are taking a really long time to resolve, and this whole thing should be nearly instant!


if (resource.type === 'document') {
const md = documentToMarkdown(resource.document, {
includeMetadata: true,
includeFrontmatter: url.searchParams.has('frontmatter'),
})

return new Response(md, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Hypermedia-Id': encodeURIComponent(resourceId.id),
'X-Hypermedia-Version': resource.document.version,
'X-Hypermedia-Type': 'Document',
'Cache-Control': 'public, max-age=60',
},
})
} else if (resource.type === 'comment') {
// For comments, create a simple markdown response
const content = resource.comment.content || []
const fakeDoc = {
content,
metadata: {},
version: resource.comment.version,
authors: [resource.comment.author],
} as any

const md = documentToMarkdown(fakeDoc, {includeMetadata: false})

return new Response(md, {
status: 200,
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
'X-Hypermedia-Id': encodeURIComponent(resourceId.id),
'X-Hypermedia-Type': 'Comment',
},
})
}

return new Response('# Not Found\n\nResource type not supported.', {
status: 404,
headers: {'Content-Type': 'text/markdown; charset=utf-8'},
})
} catch (e) {
console.error('Error handling markdown request:', e)
return new Response(
`# Error\n\nFailed to load resource: ${(e as Error).message}`,
{
status: 500,
headers: {'Content-Type': 'text/markdown; charset=utf-8'},
}
)
}
}

async function handleOptionsRequest(request: Request) {
const parsedRequest = parseRequest(request)
const {hostname} = parsedRequest
Expand Down Expand Up @@ -353,6 +463,12 @@ export default async function handleRequest(
status: 404,
})
}

// Handle .md extension requests - return raw markdown for bots/agents
if (url.pathname.endsWith('.md')) {
return await handleMarkdownRequest(parsedRequest, hostname)
}

if (url.pathname.startsWith('/hm/embed/')) {
// allowed to embed anywhere
} else {
Expand Down
256 changes: 256 additions & 0 deletions frontend/apps/web/app/markdown.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/**
* Server-side markdown converter for Seed Hypermedia documents
* Enables HTTP GET with .md extension to return raw markdown
*/

import type {BlockNode, Block, Annotation, HMDocument} from '@shm/shared/hm-types'

export type MarkdownOptions = {
includeMetadata?: boolean
includeFrontmatter?: boolean
}

/**
* Convert a document to markdown
*/
export function documentToMarkdown(
doc: HMDocument,
options?: MarkdownOptions
): string {
const lines: string[] = []

// Optional frontmatter
if (options?.includeFrontmatter && doc.metadata) {
lines.push('---')
if (doc.metadata.name) lines.push(`title: "${escapeYaml(doc.metadata.name)}"`)
if (doc.metadata.summary) lines.push(`summary: "${escapeYaml(doc.metadata.summary)}"`)
if (doc.authors?.length) lines.push(`authors: [${doc.authors.join(', ')}]`)
lines.push(`version: ${doc.version}`)
lines.push('---')
lines.push('')
}

// Title from metadata
if (options?.includeMetadata && doc.metadata?.name) {
lines.push(`# ${doc.metadata.name}`)
lines.push('')
}

// Content blocks
for (const node of doc.content || []) {
const blockMd = blockNodeToMarkdown(node, 0)
if (blockMd) {
lines.push(blockMd)
}
}

return lines.join('\n')
}

/**
* Convert a block node (with children) to markdown
*/
function blockNodeToMarkdown(
node: BlockNode,
depth: number
): string {
const block = node.block
const children = node.children || []

let result = blockToMarkdown(block, depth)

// Handle children based on childrenType
const childrenType = block.attributes?.childrenType as string | undefined

for (const child of children) {
const childMd = blockNodeToMarkdown(child, depth + 1)
if (childMd) {
if (childrenType === 'Ordered') {
result += '\n' + indent(depth + 1) + '1. ' + childMd.trim()
} else if (childrenType === 'Unordered') {
result += '\n' + indent(depth + 1) + '- ' + childMd.trim()
} else if (childrenType === 'Blockquote') {
result += '\n' + indent(depth + 1) + '> ' + childMd.trim()
} else {
result += '\n' + childMd
}
}
}

return result
}

/**
* Convert a single block to markdown
*/
function blockToMarkdown(
block: Block,
depth: number
): string {
const ind = indent(depth)

switch (block.type) {
case 'Paragraph':
return ind + applyAnnotations(block.text || '', block.annotations)

case 'Heading':
// Use depth to determine heading level (max h6)
const level = Math.min(depth + 1, 6)
const hashes = '#'.repeat(level)
return `${hashes} ${applyAnnotations(block.text || '', block.annotations)}`

case 'Code':
const lang = (block.attributes?.language as string) || ''
return ind + '```' + lang + '\n' + ind + (block.text || '') + '\n' + ind + '```'

case 'Math':
return ind + '$$\n' + ind + (block.text || '') + '\n' + ind + '$$'

case 'Image':
const altText = block.text || 'image'
const imgUrl = formatMediaUrl(block.link || '')
return ind + `![${altText}](${imgUrl})`

case 'Video':
const videoUrl = formatMediaUrl(block.link || '')
return ind + `[Video](${videoUrl})`

case 'File':
const fileName = (block.attributes?.name as string) || 'file'
const fileUrl = formatMediaUrl(block.link || '')
return ind + `[${fileName}](${fileUrl})`

case 'Embed':
return ind + `> [Embed: ${block.link}](${block.link})`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should actually do the embed here. So we should attempt to load the destination document, select the relevant content according to the blockRef and inject it directly into the markdown.


case 'WebEmbed':
return ind + `[Web Embed](${block.link})`

case 'Button':
const buttonText = block.text || 'Button'
return ind + `[${buttonText}](${block.link})`

case 'Query':
return ind + `<!-- Query block -->`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also resolve query blocks and generate a list of links. Without a real resolution here, this is not useful.


case 'Nostr':
return ind + `[Nostr: ${block.link}](${block.link})`

default:
if (block.text) {
return ind + block.text
}
return ''
}
}

/**
* Apply text annotations (bold, italic, links, etc.)
*/
function applyAnnotations(
text: string,
annotations: Annotation[] | undefined
): string {
if (!annotations || annotations.length === 0) {
return text
}

// Build a list of markers with positions
type Marker = {pos: number; type: 'open' | 'close'; annotation: Annotation}
const markers: Marker[] = []

for (const ann of annotations) {
const starts = ann.starts || []
const ends = ann.ends || []

for (let i = 0; i < starts.length; i++) {
markers.push({pos: starts[i], type: 'open', annotation: ann})
if (ends[i] !== undefined) {
markers.push({pos: ends[i], type: 'close', annotation: ann})
}
}
}

// Sort by position (opens before closes at same position)
markers.sort((a, b) => {
if (a.pos !== b.pos) return a.pos - b.pos
return a.type === 'open' ? -1 : 1
})

// Build result string
let result = ''
let lastPos = 0

for (const marker of markers) {
result += text.slice(lastPos, marker.pos)
lastPos = marker.pos
result += getAnnotationMarker(marker.annotation, marker.type)
}

result += text.slice(lastPos)

// Remove object replacement characters (used for inline embeds)
result = result.replace(/\uFFFC/g, '')

return result
}

/**
* Get markdown marker for annotation
*/
function getAnnotationMarker(
ann: Annotation,
type: 'open' | 'close'
): string {
switch (ann.type) {
case 'Bold':
return '**'
case 'Italic':
return '_'
case 'Strike':
return '~~'
case 'Code':
return '`'
case 'Underline':
return type === 'open' ? '<u>' : '</u>'
case 'Link':
if (type === 'open') {
return '['
} else {
return `](${ann.link || ''})`
}
case 'Embed':
if (type === 'open') {
return `[@`
} else {
return `](${ann.link || ''})`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

huh, I guess this means that mention names are not resolved? For example if somebody mentions me, I would expect my name to appear in the markdown.

see on this doc where I was mentioned: https://explore.hyper.media/hm/z6Mkj1exeQwkB36iENZw4rUdEJuHNMJEYF6MUYpDZyLrX68R?v=bafy2bzaceanuulpdkomr66decosvmveyx56l7bvbkzpj7vgoonhnzetc44pjs

but the markdown does not resolve my name: https://seed-gateway.exe.xyz/hm/z6Mkj1exeQwkB36iENZw4rUdEJuHNMJEYF6MUYpDZyLrX68R.md

}
default:
return ''
}
}

/**
* Format media URL (handle ipfs:// URLs)
*/
function formatMediaUrl(url: string): string {
if (url.startsWith('ipfs://')) {
const cid = url.slice(7)
return `https://ipfs.io/ipfs/${cid}`
}
return url
}

/**
* Create indentation string
*/
function indent(depth: number): string {
return ' '.repeat(depth)
}

/**
* Escape string for YAML frontmatter
*/
function escapeYaml(str: string): string {
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n')
}