Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b4af5b5
feat(engine): define independent stable bilingual URL, SEO and safe-l…
sanskarIN Nov 6, 2026
7e5367e
feat(engine): implement safe TypeScript Markdown renderer, YAML home …
sanskarIN Nov 6, 2026
2fdec70
feat(engine): build all 138 bilingual pages with Scarp-owned static l…
sanskarIN Nov 6, 2026
ace2e05
feat(engine): create independent responsive Scarp Docs design system …
sanskarIN Nov 6, 2026
5c5881f
feat(engine): implement framework-free TypeScript browser search, the…
sanskarIN Nov 6, 2026
6daaea6
build(engine): compile standalone browser TypeScript directly without…
sanskarIN Nov 6, 2026
fe41593
build(engine): add independent Scarp static-site commands and Markdow…
sanskarIN Nov 6, 2026
3d535f5
test(engine): independently verify 138 routes, safe Markdown, Unicode…
sanskarIN Nov 6, 2026
26b8ffc
ci(engine): require independent TypeScript renderer, page parity and …
sanskarIN Nov 6, 2026
2d9d5da
chore(engine): keep generated preview builds out of Git source
sanskarIN Nov 6, 2026
0a298a3
build(engine): surface exact TypeScript diagnostic instead of hiding …
sanskarIN Nov 6, 2026
afda46d
fix(engine): use supported TypeScript 6 ESNext module mode for import…
sanskarIN Nov 6, 2026
79a56d9
test(engine): exercise real Chromium bilingual search, CLI finder, th…
sanskarIN Nov 6, 2026
74ed244
ci(engine): test standalone English/Hindi site in Chromium and axe al…
sanskarIN Nov 6, 2026
1642094
fix(engine): handle Escape in native search inputs to close dialog an…
sanskarIN Nov 6, 2026
d2a5608
fix(engine): avoid duplicating author-provided H1 when rendering docu…
sanskarIN Nov 6, 2026
3972c06
fix(engine): index complete English/Hindi document bodies instead of …
sanskarIN Nov 6, 2026
406eb53
feat(engine): hide source-only compiler comments and make wide Markdo…
sanskarIN Nov 6, 2026
42b33ed
fix(engine): style focusable table scroll wrappers for narrow screens
sanskarIN Nov 6, 2026
da52b73
docs(engine): document standalone architecture, build commands and pr…
sanskarIN Nov 6, 2026
3e3d340
fix(engine): keep compiler provenance comments out of rendered search…
sanskarIN Nov 6, 2026
5dd571c
test(engine): reject nonfocusable Markdown table regression and valid…
sanskarIN Nov 6, 2026
65db3a8
docs(readme): show current Scarp-owned docs engine status without mis…
sanskarIN Nov 6, 2026
888ce5c
fix(engine): reject colon-containing source paths to match existing U…
sanskarIN Nov 6, 2026
2e8457d
test(engine): prevent URL-like source names from bypassing the old ca…
sanskarIN Nov 6, 2026
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
run: npm install --no-audit --no-fund
- name: Type-check TypeScript and Vue sources
run: npm run typecheck
- name: Build and validate framework-free Scarp documentation engine preview
run: npm run scarp:test
- name: Ensure documentation tooling uses TypeScript
run: npm run check:tooling
- name: Verify supported Vue/TypeScript toolchain and dependency-update policy
Expand Down Expand Up @@ -156,6 +158,8 @@ jobs:
run: npm run docs:build && npm run package:offline
- name: Check English/Hindi accessibility, CLI selector, offline search and mobile navigation in Chromium
run: npm run test:browser-offline
- name: Test Scarp-owned static engine in Chromium independently of VitePress
run: npm run scarp:test && npm run scarp:browser-test
- name: Upload accessibility audit evidence on failure
if: failure()
uses: actions/upload-artifact@v4
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Scarp Documentation


