diff --git a/README.md b/README.md index 9258fc03..a6cdd5fb 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,11 @@ Markover is an **Early macOS preview** for reviewing Markdown as a document tree and returning block-level feedback to an agent. -
-
-
-
+
Give Markover one Markdown path and a short summary of why the document exists and what feedback would help:
+markover is an unrelated Markdown-cleanup tool.npx --yes \
--package=https://github.com/lastobelus/markover/releases/latest/download/markover-cli.tgz \
markover open ./DOCUMENT.md \
diff --git a/docs/user/assets/markover-github-social-preview.png b/docs/user/assets/markover-github-social-preview.png
new file mode 100644
index 00000000..c25f87d1
Binary files /dev/null and b/docs/user/assets/markover-github-social-preview.png differ
diff --git a/docs/user/assets/markover-pages-social-card.png b/docs/user/assets/markover-pages-social-card.png
new file mode 100644
index 00000000..d4a8bb43
Binary files /dev/null and b/docs/user/assets/markover-pages-social-card.png differ
diff --git a/docs/user/guide/index.html b/docs/user/guide/index.html
index 3aa93eea..8c689dd0 100644
--- a/docs/user/guide/index.html
+++ b/docs/user/guide/index.html
@@ -5,6 +5,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Getting started — Markover documentation
diff --git a/docs/user/index.html b/docs/user/index.html
index 5190a9d2..1ab09c28 100644
--- a/docs/user/index.html
+++ b/docs/user/index.html
@@ -5,6 +5,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Markover — Early macOS preview
diff --git a/docs/user/limitations/index.html b/docs/user/limitations/index.html
index 45c33391..356a7cec 100644
--- a/docs/user/limitations/index.html
+++ b/docs/user/limitations/index.html
@@ -5,6 +5,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Markdown support and preview limitations — Markover documentation
diff --git a/docs/user/privacy/index.html b/docs/user/privacy/index.html
index 0fd95bdc..e5fa9389 100644
--- a/docs/user/privacy/index.html
+++ b/docs/user/privacy/index.html
@@ -5,6 +5,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Privacy, storage, and recovery — Markover documentation
diff --git a/package.json b/package.json
index 1c88e8a9..b5cc0f25 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"scripts": {
"build": "npm run clean && tsc -p tsconfig.build.json && node build/scripts/copy-build-assets.js && node build/scripts/build-app.js",
"build:icon:mac": "npm run build --silent && node build/scripts/build-macos-icon.js",
+ "build:social-cards": "npm run build --silent && node build/scripts/build-social-cards.js",
"build:cli": "npm run build --silent && node build/scripts/build-cli.js",
"clean": "node -e \"require('node:fs').rmSync('build', { recursive: true, force: true })\"",
"package:mac": "install-electron --no && npm run build:icon:mac && npm run notices:check:built && node build/scripts/package-macos.js --trust-mode=ad-hoc",
diff --git a/scripts/build-social-cards.ts b/scripts/build-social-cards.ts
new file mode 100644
index 00000000..7cb391aa
--- /dev/null
+++ b/scripts/build-social-cards.ts
@@ -0,0 +1,119 @@
+#!/usr/bin/env node
+
+import { spawnSync } from 'node:child_process'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+
+const projectDirectory = path.resolve(__dirname, '../..')
+
+interface SocialCard {
+ source: string
+ output: string
+ width: number
+ height: number
+ maximumBytes?: number
+}
+
+export const cards: readonly SocialCard[] = [
+ {
+ source: 'doc/launch/issue-16/github-social-preview.svg',
+ output: 'docs/user/assets/markover-github-social-preview.png',
+ width: 1280,
+ height: 640,
+ maximumBytes: 1_000_000
+ },
+ {
+ source: 'doc/launch/issue-16/pages-social-card.svg',
+ output: 'docs/user/assets/markover-pages-social-card.png',
+ width: 1200,
+ height: 630
+ }
+]
+
+export interface ImageDimensions {
+ width: number
+ height: number
+}
+
+export function dimensions(bytes: Buffer): ImageDimensions {
+ if (bytes.subarray(1, 4).toString() !== 'PNG') {
+ throw new Error('Expected a PNG export')
+ }
+ return {
+ width: bytes.readUInt32BE(16),
+ height: bytes.readUInt32BE(20)
+ }
+}
+
+export function inlineCanonicalAssets(source: string): string {
+ const lockup = fs.readFileSync(
+ path.join(projectDirectory, 'design/brand/markover-lockup.svg')
+ )
+ return source.replace(
+ '../../../design/brand/markover-lockup.svg',
+ `data:image/svg+xml;base64,${lockup.toString('base64')}`
+ )
+}
+
+function render(card: SocialCard, temporaryDirectory: string): void {
+ const sourcePath = path.join(projectDirectory, card.source)
+ const outputPath = path.join(projectDirectory, card.output)
+ const temporarySource = path.join(
+ temporaryDirectory,
+ path.basename(card.source)
+ )
+ fs.writeFileSync(
+ temporarySource,
+ inlineCanonicalAssets(fs.readFileSync(sourcePath, 'utf8'))
+ )
+ const result = spawnSync('/usr/bin/sips', [
+ '-s',
+ 'format',
+ 'png',
+ temporarySource,
+ '--out',
+ outputPath
+ ], { encoding: 'utf8' })
+ if (result.error) throw result.error
+ if (result.status !== 0) {
+ throw new Error(
+ result.stderr.trim() || `sips exited ${String(result.status)}`
+ )
+ }
+
+ const bytes = fs.readFileSync(outputPath)
+ const size = dimensions(bytes)
+ if (size.width !== card.width || size.height !== card.height) {
+ throw new Error(
+ `${card.output} is ${size.width}×${size.height}; expected ${card.width}×${card.height}`
+ )
+ }
+ if (card.maximumBytes !== undefined && bytes.length >= card.maximumBytes) {
+ throw new Error(`${card.output} must remain under ${card.maximumBytes} bytes`)
+ }
+}
+
+export function main(): void {
+ if (process.platform !== 'darwin') {
+ throw new Error('Building Markover social cards requires macOS.')
+ }
+ const temporaryDirectory = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'markover-social-cards-')
+ )
+ try {
+ for (const card of cards) render(card, temporaryDirectory)
+ } finally {
+ fs.rmSync(temporaryDirectory, { recursive: true, force: true })
+ }
+}
+
+if (require.main === module) {
+ try {
+ main()
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ process.stderr.write(`markover social cards: ${message}\n`)
+ process.exit(1)
+ }
+}
diff --git a/test/launch-assets.test.ts b/test/launch-assets.test.ts
new file mode 100644
index 00000000..e41731d9
--- /dev/null
+++ b/test/launch-assets.test.ts
@@ -0,0 +1,228 @@
+import assert from 'node:assert/strict'
+import crypto from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import test from 'node:test'
+
+const root = path.resolve(__dirname, '../..')
+const read = (relativePath: string): string =>
+ fs.readFileSync(path.join(root, relativePath), 'utf8')
+const bytes = (relativePath: string): Buffer =>
+ fs.readFileSync(path.join(root, relativePath))
+const sha256 = (contents: Buffer): string =>
+ crypto.createHash('sha256').update(contents).digest('hex')
+
+interface LaunchAsset {
+ source: string
+ sourceSha256: string
+ output: string
+ outputSha256: string
+ width: number
+ height: number
+ maximumBytes?: number
+}
+
+interface LaunchManifest {
+ issue: number
+ phase: string
+ canonicalDescriptor: string
+ repository: {
+ description: string
+ homepage: string
+ topics: string[]
+ socialPreview: string
+ }
+ profilePins: string[]
+ release: {
+ currentPublicTag: string
+ focusedPreviewCandidateTag: string
+ focusedPreviewTag: string | null
+ platform: string
+ blockedByIssues: number[]
+ }
+ announcement: {
+ community: string
+ title: string
+ }
+ assets: {
+ canonicalLockup: { path: string; sha256: string }
+ githubSocialPreview: LaunchAsset
+ pagesSocialCard: LaunchAsset
+ canonicalProductImage: { path: string; sha256: string }
+ }
+}
+
+interface PackageManifest {
+ scripts: Record
+}
+
+const manifest = JSON.parse(
+ read('doc/launch/issue-16/launch-manifest.json')
+) as LaunchManifest
+
+function pngDimensions(contents: Buffer): { width: number; height: number } {
+ assert.equal(contents.subarray(1, 4).toString(), 'PNG')
+ return {
+ width: contents.readUInt32BE(16),
+ height: contents.readUInt32BE(20)
+ }
+}
+
+test('launch manifest preserves the agreed repository metadata', () => {
+ assert.equal(manifest.issue, 16)
+ assert.equal(manifest.phase, 'focused-preview')
+ assert.equal(manifest.canonicalDescriptor, 'Structured review for Markdown.')
+ assert.deepEqual(manifest.repository, {
+ description: 'Local-first macOS app for reviewing agent-produced Markdown and returning structured, block-level feedback to the agent thread.',
+ homepage: 'https://lastobelus.github.io/markover/',
+ topics: [
+ 'markdown',
+ 'document-review',
+ 'annotations',
+ 'ai-agents',
+ 'coding-agents',
+ 'codex',
+ 'local-first',
+ 'macos',
+ 'electron',
+ 'developer-tools'
+ ],
+ socialPreview: 'docs/user/assets/markover-github-social-preview.png'
+ })
+ assert.deepEqual(manifest.profilePins, [
+ 'lastobelus/markover',
+ 'lastobelus/lastCode'
+ ])
+ assert.equal(manifest.release.currentPublicTag, 'v0.1.3')
+ assert.equal(manifest.release.focusedPreviewCandidateTag, 'v0.1.3')
+ assert.equal(manifest.release.focusedPreviewTag, null)
+ assert.equal(
+ manifest.release.platform,
+ 'macOS 14 Sonoma or newer on Apple Silicon'
+ )
+ assert.deepEqual(manifest.release.blockedByIssues, [93, 11, 10])
+ assert.equal(manifest.announcement.community, 'r/codex')
+ assert.equal(
+ manifest.announcement.title,
+ 'I built a little Electron app for reviewing agent-written Markdown block by block'
+ )
+})
+
+test('social-card sources and outputs match the launch manifest', () => {
+ const canonicalLockup = manifest.assets.canonicalLockup
+ assert.equal(sha256(bytes(canonicalLockup.path)), canonicalLockup.sha256)
+
+ const cards = [
+ manifest.assets.githubSocialPreview,
+ manifest.assets.pagesSocialCard
+ ]
+ for (const asset of cards) {
+ const source = bytes(asset.source)
+ const output = bytes(asset.output)
+ assert.equal(sha256(source), asset.sourceSha256)
+ assert.equal(sha256(output), asset.outputSha256)
+ assert.deepEqual(pngDimensions(output), {
+ width: asset.width,
+ height: asset.height
+ })
+ if (asset.maximumBytes !== undefined) {
+ assert.ok(output.length < asset.maximumBytes)
+ }
+
+ const svg = source.toString('utf8')
+ assert.match(svg, new RegExp(`width="${asset.width}" height="${asset.height}"`))
+ assert.match(svg, /design\/brand\/markover-lockup\.svg/)
+ assert.match(svg, /Structured review for Markdown\./)
+ assert.doesNotMatch(svg, /gradient/i)
+ }
+
+ const productImage = manifest.assets.canonicalProductImage
+ assert.equal(sha256(bytes(productImage.path)), productImage.sha256)
+})
+
+test('every Pages surface publishes complete large-card metadata', () => {
+ const pages = [
+ 'docs/user/index.html',
+ 'docs/user/guide/index.html',
+ 'docs/user/agents/index.html',
+ 'docs/user/privacy/index.html',
+ 'docs/user/limitations/index.html'
+ ]
+ for (const page of pages) {
+ const html = read(page)
+ assert.match(html, /rel="canonical"/)
+ assert.match(html, /property="og:type" content="website"/)
+ assert.match(html, /property="og:site_name" content="Markover"/)
+ assert.match(html, /property="og:title"/)
+ assert.match(html, /property="og:description"/)
+ assert.match(html, /property="og:url"/)
+ assert.match(
+ html,
+ /property="og:image" content="https:\/\/lastobelus\.github\.io\/markover\/assets\/markover-pages-social-card\.png"/
+ )
+ assert.match(html, /property="og:image:width" content="1200"/)
+ assert.match(html, /property="og:image:height" content="630"/)
+ assert.match(html, /name="twitter:card" content="summary_large_image"/)
+ assert.match(html, /name="twitter:image:alt"/)
+ }
+})
+
+test('public setup surfaces preserve the repository launcher and preview boundary', () => {
+ const readme = read('README.md')
+ const agentGuide = read('docs/user/agents/index.html')
+ const userGuide = read('docs/user/guide/index.html')
+ const launcherUrl = /https:\/\/github\.com\/lastobelus\/markover\/releases\/latest\/download\/markover-cli\.tgz/
+
+ for (const source of [readme, agentGuide]) {
+ assert.match(source, launcherUrl)
+ assert.match(
+ source,
+ /public npm package named(?:\s|>)*(?:)?`?markover/
+ )
+ }
+ for (const source of [readme, userGuide]) {
+ assert.match(source, /Node(?:\.js)? 22\.13\.0 or newer/)
+ }
+
+ assert.equal((readme.match(/markover-review-editor@2x\.png/g) ?? []).length, 1)
+ assert.doesNotMatch(readme, /markover-annotation-browser@2x\.png/)
+ assert.match(readme, /free and MIT-licensed/)
+ assert.match(
+ readme,
+ /ordinary(?:\s|>)+review data stays in your macOS account/
+ )
+ assert.match(readme, /macOS 14 Sonoma or newer on Apple Silicon/)
+ assert.match(agentGuide, /Check Markover/)
+})
+
+test('prepared announcement and demo retain their finalization gates', () => {
+ const reddit = read('doc/launch/issue-16/reddit-draft.md')
+ const storyboard = read('doc/launch/issue-16/demo-storyboard.md')
+ const filter = read('doc/launch/issue-16/handoff-summary.jq')
+
+ assert.match(reddit, /\[DEMO_URL\]/)
+ assert.match(reddit, /\[PREVIEW_TAG\]/)
+ assert.match(reddit, /first step that is confusing or broken/)
+ assert.match(reddit, /Apple Silicon Macs running\s+macOS 14 Sonoma or newer/)
+ assert.doesNotMatch(reddit, /stars|upvotes/i)
+ assert.match(storyboard, /Check Markover/)
+ assert.match(storyboard, /current landing page/)
+ assert.match(storyboard, /Codex and T3 Code/)
+ assert.match(storyboard, /issues 93, 11, and 10/)
+ assert.match(filter, /review: \{ status: \.review\.status \}/)
+ assert.match(filter, /attachments/)
+ assert.match(filter, /sourceEdit/)
+})
+
+test('social-card build follows the macOS-native TypeScript pipeline', () => {
+ const script = read('scripts/build-social-cards.ts')
+ const packageJson = JSON.parse(read('package.json')) as PackageManifest
+ assert.equal(
+ packageJson.scripts['build:social-cards'],
+ 'npm run build --silent && node build/scripts/build-social-cards.js'
+ )
+ assert.match(script, /\/usr\/bin\/sips/)
+ assert.match(script, /markover-lockup\.svg/)
+ assert.match(script, /docs\/user\/assets/)
+ assert.doesNotMatch(script, /sharp|canvas|playwright/i)
+})