Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@ tokio = {version = "1", features = ["full"]}
tower-http = {version = "0.7", features = ["cors"]}
tracing = "0.1"
tracing-subscriber = {version = "0.3", features = ["env-filter", "fmt"]}
globset = "0.4.20"

52 changes: 45 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,15 @@ mq-serve docs/ -p 8080

# Bind to all interfaces (e.g. inside Docker) — requires an explicit opt-in,
# since mq-serve has no authentication and anyone reaching the address can read your files
mq-serve docs/ --bind 0.0.0.0 --dangerously-allow-remote-access
mq-serve docs/ --bind 0.0.0.0 --dangerously-allow-remote

# Glob patterns (quote them so the shell does not expand them first).
# New files that match the pattern are picked up automatically.
mq-serve "docs/**/*.md"
mq-serve "notes/*.md" -t "Notes"

# --watch / -w does the same as passing the pattern as an argument; handy for scripts
mq-serve --watch "docs/**/*.md"

# Run in the foreground (e.g. in a container or for debugging)
mq-serve docs/ --foreground
Expand Down Expand Up @@ -118,15 +126,16 @@ mq-serve --stop-all # stop every mq-serve server currently running
mq-serve --restart # restart the server (session is preserved)
mq-serve --clear # clear the saved session (restarts server if running)
mq-serve --close old-draft.md # remove a file/directory from the running session
mq-serve --unwatch docs/ # alias for --close, for directories you no longer want watched
mq-serve --unwatch docs/ # alias for --close, for directories or patterns you no longer want watched
mq-serve --close "docs/**/*.md" # remove a glob pattern (pass it exactly as you added it)
mq-serve --restart -p 7701 # restart/clear/close on a specific port
```

## Options

```
Arguments:
[FILES_OR_DIRS]... Markdown files or directories to serve. Defaults to the current directory
[FILES_OR_DIRS]... Markdown files, directories or glob patterns to serve. Defaults to the current directory

Options:
-t, --target <NAME>
Expand All @@ -135,14 +144,16 @@ Options:
Port to listen on [default: 7700]
-b, --bind <BIND>
Address to bind to [default: 127.0.0.1]
--dangerously-allow-remote-access
--dangerously-allow-remote
Required together with a non-loopback --bind to confirm the server should be reachable from the network (it has no authentication)
--no-open
Do not automatically open the browser
--open
Always open the browser, even when adding files to an already-running server
--no-watch
Disable file-change watching
-w, --watch <PATTERN>...
Add one or more files, directories or glob patterns to watch. Same as passing them as positional arguments, but unambiguous for quoted patterns such as --watch "docs/**/*.md"
-f, --foreground
Run in the foreground instead of the background (default is background)
--stop
Expand All @@ -167,17 +178,44 @@ Options:
Print version
```

## Querying from the CLI

`-q/--query` runs an mq query without starting a server and prints the result to stdout. It accepts the same files, directories and glob patterns as the server, or piped stdin.

```bash
mq-serve -q '.h' docs/ # headings of every file under docs/
mq-serve -q '.code' "docs/**/*.md" --json # JSON: [{ "path", "name", "result" }, ...]
cat notes.md | mq-serve -q '.h1'
```

Files for which the query produces no output are skipped. The exit status is 1 if the query is invalid.

The running server exposes the same thing over HTTP:

| Endpoint | Body | Effect |
| -------- | ---- | ------ |
| `POST /api/query` | `{ "path": "...", "query": "..." }` or `{ "content": "...", "query": "..." }` | Query one served file or raw Markdown |
| `POST /api/query-all` | `{ "query": "..." }` | Query every served file |

## mq Query Examples

| Query | Effect |
| ---------------- | ----------------------------------------- |
| `.h` | Extract all headings |
| `.code` | Extract all code blocks |
| `.p` | Extract all paragraphs |
| `.link` | Extract all links |
| `.list` | Extract all lists |
| `.h \| upcase()` | Extract headings and convert to uppercase |

Enter a query in the bar at the top of the page and press Enter.
Click **Clear** to reset to the original content.
Type a query in the bar at the top of the page; it runs as you type.
Click **×** to reset to the original content.

The buttons next to the query box:

- **★ Save**: keep the current query in your saved list
- **History / presets menu**: pick from saved queries, recent queries, or built-in presets (headings, code blocks, links, unchecked tasks, ...)
- **Layers (all files)**: run the query against every served file and show the matches grouped by file, instead of only the open file
- **Link**: copy a URL that reopens the viewer with the same query, e.g. `http://localhost:7700/?q=.code&all=1`. The address bar is kept in sync, so reloading keeps your query too.

## Development

Expand Down
117 changes: 93 additions & 24 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { FileGroup, SearchResult, Session } from './types'
import type { FileGroup, MultiQueryResponse, SearchResult, Session } from './types'
import { loadSession, saveSession } from './store/session'
import { useWebSocket } from './hooks/useWebSocket'
import { useDropzone } from './hooks/useDropzone'
import { renderMarkdown, type ParseResult } from './lib/markdown'
import { preprocessMdx } from './lib/mdx'
import { isTrivial, pushHistory, readUrlQuery, syncUrl, toggleSaved } from './lib/queries'
import { getTheme, applyTheme } from './lib/themes'
import QueryBar from './components/QueryBar'
import Toolbar from './components/Toolbar'
Expand All @@ -13,6 +14,19 @@ import Preview from './components/Preview'

interface InMemoryFile { name: string; content: string }