**Migration underway:** Scarp Docs is developing a framework-free TypeScript documentation engine. See [architecture and migration gates](SCARP-DOCS-ENGINE.md) and [Issue #70](https://github.com/scarp-lang/scarp-docs/issues/70). The independent build and browser checks are in CI; VitePress remains the current published-site generator until the migration gate passes. No stable release is implied.

Official VitePress documentation for the experimental [Scarp Programming Language](https://github.com/scarp-lang/scarp).

**Website (English):** https://scarp-lang.github.io/scarp-docs/
Expand Down
48 changes: 48 additions & 0 deletions SCARP-DOCS-ENGINE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Scarp Docs Engine — migration from VitePress

Scarp Docs is migrating to an independently authored **TypeScript static documentation system**, tracked in [Issue #70](https://github.com/scarp-lang/scarp-docs/issues/70). This changes the documentation website, not the Scarp compiler.

## Architecture

The new engine lives in scripts/scarp-site/ and loads no VitePress or Vue website runtime.

| Component | Role |
| --- | --- |
| routes.ts | Existing public URLs, English/Hindi pairs, safe internal links |
| markdown.ts | Markdown rendering, YAML frontmatter, Unicode heading anchors, safe code blocks, callouts and widgets |
| build.ts | Generates HTML, bilingual full-text search, reciprocal SEO tags, sitemap and local assets |
| site.css | Independent responsive typography, navigation, code/table scrolling, homepage and themes |
| client.ts | Independent TypeScript search, dark mode, menus, language switching, published-command selection and deployment history |
| test.ts | Static route, Unicode, SEO, markup, search and release-evidence tests |
| browser-test.ts | Real Chromium keyboard, mobile, search, Command Finder and axe WCAG A/AA tests |

The build uses focused Markdown (markdown-it) and YAML (yaml) parsers, not a website framework. The browser code is standalone TypeScript compiled with TypeScript itself, without a JavaScript UI framework or bundler. Release command data is temporarily reused from a pure TypeScript table stored in the old theme tree. It has no VitePress runtime dependency, but must move into a framework-neutral shared path before the old tree is removed.

## Commands

~~~sh
npm install --no-audit --no-fund
npm run scarp:build
npm run scarp:test
npx playwright install chromium
npm run scarp:browser-test
~~~

The engine's test build is generated under artifacts/scarp-engine-preview/scarp-docs/. Its assets use the existing /scarp-docs/ URL prefix; the browser test starts its own safe loopback HTTP server.

**Do not confuse the shadow build with production.** The official npm run docs:build and GitHub Pages workflow still use VitePress until the final migration gate has passed.

## Acceptance gates for replacing production

1. **138 pages**: preserve all 69 English and 69 Hindi source routes, local links, home features, heading anchors, code samples, callouts and both embedded interactive widgets.
2. **Search and releases**: use a full local index with no external provider; verify correct pinned compiler versions and do not invent unshipped commands.
3. **Accessible UI**: test keyboard, focus return, language switching, appearance persistence, 320px reflow, mobile navigation and code/table scrolling. Run axe WCAG A/AA on representative states. Automated tests are not manual screen-reader certification; see Issue #65.
4. **Deploy and offline**: change production build, archive generation, Pages upload, sitemap/live smoke and downloaded offline browser checks together. Confirm complete URL continuity.
5. **Remove VitePress**: only after actual production and offline verification, migrate pure release data out of the old theme, remove its Vue/VitePress files and dependencies, and update older conformance and toolchain tests without weakening them.
6. **Release policy**: no stable documentation version is created by this migration. Keep the existing prerelease release gates.

## Content security and provenance

The renderer escapes unreviewed raw Markdown HTML. It explicitly recognizes only the project's callouts and named widgets; future embedded content must be implemented and tested rather than bypassing HTML safety. The core language reference remains pinned to v0.0.5-alpha.1. The latest published compiler verified at migration start is v0.0.6-alpha.1, not the changing compiler main branch.

This phase provides an **independent tested engine**, not the final VitePress removal. Complete the public-site switch in a follow-up PR tracked by Issue #70 after parity is proven.
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
"docs:dev": "vitepress dev docs --host 127.0.0.1",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"scarp:build": "tsx scripts/scarp-site/build.ts",
"scarp:test": "tsx scripts/scarp-site/test.ts",
"scarp:browser-test": "tsx scripts/scarp-site/browser-test.ts",
"package:offline": "tsx scripts/package-offline.ts",
"test:offline": "tsx scripts/test-offline.ts",
"test:browser-offline": "tsx scripts/test-browser-offline.ts",
Expand Down Expand Up @@ -101,7 +104,10 @@
"tsx": "4.23.15",
"vue-tsc": "3.3.13",
"playwright": "1.64.1",
"axe-core": "4.14.0"
"axe-core": "4.14.0",
"markdown-it": "14.1.0",
"@types/markdown-it": "14.1.2",
"yaml": "2.8.1"
},
"engines": {
"node": ">=20.19.0"
Expand Down
181 changes: 181 additions & 0 deletions scripts/scarp-site/browser-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict'
import { createServer, type Server } from 'node:http'
import { readFile, stat } from 'node:fs/promises'
import { resolve, extname, sep, join } from 'node:path'
import { chromium, type BrowserContext, type Page } from 'playwright'
import { auditOfflineAccessibility } from '../lib/offline-a11y.ts'
import { previewRoot } from './build.ts'

const root=resolve(previewRoot,'scarp-docs')
const base='/scarp-docs/'
let assertions=0
const mime:Record<string,string>={
'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8',
'.css':'text/css; charset=utf-8','.json':'application/json',
'.xml':'application/xml; charset=utf-8','.svg':'image/svg+xml'
}
const server=createServer((req,res)=>{
const pathname=new URL(req.url??'/', 'http://127.0.0.1').pathname
if(!pathname.startsWith(base)||pathname.includes('\\')||
pathname.split('/').some(x=>x==='..')){
res.writeHead(404).end('Not found')
return
}
const tail=decodeURIComponent(pathname.slice(base.length))
const path=resolve(root,tail+(tail.endsWith('/')||!tail? 'index.html':
extname(tail)?'':'.html'))
if(path!==root&&!path.startsWith(root+sep)){
res.writeHead(404).end('Not found')
return
}
void (async()=>{
try{
if(!(await stat(path)).isFile())throw new Error('Not a file')
const data=await readFile(path)
res.writeHead(200,{
'Content-Type':mime[extname(path)]??'application/octet-stream',
'Content-Length':data.length,'Cache-Control':'no-store'
})
res.end(data)
}catch{res.writeHead(404).end('Not found')}
})()
})
const listen=():Promise<number>=>new Promise((yes,no)=>{
server.once('error',no)
server.listen(0,'127.0.0.1',()=>{
const address=server.address()
if(!address||typeof address==='string'){no(new Error('No local server port'));return}
yes(address.port)
})
})

async function context(browser:Awaited<ReturnType<typeof chromium.launch>>,baseUrl:string,
width:number):Promise<{ctx:BrowserContext;outside:string[]}>{
const ctx=await browser.newContext({viewport:{width,height:800},deviceScaleFactor:1})
const outside:string[]=[]
await ctx.route('**/*',route=>{
const requested=route.request().url()
if(requested.startsWith(baseUrl+'/'))return route.continue()
outside.push(requested)
return route.abort('blockedbyclient')
})
return {ctx,outside}
}

async function testLocale(
browser:Awaited<ReturnType<typeof chromium.launch>>,
origin:string,lang:'en'|'hi'
):Promise<void>{
const {ctx,outside}=await context(browser,origin,1280)
let page:Page|undefined
try{
page=await ctx.newPage()
const prefix=lang==='hi'?base+'hi/':base
const homepage=await page.goto(origin+prefix,{waitUntil:'networkidle'})
assert.equal(homepage?.status(),200)
assert.equal(await page.locator('html').getAttribute('lang'),lang==='hi'?'hi-IN':'en-US')
assert.equal(await page.locator('main').count(),1)
assert.equal(await page.locator('.features .feature').count()>8,true)
assert.equal(await page.locator('.hero h1').count(),1)
assert.equal(await page.locator('link[rel="canonical"]').getAttribute('href'),
'https://scarp-lang.github.io'+prefix)
assertions++

await page.goto(origin+prefix+'tooling/command-finder',{waitUntil:'networkidle'})
const widget=page.locator('[data-command-finder]')
await widget.locator('#scarp-release').waitFor({state:'visible'})
await widget.locator('#scarp-task').selectOption('inspect-symbols')
await widget.locator('.finder-result pre').waitFor({state:'visible'})
assert.match(await widget.locator('pre').innerText(),
/scarp inspect symbols --json FILE\.srp/)
assert.equal(await widget.locator('pre').getAttribute('tabindex'),'0')
await widget.locator('#scarp-release').selectOption('v0.0.5-alpha.1')
assert.equal(await widget.locator('pre').count(),0)
assert.match(await widget.locator('.finder-result').innerText(),/v0\.0\.6-alpha\.1/)
await widget.locator('#scarp-release').selectOption('v0.0.6-alpha.1')
await widget.locator('pre').waitFor({state:'visible'})
assertions++

const theme=page.locator('#theme-toggle')
const before=await page.locator('html').evaluate(node=>node.classList.contains('dark'))
await theme.click()
assert.equal(await page.locator('html').evaluate(node=>node.classList.contains('dark')),!before)
await page.reload({waitUntil:'networkidle'})
assert.equal(await page.locator('html').evaluate(node=>node.classList.contains('dark')),!before)
assertions++

const searchTrigger=page.locator('#search-open')
await searchTrigger.focus()
await searchTrigger.press('Enter')
const dialog=page.locator('#search-panel')
await dialog.waitFor({state:'visible'})
assert.equal(await dialog.getAttribute('aria-labelledby'),'search-title')
const searchInput=page.locator('#search-query')
assert.equal(await searchInput.evaluate(node=>document.activeElement===node),true)
await searchInput.fill('funcra')
await page.locator('#search-results li a').first().waitFor({state:'visible',timeout:15000})
assert.ok((await page.locator('#search-results li a').first().getAttribute('href'))?.startsWith(prefix))
await searchInput.fill('zzqvkpwmntrxhfbjkldsrp9776')
await page.getByRole('status').getByText(
lang==='hi'?'कोई परिणाम नहीं मिला':'No results found',{exact:true}).waitFor()
await page.keyboard.press('Escape')
await dialog.waitFor({state:'hidden'})
await page.waitForFunction(()=>document.activeElement===document.querySelector('#search-open'))
assertions++

const translated=page.locator('a.language-link')
assert.equal(await translated.getAttribute('href'),
lang==='en'?base+'hi/tooling/command-finder':base+'tooling/command-finder')
const audit=await auditOfflineAccessibility(page!,lang+'-engine-page',
join(previewRoot,'audit-results'))
assert.ok(audit.passes>0)
assertions++
assert.deepEqual(outside,[],lang+': custom site attempted external requests')
}finally{await ctx.close()}
}

async function mobile(
browser:Awaited<ReturnType<typeof chromium.launch>>,origin:string,lang:'en'|'hi'
):Promise<void>{
const {ctx,outside}=await context(browser,origin,375)
try{
const page=await ctx.newPage()
const route=lang==='hi'?base+'hi/guide/learning-path':base+'guide/learning-path'
await page.goto(origin+route,{waitUntil:'networkidle'})
const overflow=await page.evaluate(()=>document.documentElement.scrollWidth-window.innerWidth)
assert.ok(overflow<=2,lang+': narrow layout overflow '+overflow)
const toggle=page.locator('#menu-toggle')
await toggle.click()
assert.equal(await toggle.getAttribute('aria-expanded'),'true')
assert.equal(await page.locator('#side-nav').evaluate(x=>x.classList.contains('open')),true)
await page.locator('#side-nav a').first().click()
assert.equal(await toggle.getAttribute('aria-expanded'),'false')
await page.keyboard.press('Control+k')
await page.locator('#search-query').waitFor({state:'visible'})
await page.keyboard.press('Escape')
assert.deepEqual(outside,[])
const audit=await auditOfflineAccessibility(page,lang+'-engine-mobile',
join(previewRoot,'audit-results'))
assert.ok(audit.passes>0)
assertions++
}finally{await ctx.close()}
}

let browser:Awaited<ReturnType<typeof chromium.launch>>|undefined
try{
const port=await listen()
const origin='http://127.0.0.1:'+port
browser=await chromium.launch({headless:true})
for(const locale of ['en','hi'] as const){
await testLocale(browser,origin,locale)
await mobile(browser,origin,locale)
}
console.log('PASS: '+assertions+
' Scarp-owned engine Chromium tests: translated routes, local search, theme, CLI, 375px navigation and axe WCAG A/AA scans')
}catch(error){
console.error('FAIL: Scarp-owned engine Chromium: '+String(error))
process.exitCode=1
}finally{
await browser?.close()
await new Promise<void>(resolveDone=>server.close(()=>resolveDone()))
}
Loading
Loading