/** Joins per-file results into one document, titling each section with its path. */
function combineResults(results: { path: string; result: string }[]): string {
if (results.length === 0) return '*No matches in any file.*'
const dirs = results.map((r) => r.path.slice(0, r.path.lastIndexOf('/') + 1))
let prefix = dirs[0]
for (const d of dirs) {
while (!d.startsWith(prefix)) prefix = prefix.slice(0, prefix.lastIndexOf('/', prefix.length - 2) + 1)
}
return results
.map((r) => `## ${r.path.slice(prefix.length)}\n\n${r.result.trimEnd()}`)
.join('\n\n---\n\n')
}

function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
Expand All @@ -23,14 +37,15 @@ function useDebounce<T>(value: T, delay: number): T {
}

export default function App() {
const [session, setSession] = useState<Session>(loadSession)
const [session, setSession] = useState<Session>(() => ({ ...loadSession(), ...readUrlQuery() }))
const [groups, setGroups] = useState<FileGroup[]>([])
const [rawContent, setRawContent] = useState('')
const [queryError, setQueryError] = useState('')
const [parseResult, setParseResult] = useState<ParseResult | null>(null)
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [inMemoryFiles, setInMemoryFiles] = useState<InMemoryFile[]>([])
const [isLoading, setIsLoading] = useState(false)
const [changeTick, setChangeTick] = useState(0)
const rawContentRef = useRef(rawContent)
useEffect(() => { rawContentRef.current = rawContent }, [rawContent])
const [systemPrefersDark, setSystemPrefersDark] = useState(
Expand Down Expand Up @@ -156,22 +171,70 @@ export default function App() {
loadFile(path)
}, [updateSession, loadFile])

const recordHistory = useCallback((query: string) => {
setSession((prev) => {
const queryHistory = pushHistory(prev.queryHistory, query)
if (queryHistory === prev.queryHistory) return prev
const next = { ...prev, queryHistory }
saveSession(next)
return next
})
}, [])

// Keep the address bar in step with the query so the page can be shared.
useEffect(() => {
syncUrl(debouncedQuery, session.queryAll)
}, [debouncedQuery, session.queryAll])

// Run mq query when content or query changes, then render markdown in one step
useEffect(() => {
let cancelled = false
const trivial = isTrivial(debouncedQuery) || debouncedQuery.trim() === '.'

const show = (markdown: string) =>
renderMarkdown(markdown, themePreset.mode).then((parsed) => {
if (cancelled) return
setParseResult(parsed)
setIsLoading(false)
})

const fail = (message: string) => {
if (cancelled) return
setQueryError(message)
setIsLoading(false)
}

// Query across every served file
if (session.queryAll && !trivial) {
setIsLoading(true)
fetch('/api/query-all', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: debouncedQuery }),
})
.then((r) => r.json())
.then((data: MultiQueryResponse) => {
if (cancelled) return
if (data.error) return fail(data.error)
setQueryError('')
recordHistory(debouncedQuery)
return show(combineResults(data.results))
})
.catch(() => fail('Network error'))
return () => { cancelled = true }
}

if (!rawContent) {
setIsLoading(false)
return
}
const isMdx = session.currentPath?.endsWith('.mdx') ?? false
const content = isMdx ? preprocessMdx(rawContent) : rawContent

if (debouncedQuery.trim() === '.' || debouncedQuery.trim() === '') {
if (trivial) {
setQueryError('')
renderMarkdown(content, themePreset.mode).then((result) => {
setParseResult(result)
setIsLoading(false)
}).catch(console.error)
return
show(content).catch(console.error)
return () => { cancelled = true }
}

fetch('/api/query', {
Expand All @@ -181,29 +244,27 @@ export default function App() {
})
.then((r) => r.json())
.then((data: { result?: string; error?: string }) => {
if (data.error) {
setQueryError(data.error)
setIsLoading(false)
} else {
const result = data.result ?? ''
setQueryError('')
return renderMarkdown(result, themePreset.mode).then((parsed) => {
setParseResult(parsed)
setIsLoading(false)
})
}
if (cancelled) return
if (data.error) return fail(data.error)
setQueryError('')
recordHistory(debouncedQuery)
return show(data.result ?? '')
})
.catch(() => {
setQueryError('Network error')
setIsLoading(false)
})
}, [rawContent, debouncedQuery, session.currentPath, themePreset.mode])
.catch(() => fail('Network error'))
return () => { cancelled = true }
}, [
rawContent, debouncedQuery, session.currentPath, session.queryAll,
// Re-run the cross-file query whenever any file changes.
session.queryAll ? changeTick : 0,
themePreset.mode, recordHistory,
])

// WebSocket live reload
const wsStatus = useWebSocket(
useCallback(
(data: unknown) => {
const msg = data as { type: string; path?: string }
setChangeTick((t) => t + 1)
if (msg.type === 'change') {
fetchGroups()
if (msg.path && msg.path === session.currentPath) {
Expand Down Expand Up @@ -273,6 +334,14 @@ export default function App() {
onClear={() => updateSession({ query: '.' })}
wsStatus={wsStatus}
queryError={queryError}
queryAll={session.queryAll}
onQueryAllChange={(v) => updateSession({ queryAll: v })}
history={session.queryHistory}
saved={session.savedQueries}
onToggleSaved={() =>
updateSession({ savedQueries: toggleSaved(session.savedQueries, session.query) })
}
onClearHistory={() => updateSession({ queryHistory: [] })}
/>
<Toolbar
theme={session.theme}
Expand Down
Loading
Loading