diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7104f14..9c6b9a81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index e89f6b30..d30fba2d 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/SCARP-DOCS-ENGINE.md b/SCARP-DOCS-ENGINE.md new file mode 100644 index 00000000..61c83d5b --- /dev/null +++ b/SCARP-DOCS-ENGINE.md @@ -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. diff --git a/package.json b/package.json index dfe11f9b..8aad55c7 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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" diff --git a/scripts/scarp-site/browser-test.ts b/scripts/scarp-site/browser-test.ts new file mode 100644 index 00000000..1d36a1f1 --- /dev/null +++ b/scripts/scarp-site/browser-test.ts @@ -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={ + '.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=>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>,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>, + origin:string,lang:'en'|'hi' +):Promise{ + 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>,origin:string,lang:'en'|'hi' +):Promise{ + 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>|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(resolveDone=>server.close(()=>resolveDone())) +} diff --git a/scripts/scarp-site/build.ts b/scripts/scarp-site/build.ts new file mode 100644 index 00000000..a3a1fc6f --- /dev/null +++ b/scripts/scarp-site/build.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import ts from 'typescript' +import { documentationPages } from '../lib/docs-pages.ts' +import { commandAdvice, FINDER_TEXT, RELEASES, TASKS } from '../../docs/.vitepress/theme/lib/command-finder.ts' +import { escapeHtml, localLink, htmlPath, pairedRoutes, BASE } from './routes.ts' +import { renderMarkdown, type RenderedPage, type HomeAction, type HomeFeature } from './markdown.ts' + +const root=resolve(fileURLToPath(new URL('../..',import.meta.url))) +const docsRoot=join(root,'docs') +export const previewRoot=join(root,'artifacts','scarp-engine-preview') +const siteRoot=join(previewRoot,'scarp-docs') +const styles=join(root,'scripts/scarp-site/site.css') +const client=join(root,'scripts/scarp-site/client.ts') +const groupOrder=['guide','tutorials','language','tooling','reference','examples','releases','community','project'] +const groupLabels:Record={ + guide:['Getting started','शुरुआत'], + tutorials:['Verified tutorials','सत्यापित ट्यूटोरियल'], + language:['Language','भाषा'], + tooling:['Developer tools','डेवलपर टूल्स'], + reference:['Reference','संदर्भ'], + examples:['Examples','उदाहरण'], + releases:['Published releases','प्रकाशित रिलीज़'], + community:['Community','समुदाय'], + project:['Maintainers','अनुरक्षक'] +} +const logo=''+ + ''+ + '' + +interface IndexEntry {route:string;title:string;locale:'en'|'hi';section:string;excerpt:string} +interface PageView {source:string;page:RenderedPage;route:string;locale:'en'|'hi'} + +function htmlAttr(text:string):string{return escapeHtml(text)} +function attrs(href:string, cls?:string):string { + return 'href="'+htmlAttr(href)+'"'+(cls?' class="'+htmlAttr(cls)+'"':'') +} +function localized(en:string,hi:string,locale:'en'|'hi'):string{return locale==='hi'?hi:en} +function groupFor(source:string):string { + const path=source.startsWith('hi/')?source.slice(3):source + return path.split('/')[0]==='index.md'?'guide':path.split('/')[0] +} +function homeActions(items: HomeAction[]|undefined, source:string):string { + return (items??[]).map(action=>''+ + escapeHtml(action.text)+'').join('') +} +function homeFeatures(items:HomeFeature[]|undefined,source:string):string{ + return '
'+(items??[]).map(item=> + '

'+escapeHtml(item.title)+'

'+ + '

'+escapeHtml(item.details)+'

').join('')+'
' +} + +function homeMarkup(page:RenderedPage,source:string):string { + const hero=page.frontmatter.hero + if (!hero || page.frontmatter.layout!=='home') return page.html + return '
Scarp'+ + '

'+escapeHtml(hero.name??'Scarp')+'

'+ + '

'+escapeHtml(hero.text??page.title)+'

'+ + '

'+escapeHtml(hero.tagline??'')+'

'+ + '
'+homeActions(hero.actions,source)+'
'+ + homeFeatures(page.frontmatter.features,source)+page.html +} +function navigation(locale:'en'|'hi',active:string,views:PageView[]):string { + const inLocale=views.filter(v=>v.locale===locale && v.source!=='index.md' && v.source!=='hi/index.md') + const byGroup=new Map() + for(const v of inLocale){ + const key=groupFor(v.source) + if(!byGroup.has(key))byGroup.set(key,[]) + byGroup.get(key)!.push(v) + } + return '' +} +function finderData():string { + const entries=RELEASES.flatMap(release=> + TASKS.map(task=>commandAdvice(release.tag,task.id))) + const json=JSON.stringify({releases:RELEASES,tasks:TASKS,entries,text:FINDER_TEXT}) + .replace(/'+json+'' +} + +function htmlDocument(view:PageView, views:PageView[]):string{ + const {page,source,route,locale}=view + const meta=pairedRoutes(source) + const other=locale==='en'?meta.hindi:meta.english + const home=page.frontmatter.layout==='home' + const body=home?homeMarkup(page,source):page.html + const heading=home||page.headings.some(item=>item.level===1) + ?'':'

'+escapeHtml(page.title)+'

' + const seoDescription=(home?page.frontmatter.hero?.tagline:page.bodyText)??page.bodyText + const description=seoDescription.slice(0,185) + const lang=locale==='hi'?'hi-IN':'en-US' + const target=other.slice('https://scarp-lang.github.io'.length) + const localeText=localized('हिन्दी','English',locale) + const title=page.title==='Scarp'?'Scarp Docs':page.title+' · Scarp Docs' + const searchText=localized('Search documentation','दस्तावेज़ खोजें',locale) + const contents=page.headings.filter(h=>h.level===2||h.level===3).slice(0,45) + const toc=contents.length + ?'':'' + const finder=body.includes('data-command-finder')?finderData():'' + return ''+ + ''+ + ''+ + ''+escapeHtml(title)+''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '
'+navigation(locale,route,views)+ + '
'+heading+body+'
'+toc+'
'+ + ''+ + '

'+searchText+'

'+ + '
'+ + ''+ + ''+ + '

    '+ + finder+ + '
    '+localized('Experimental Scarp language. Verify each guide against its published compiler release.', + 'Scarp भाषा प्रयोगात्मक है। हर गाइड का प्रकाशित compiler version जाँचें।',locale)+ + ' · MIT documentation source
    '+ + '' +} + +export async function buildSite():Promise<{pages:number;assets:number}>{ + // The engine preview NEVER overwrites VitePress production output. + rmSync(previewRoot,{recursive:true,force:true}) + mkdirSync(join(siteRoot,'assets'),{recursive:true}) + const sources=documentationPages(docsRoot) + const views=sources.map(source=>{ + const page=renderMarkdown(readFileSync(join(docsRoot,source),'utf8'),source) + return {page,source,route:pairedRoutes(source).route,locale:pairedRoutes(source).locale} + }) + assert.equal(views.length,138,'Migration must preserve all 138 paired source pages') + assert.equal(views.filter(v=>v.locale==='hi').length,69,'Hindi page parity') + const routes=new Set(views.map(v=>v.route)) + assert.equal(routes.size,views.length,'Generated public routes must be unique') + const searchIndex:IndexEntry[]=views.map(view=>({ + route:view.route,title:view.page.title,locale:view.locale, + section:groupFor(view.source),excerpt:view.page.bodyText + })) + for(const view of views){ + const dest=join(siteRoot,htmlPath(view.source)) + mkdirSync(resolve(dest,'..'),{recursive:true}) + writeFileSync(dest,htmlDocument(view,views),'utf8') + } + const sitemap='\n'+ + '\n'+ + views.map(v=>''+pairedRoutes(v.source).canonical+'').join('\n')+ + '\n\n' + writeFileSync(join(siteRoot,'sitemap.xml'),sitemap) + writeFileSync(join(siteRoot,'assets','search-index.json'),JSON.stringify(searchIndex)) + writeFileSync(join(siteRoot,'assets','scarp-mark.svg'),logo) + writeFileSync(join(siteRoot,'assets','scarp-site.css'),readFileSync(styles,'utf8')) + // Browser controller is a single import-free TypeScript file. Compile it + // directly: no VitePress, Vite, Vue, esbuild or client framework. + const browserScript=ts.transpileModule(readFileSync(client,'utf8'),{ + fileName:'client.ts', + compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}, + reportDiagnostics:true + }) + if(browserScript.diagnostics?.length) { + const details=ts.formatDiagnosticsWithColorAndContext(browserScript.diagnostics,{ + getCanonicalFileName:name=>name,getCurrentDirectory:()=>root, + getNewLine:()=> '\n' + }) + throw new Error('Client TypeScript compilation failed: '+details) + } + writeFileSync(join(siteRoot,'assets','scarp-site.js'),browserScript.outputText) + writeFileSync(join(siteRoot,'404.html'),'Not found · Scarp Docs'+ + '

    Documentation page not found

    Scarp Docs
    ') + console.log('PASS: independent Scarp TypeScript engine generated '+views.length+ + ' English/Hindi Markdown routes, local CSS/JavaScript and sitemap under '+siteRoot) + return {pages:views.length,assets:4} +} +if(process.argv[1] && resolve(process.argv[1])===fileURLToPath(import.meta.url)){ + await buildSite() +} diff --git a/scripts/scarp-site/client.ts b/scripts/scarp-site/client.ts new file mode 100644 index 00000000..c56315f5 --- /dev/null +++ b/scripts/scarp-site/client.ts @@ -0,0 +1,293 @@ +/** + * Scarp's own browser controller. No Vue/VitePress runtime. + * The server renders every page as plain HTML; this enhances only the search, + * appearance, mobile menu, deployment count and release-aware CLI widget. + */ +interface SearchEntry { route: string; title: string; locale: 'en'|'hi'; section: string; excerpt: string } +interface Release { tag:string; label:string } +interface Task { id:string; en:string; hi:string } +interface CommandAdvice { + release:string;task:string;supported:boolean;firstRelease:string; + commands:string[];reference:string;note:string +} +interface LocalStrings { + heading:string; intro:string; version:string; task:string; supported:string; + unavailable:string; first:string; command:string; copy:string; copied:string; + copyFailed:string; reference:string; missing:string; warning:string; + notes:Record +} +interface FinderData { + releases:Release[];tasks:Task[];entries:CommandAdvice[]; + text:{en:LocalStrings;hi:LocalStrings} +} +const BASE='/scarp-docs/' +const hindi=document.documentElement.lang.startsWith('hi') +const locale:'en'|'hi'=hindi?'hi':'en' +const t=(en:string,hi:string):string=>hindi?hi:en +function el( + tag:K,cls?:string,text?:string +):HTMLElementTagNameMap[K]{ + const node=document.createElement(tag) + if(cls)node.className=cls + if(text!==undefined)node.textContent=text + return node +} + +/** Restore persisted appearance without relying on third-party theme scripts. */ +function installAppearance():void { + const toggle=document.querySelector('#theme-toggle') + if(!toggle)return + let stored:string|null=null + try{stored=localStorage.getItem('scarp-docs-appearance')}catch{ /* disabled storage */ } + const initial=stored==='dark'||(stored!== 'light'&& + window.matchMedia('(prefers-color-scheme: dark)').matches) + const apply=(dark:boolean):void=>{ + document.documentElement.classList.toggle('dark',dark) + toggle.setAttribute('aria-pressed',String(dark)) + toggle.setAttribute('aria-label',dark?t('Use light theme','लाइट मोड चुनें'): + t('Use dark theme','डार्क मोड चुनें')) + } + apply(initial) + toggle.addEventListener('click',()=>{ + const next=!document.documentElement.classList.contains('dark') + apply(next) + try{localStorage.setItem('scarp-docs-appearance',next?'dark':'light')} + catch{ /* private browsing */ } + }) +} + +function installMenu():void { + const toggle=document.querySelector('#menu-toggle') + const navigation=document.querySelector('#side-nav') + if(!toggle||!navigation)return + const setOpen=(open:boolean):void=>{ + navigation.classList.toggle('open',open) + toggle.setAttribute('aria-expanded',String(open)) + } + toggle.addEventListener('click',()=>setOpen(!navigation.classList.contains('open'))) + navigation.addEventListener('click',event=>{ + if((event.target as Element).closest('a'))setOpen(false) + }) + document.addEventListener('keydown',event=>{ + if(event.key==='Escape'&&navigation.classList.contains('open')){ + setOpen(false) + toggle.focus() + } + }) +} + +let searchIndexPromise:Promise|null=null +function searchIndex():Promise { + if(!searchIndexPromise){ + searchIndexPromise=fetch(BASE+'assets/search-index.json').then(response=>{ + if(!response.ok)throw new Error('Local search index unavailable') + return response.json() as Promise + }).catch(err=>{searchIndexPromise=null;throw err}) + } + return searchIndexPromise +} +function search(entries:SearchEntry[],value:string):SearchEntry[]{ + const terms=value.toLocaleLowerCase().split(/\s+/u).filter(Boolean) + if(terms.length===0)return [] + const scored=entries.filter(e=>e.locale===locale).map(e=>{ + const heading=e.title.toLocaleLowerCase() + const content=e.excerpt.toLocaleLowerCase() + if(!terms.every(term=>heading.includes(term)||content.includes(term)))return null + const score=terms.reduce((sum,term)=>sum+ + (heading===term?100:heading.startsWith(term)?50:heading.includes(term)?22:0)+ + (content.includes(term)?1:0),0) + return {entry:e,score} + }).filter((x):x is {entry:SearchEntry;score:number}=>x!==null) + return scored.sort((a,b)=>b.score-a.score||a.entry.title.localeCompare(b.entry.title)) + .slice(0,18).map(x=>x.entry) +} + +function installSearch():void { + const dialog=document.querySelector('#search-panel') + const open=document.querySelector('#search-open') + const close=document.querySelector('#search-close') + const input=document.querySelector('#search-query') + const output=document.querySelector('#search-results') + const status=document.querySelector('#search-status') + if(!dialog||!open||!close||!input||!output||!status)return + let previousFocus:HTMLElement|null=null + let generation=0 + + const refresh=async():Promise=>{ + const current=++generation + const query=input.value.trim() + output.replaceChildren() + if(!query){ + status.textContent=t('Start typing to search the documentation.','खोजने के लिए शब्द लिखें।') + return + } + status.textContent=t('Searching local documentation…','स्थानीय दस्तावेज़ खोजे जा रहे हैं…') + try { + const entries=search(await searchIndex(),query) + if(current!==generation||!dialog.open)return + status.textContent=entries.length + ?t(entries.length+' result(s)',entries.length+' परिणाम') + :t('No results found','कोई परिणाम नहीं मिला') + for(const entry of entries){ + const li=el('li') + const link=el('a') + link.href=entry.route + link.appendChild(el('strong',undefined,entry.title)) + link.appendChild(el('small',undefined,entry.excerpt.slice(0,140))) + li.appendChild(link) + output.appendChild(li) + } + }catch{ + if(current===generation)status.textContent= + t('Could not load the local search index. Use page navigation instead.', + 'स्थानीय खोज उपलब्ध नहीं है। पृष्ठों के लिंक का उपयोग करें।') + } + } + const show=():void=>{ + if(dialog.open)return + previousFocus=document.activeElement instanceof HTMLElement?document.activeElement:open + dialog.showModal() + input.focus() + void refresh() + } + open.addEventListener('click',show) + close.addEventListener('click',()=>dialog.close()) + dialog.addEventListener('click',event=>{ + if(event.target===dialog)dialog.close() + }) + dialog.addEventListener('close',()=>{ + generation++ + if(previousFocus?.isConnected)previousFocus.focus({preventScroll:true}) + else open.focus({preventScroll:true}) + }) + input.addEventListener('input',()=>{void refresh()}) + document.addEventListener('keydown',event=>{ + // Search inputs may consume Escape to clear their own value. The explicit + // handler guarantees the dialog always closes and restores trigger focus. + if(event.key==='Escape'&&dialog.open){ + event.preventDefault() + dialog.close() + return + } + const target=event.target as HTMLElement + const typing=target.closest('input,textarea,select,[contenteditable="true"]') + if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='k'){ + event.preventDefault() + show() + }else if(event.key==='/'&&!typing&&!dialog.open&&!event.ctrlKey&&!event.metaKey){ + event.preventDefault() + show() + } + }) +} + +function installFinder():void { + const panel=document.querySelector('[data-command-finder]') + const data=document.querySelector('#scarp-command-data') + if(!panel||!data?.textContent)return + let parsed:FinderData + try{parsed=JSON.parse(data.textContent) as FinderData} + catch{panel.textContent=t('Command data unavailable.','Command data उपलब्ध नहीं।');return} + const words=parsed.text[locale] + const h=el('h2',undefined,words.heading) + const intro=el('p',undefined,words.intro) + const fields=el('div','fields') + const versionField=el('div') + const releaseLabel=el('label',undefined,words.version) + releaseLabel.htmlFor='scarp-release' + const releaseSelect=el('select') as HTMLSelectElement + releaseSelect.id='scarp-release' + for(const release of parsed.releases){ + const option=el('option',undefined,release.label) + option.value=release.tag + releaseSelect.appendChild(option) + } + releaseSelect.value=parsed.releases.at(-1)?.tag??'' + const taskField=el('div') + const taskLabel=el('label',undefined,words.task) + taskLabel.htmlFor='scarp-task' + const taskSelect=el('select') as HTMLSelectElement + taskSelect.id='scarp-task' + for(const task of parsed.tasks){ + const option=el('option',undefined,task[locale]) + option.value=task.id + taskSelect.appendChild(option) + } + versionField.append(releaseLabel,releaseSelect) + taskField.append(taskLabel,taskSelect) + fields.append(versionField,taskField) + const result=el('div','finder-result') + result.setAttribute('aria-live','polite') + result.setAttribute('aria-atomic','true') + panel.append(h,intro,fields,result) + + const render=():void=>{ + const item=parsed.entries.find(entry=>entry.release===releaseSelect.value&& + entry.task===taskSelect.value) + if(!item)return + result.replaceChildren() + const status=el('p','status'+(item.supported?'':' unavailable'), + item.supported?words.supported:words.unavailable) + result.appendChild(status) + if(!item.supported){ + result.append(el('p',undefined,words.first+' '+item.firstRelease), + el('p',undefined,words.missing)) + }else{ + result.appendChild(el('p',undefined,words.command)) + const pre=el('pre') + pre.tabIndex=0 + pre.appendChild(el('code',undefined,item.commands.join('\n'))) + result.appendChild(pre) + const button=el('button',undefined,words.copy) + button.type='button' + button.addEventListener('click',()=>{ + if(!navigator.clipboard?.writeText){ + button.textContent=words.copyFailed + return + } + void navigator.clipboard.writeText(item.commands.join('\n')) + .then(()=>{button.textContent=words.copied}) + .catch(()=>{button.textContent=words.copyFailed}) + }) + result.appendChild(button) + result.appendChild(el('p',undefined,words.notes[item.note]??'')) + } + const reference=el('a',undefined,words.reference) + reference.href=BASE+item.reference.replace(/^\/+/,'') + result.appendChild(reference) + result.appendChild(el('p',undefined,words.warning)) + } + releaseSelect.addEventListener('change',render) + taskSelect.addEventListener('change',render) + render() +} + +function installDeployments():void { + const target=document.querySelector('[data-deployments]') + const button=target?.querySelector('[data-load-deployments]') + const output=target?.querySelector('output') + if(!button||!output)return + button.addEventListener('click',()=>{ + button.disabled=true + output.textContent=t('Checking published workflow history…','प्रकाशित workflow इतिहास देख रहे हैं…') + void fetch('https://api.github.com/repos/scarp-lang/scarp-docs/actions/workflows/pages.yml/runs?status=success&per_page=1') + .then(async response=>{ + if(!response.ok)throw new Error('Public Actions API rate limit or network unavailable') + return response.json() as Promise<{total_count?:number}> + }).then(body=>{ + output.textContent=typeof body.total_count==='number' + ?t('Successful Pages workflow runs: '+body.total_count, + 'सफल Pages workflow रन: '+body.total_count) + :t('Run count unavailable','रन की संख्या उपलब्ध नहीं') + }).catch(()=>{ + output.textContent=t('Count unavailable offline. View GitHub Actions when connected.', + 'ऑफलाइन संख्या उपलब्ध नहीं। इंटरनेट पर GitHub Actions देखें।') + }).finally(()=>{button.disabled=false}) + }) +} + +installAppearance() +installMenu() +installSearch() +installFinder() +installDeployments() diff --git a/scripts/scarp-site/markdown.ts b/scripts/scarp-site/markdown.ts new file mode 100644 index 00000000..28dc7a1e --- /dev/null +++ b/scripts/scarp-site/markdown.ts @@ -0,0 +1,183 @@ +import MarkdownIt from 'markdown-it' +import { parse as parseYaml } from 'yaml' +import { documentationHeadingSlug } from '../lib/docs-slug.ts' +import { escapeHtml, localLink } from './routes.ts' + +export interface HomeAction { theme?: string; text: string; link: string } +export interface HomeFeature { title: string; details: string; link: string } +export interface FrontMatter { + layout?: string + title?: string + titleTemplate?: string + hero?: { + name?: string; text?: string; tagline?: string + image?: { src?: string; alt?: string } + actions?: HomeAction[] + } + features?: HomeFeature[] +} + +export interface RenderedPage { + title: string + html: string + bodyText: string + headings: Array<{ id: string; title: string; level: number }> + frontmatter: FrontMatter +} + +export function splitFrontMatter(markdown: string): {matter: FrontMatter; content: string} { + const source = markdown.replace(/^\uFEFF/, '') + if (!source.startsWith('---\n')) return {matter:{}, content:source} + const end = source.indexOf('\n---\n', 4) + if (end === -1) throw new Error('Unclosed YAML front matter') + const raw = parseYaml(source.slice(4, end)) + if (raw !== null && (typeof raw !== 'object' || Array.isArray(raw))) { + throw new TypeError('Documentation front matter must be a YAML object') + } + return {matter:(raw ?? {}) as FrontMatter,content:source.slice(end+5)} +} + +/** + * Translate trusted VitePress-style callout *syntax*, not arbitrary source HTML. + * All normal Markdown is parsed with html:false so a Markdown contribution + * cannot embed script tags, handlers or HTML event attributes. + */ +function containers(source: string): { + markdown: string; trusted: Map +} { + const trusted = new Map() + const stack: string[] = [] + let counter=0 + const lines = source.split('\n') + const output: string[] = [] + for (const line of lines) { + const open = /^::: (tip|info|warning|danger|note|details)(?:\s+(.*))?\s*$/.exec(line) + if (open) { + const kind = open[1] + const name = (open[2] ?? kind).trim() + const token = 'SCARPTRUSTEDBLOCK'+counter+++'TOKEN' + const element = kind === 'details' + ? '
    '+escapeHtml(name)+'' + : '
    ':'') + output.push('',token,'') + continue + } + output.push(line) + } + if (stack.length) throw new Error('Unclosed Markdown callout container') + return {markdown:output.join('\n'),trusted} +} + +function highlightScarp(raw: string): string { + // Lex on the raw text BEFORE escaping: highlighted spans never wrap entities. + const tokens = /(\/\/[^\n]*|"(?:\\.|[^"\\])*"|\b(?:bind|var|funcra|pure|if|elif|else|while|repeat|break|continue|return|needs|ensures|import|as|pub|true|false|integer|float|boolean|string|vector|integers|tile|unit)\b|\b\d+(?:\.\d+)?\b)/g + let out='', previous=0 + for (const match of raw.matchAll(tokens)) { + const index=match.index + out+=escapeHtml(raw.slice(previous,index)) + const value=match[0] + const type=value.startsWith('//')?'comment':value.startsWith('"')?'string': + /^\d/.test(value)?'number':'keyword' + out+=''+escapeHtml(value)+'' + previous=index+value.length + } + return out+escapeHtml(raw.slice(previous)) +} + +function widgetPlaceholders(source: string): {markdown:string;trusted:Map} { + const trusted=new Map() + const widgets: Array<[RegExp,string,string]> = [ + [//g,'SCARPWIDGETCOMMANDS','
    '], + [//g,'SCARPWIDGETDEPLOYMENTS', + '

    GitHub Pages deployment history is available from GitHub Actions.

    '] + ] + let markdown=source + for (const [pattern,token,html] of widgets) { + if (pattern.test(markdown)) { + trusted.set(token,html) + markdown=markdown.replace(pattern,'\n'+token+'\n') + } + } + return {markdown,trusted} +} + +export function renderMarkdown(markdown: string, source: string): RenderedPage { + const {matter,content} = splitFrontMatter(markdown) + const headings: RenderedPage['headings'] = [] + const anchorCounts=new Map() + const renderer=new MarkdownIt({html:false,linkify:true,typographer:false,breaks:false}) + const previousLink=renderer.renderer.rules.link_open + renderer.renderer.rules.link_open=(tokens,i,opts,env,self)=>{ + const token=tokens[i] + const raw=token.attrGet('href') + if (raw) { + const next=localLink(raw,source) + token.attrSet('href',next) + if (/^https?:\/\//.test(next)) { + token.attrSet('rel','noopener noreferrer') + } + } + return previousLink + ? previousLink(tokens,i,opts,env,self) + : self.renderToken(tokens,i,opts) + } + renderer.renderer.rules.image=(tokens,i,opts,env,self)=>{ + const token=tokens[i] + const src=token.attrGet('src') + if (src) token.attrSet('src',localLink(src,source)) + token.attrSet('loading','lazy') + return self.renderToken(tokens,i,opts) + } + renderer.renderer.rules.heading_open=(tokens,i,opts,env,self)=>{ + const token=tokens[i] + const inline=tokens[i+1] + const title=(inline?.children??[]).map(child=>child.type==='image' + ?child.content:(child.type==='text'||child.type==='code_inline'?child.content:'')).join('').trim() + const base=documentationHeadingSlug(title)||'section' + const number=anchorCounts.get(base)??0 + anchorCounts.set(base,number+1) + const id=number?base+'-'+number:base + token.attrSet('id',id) + headings.push({id,title,level:Number(token.tag.slice(1))}) + return self.renderToken(tokens,i,opts) + } + renderer.renderer.rules.table_open=(tokens,i,opts,env,self)=>{ + return '
    '+ + self.renderToken(tokens,i,opts) + } + renderer.renderer.rules.table_close=(tokens,i,opts,env,self)=> + self.renderToken(tokens,i,opts)+'
    ' + renderer.renderer.rules.fence=(tokens,i)=>{ + const token=tokens[i] + const lang=token.info.trim().split(/\s+/)[0].replace(/[^a-zA-Z0-9_-]/g,'').toLowerCase() + const rendered=lang==='scarp'||lang==='srp' + ?highlightScarp(token.content):escapeHtml(token.content) + return '
    '+rendered+'
    \n' + } + // Compiler provenance markers remain in Markdown source for CI, but must + // not be exposed as escaped HTML comments in the rendered user page. + const visibleContent=content.replace(//g,'') + const widgets=widgetPlaceholders(visibleContent) + const blocks=containers(widgets.markdown) + let html=renderer.render(blocks.markdown) + const placeholders=new Map([...widgets.trusted,...blocks.trusted]) + for (const [token, trusted] of placeholders) { + html=html.replaceAll('

    '+token+'

    ',trusted) + } + // Trust no raw author HTML: only the explicitly handled widget/callout tags + // are ever injected AFTER markdown-it's HTML-escaping parse. + const firstHeading=headings.find(h=>h.level===1) + const title=matter.title??firstHeading?.title??'Scarp' + const bodyText=visibleContent.replace(/<[^>]+>/g,' ') + .replace(/[#*_\x60~|]/g,' ').replace(/\s+/g,' ').trim() + return {title,html,bodyText,headings,frontmatter:matter} +} diff --git a/scripts/scarp-site/routes.ts b/scripts/scarp-site/routes.ts new file mode 100644 index 00000000..c44aa23d --- /dev/null +++ b/scripts/scarp-site/routes.ts @@ -0,0 +1,62 @@ +/** + * Scarp-owned URL contract. This module deliberately imports no site framework. + * Keep this mapping compatible with public Pages URLs when switching engines. + */ +import { posix } from 'node:path' + +export const ORIGIN = 'https://scarp-lang.github.io' +export const BASE = '/scarp-docs/' +export type Locale = 'en' | 'hi' + +export function sourceRoute(source: string): string { + if (!source.endsWith('.md') || source.startsWith('/') || source.includes('\\') || + source.includes('?') || source.includes('#') || source.includes(':') || + source.split('/').some(s => s === '.' || s === '..' || !s)) { + throw new TypeError('Invalid Scarp documentation path: ' + source) + } + const withoutExtension = source.slice(0, -3) + if (withoutExtension === 'index') return BASE + if (withoutExtension.endsWith('/index')) { + return BASE + withoutExtension.slice(0, -5).split('/').map(encodeURIComponent).join('/') + } + return BASE + withoutExtension.split('/').map(encodeURIComponent).join('/') +} + +export function htmlPath(source: string): string { + if (!source.endsWith('.md') || source.includes('..') || source.startsWith('/') || + source.includes('\\')) throw new TypeError('Invalid output route: ' + source) + return source.slice(0, -3) + '.html' +} + +export function pairedRoutes(source: string): { + locale: Locale; route: string; canonical: string; english: string; hindi: string +} { + const locale: Locale = source.startsWith('hi/') ? 'hi' : 'en' + const name = locale === 'hi' ? source.slice(3) : source + const english = ORIGIN + sourceRoute(name) + const hindi = ORIGIN + sourceRoute('hi/' + name) + return { locale, route: sourceRoute(source), + canonical: ORIGIN + sourceRoute(source), english, hindi } +} + +export function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + .replace(/'/g, ''') +} + +export function localLink(href: string, pageSource: string): string { + if (!href || href.startsWith('#')) return href + if (/^(?:https?:|mailto:|tel:)/i.test(href)) return href + if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) return '#' + if (href.startsWith(BASE)) return href + if (href.startsWith('/')) return BASE + href.slice(1) + const [pathname, fragment = ''] = href.split('#', 2) + const isMarkdown = pathname.endsWith('.md') + if (isMarkdown) { + const normalized = posix.normalize(posix.join(posix.dirname(pageSource), pathname)) + if (normalized.startsWith('../') || normalized.startsWith('/')) return '#' + return sourceRoute(normalized) + (fragment ? '#' + fragment : '') + } + return href +} diff --git a/scripts/scarp-site/site.css b/scripts/scarp-site/site.css new file mode 100644 index 00000000..6d26034f --- /dev/null +++ b/scripts/scarp-site/site.css @@ -0,0 +1,141 @@ +/* Scarp Docs' own responsive design system. No VitePress/theme CSS. */ +:root { + color-scheme: light; + --background: #ffffff; --surface: #f8fafc; --surface-strong: #eff6ff; + --text: #17223a; --muted: #475569; --line: #dbe3ef; + --accent: #1d4ed8; --accent-hover: #1e40af; --code-bg: #eff3f9; + --shadow: 0 12px 40px rgba(15, 23, 42, 0.11); +} +html.dark { + color-scheme: dark; --background: #101722; --surface: #172233; + --surface-strong: #1a2a45; --text: #f3f7ff; --muted: #c3d1e3; + --line: #35445c; --accent: #93c5fd; --accent-hover: #bfdbfe; + --code-bg: #1b2a3e; --shadow: 0 14px 48px rgba(0,0,0,0.37); +} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 6.5rem; } +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; } } +body { margin:0; font: 16px/1.7 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--text); background:var(--background); overflow-wrap:anywhere; } +a { color:var(--accent); text-decoration-thickness:.06em; text-underline-offset:.15em; } +a:hover { text-decoration: underline; } +button,input,select { font:inherit; } +button,select { cursor:pointer; } +button,select,input { color:var(--text); background:var(--background); border:1px solid var(--line); border-radius:8px; } +button { padding:.4rem .7rem; } +button:hover,select:hover { border-color:var(--accent); } +:focus-visible { outline:3px solid var(--accent); outline-offset:3px; } +.skip-link { position:fixed; top:-100px; left:12px; z-index:1000; padding:.5rem 1rem; background:var(--surface); border:2px solid var(--accent); } +.skip-link:focus { top:12px; } +.site-header { height:66px; border-bottom:1px solid var(--line); display:flex; gap:clamp(.5rem,2vw,2rem); align-items:center; justify-content:space-between; position:sticky; top:0; padding:.3rem clamp(.7rem,2.5vw,2.4rem); background:var(--background); z-index:20; } +.brand { display:flex; gap:.55rem; align-items:center; font-size:1.22rem; font-weight:800; letter-spacing:.02em; text-decoration:none; color:var(--text); } +.brand img { border-radius:6px; } +.top-nav { display:flex; gap:clamp(.5rem,1.7vw,1.5rem); margin-right:auto; } +.top-nav a { text-decoration:none; color:var(--muted); font-weight:600; font-size:.94rem; } +.top-nav a:hover { color:var(--accent); } +.header-actions { display:flex; gap:.5rem; align-items:center; white-space:nowrap; } +.header-actions button { font-size:.91rem; min-height:38px; } +.header-actions .language-link { display:inline-flex; align-items:center; padding:0 .5rem; min-height:38px; font-weight:600; } +#menu-toggle { display:none; } +.site-layout { max-width:1600px; margin:auto; padding:0 1rem; display:grid; grid-template-columns:minmax(220px,255px) minmax(0,1fr) minmax(170px,205px); gap:clamp(1rem,2.2vw,2.8rem); align-items:start; } +.side-nav { border-right:1px solid var(--line); padding:1.3rem .75rem 2rem 0; position:sticky; top:66px; max-height:calc(100vh - 66px); overflow-y:auto; scrollbar-width:thin; } +.side-nav a { text-decoration:none; display:block; padding:.22rem .6rem; color:var(--muted); border-radius:6px; line-height:1.45; font-size:.87rem; } +.side-nav a:hover,.side-nav a.active { background:var(--surface-strong); color:var(--accent); } +.side-nav a.active { font-weight:750; } +.side-home { font-weight:750; } +.nav-group { margin-top:1rem; } +.nav-group h2 { font-size:.73rem; color:var(--text); text-transform:uppercase; letter-spacing:.08em; margin:.75rem .55rem .35rem; } +.nav-group ul,.toc ul { list-style:none; padding:0; margin:0; } +.nav-group li { margin:.14rem 0; } +main { min-width:0; } +.doc-content { max-width:860px; min-height:70vh; margin:2.4rem auto 3rem; padding:0 .25rem; } +.doc-content > :first-child { margin-top:0; } +.doc-content h1 { font-size:clamp(1.7rem,3vw,2.7rem); line-height:1.24; margin:1.6rem 0 1rem; letter-spacing:-.025em; } +.doc-content h2 { font-size:clamp(1.24rem,2vw,1.65rem); margin:2.4rem 0 .75rem; line-height:1.3; padding-bottom:.45rem; border-bottom:1px solid var(--line); } +.doc-content h3 { font-size:1.16rem; margin:2rem 0 .6rem; } +.doc-content h4 { font-size:1rem; } +.doc-content p { margin:.85rem 0 1rem; } +.doc-content ul,.doc-content ol { padding-left:1.55rem; } +.doc-content li { margin:.3rem 0; } +.doc-content blockquote { margin:1.5rem 0; padding:.1rem 1.15rem; border-left:4px solid var(--accent); background:var(--surface); } +.table-scroll { width:100%; overflow-x:auto; margin:1rem 0; border-radius:.45rem; } +.table-scroll:focus-visible { outline:3px solid var(--accent); outline-offset:3px; } +.doc-content table { width:max-content; min-width:min(100%,440px); border-spacing:0; border-collapse:collapse; font-size:.9rem; } +.doc-content th,.doc-content td { border:1px solid var(--line); padding:.55rem .75rem; text-align:start; min-width:65px; } +.doc-content th { background:var(--surface); } +.doc-content img { max-width:100%; height:auto; } +.doc-content code:not(pre code) { border-radius:.3rem; padding:.13em .32em; background:var(--code-bg); font: .91em/1.5 ui-monospace,Consolas,monospace; } +.doc-content pre,.command-finder pre { max-width:100%; overflow-x:auto; padding:1rem; background:var(--code-bg); border:1px solid var(--line); border-radius:.6rem; margin:1rem 0; tab-size:2; font: .89rem/1.5 ui-monospace,Consolas,"Liberation Mono",monospace; white-space:pre; overflow-wrap:normal; } +.doc-content pre code { white-space:inherit; } +.syntax-keyword { color:#1d4ed8; font-weight:600; } +.syntax-number { color:#9a3412; } +.syntax-string { color:#047857; } +.syntax-comment { color:#64748b; font-style:italic; } +.dark .syntax-keyword { color:#a5b4fc; } +.dark .syntax-number { color:#fdba74; } +.dark .syntax-string { color:#6ee7b7; } +.dark .syntax-comment { color:#cbd5e1; } +.scarp-callout { display:block; padding:.8rem 1rem; margin:1.35rem 0; border-radius:.55rem; border-left:4px solid var(--accent); background:var(--surface-strong); } +.scarp-warning,.scarp-danger { border-left-color:#b45309; } +.scarp-callout > strong { display:block; margin-bottom:.5rem; } +.toc { position:sticky; top:82px; max-height:calc(100vh - 90px); overflow-y:auto; padding:1.3rem 0; font-size:.82rem; } +.toc h2 { font-size:.82rem; color:var(--muted); } +.toc a { text-decoration:none; display:block; border-left:2px solid var(--line); padding:.2rem .6rem; color:var(--muted); } +.toc a:hover { color:var(--accent); border-left-color:var(--accent); } +.toc-level-3 { margin-left:.6rem; } +.site-footer { border-top:1px solid var(--line); padding:1.2rem 2rem; text-align:center; color:var(--muted); font-size:.84rem; } +.hero { text-align:center; padding:2.8rem 0 1rem; } +.hero img { border-radius:20px; } +.hero-kicker { color:var(--accent); font-weight:750; letter-spacing:.1em; text-transform:uppercase; } +.hero h1 { font-size:clamp(2.2rem,5vw,4rem); margin:.5rem 0; } +.hero > p:last-of-type { max-width:700px; margin:1rem auto; color:var(--muted); font-size:1.08rem; } +.hero-actions { display:flex; justify-content:center; flex-wrap:wrap; gap:.8rem; margin:1.5rem auto 2rem; } +.hero-action { display:inline-flex; align-items:center; justify-content:center; text-decoration:none; font-weight:650; padding:.6rem 1.2rem; border-radius:.55rem; border:1px solid var(--line); color:var(--text); } +.hero-action.primary { background:#1d4ed8; border-color:#1d4ed8; color:#fff; } +.hero-action.primary:hover { background:#1e40af; } +.features { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr)); gap:.85rem; margin:1.6rem 0 2.4rem; } +.feature { display:block; color:var(--text); border:1px solid var(--line); border-radius:.85rem; padding:1rem 1.15rem; text-decoration:none; background:var(--surface); } +.feature:hover { border-color:var(--accent); text-decoration:none; } +.feature h2 { margin:.1rem 0 .55rem; padding:0; font-size:1.1rem; border:0; color:var(--accent); } +.feature p { margin:0; color:var(--muted); font-size:.93rem; } +.search-panel { width:min(700px,calc(100vw - 1.5rem)); max-height:80vh; margin:10vh auto; border:1px solid var(--line); border-radius:.9rem; padding:1rem 1.25rem; background:var(--background); color:var(--text); box-shadow:var(--shadow); } +.search-panel::backdrop { background:rgba(15,23,42,.62); } +.search-panel:not([open]) { display:none; } +.search-header { display:flex; align-items:center; justify-content:space-between; } +.search-header h2 { margin:0 0 .6rem; font-size:1.3rem; } +.search-panel label { display:block; font-size:.85rem; margin-bottom:.35rem; color:var(--muted); } +#search-query { display:block; width:100%; padding:.65rem .85rem; } +#search-status { color:var(--muted); font-size:.85rem; } +#search-results { list-style:none; padding:0; margin:.25rem 0; max-height:46vh; overflow-y:auto; } +#search-results li { border-bottom:1px solid var(--line); } +#search-results a { display:block; padding:.55rem .45rem; border-radius:.4rem; text-decoration:none; } +#search-results a:hover,#search-results a:focus { background:var(--surface); } +#search-results small { display:block; color:var(--muted); font-size:.8rem; } +.command-finder { border:1px solid var(--line); padding:1rem; border-radius:.85rem; background:var(--surface); margin:1.5rem 0; } +.command-finder h2 { margin:0 0 .5rem; border:0; } +.command-finder .fields { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:1rem; } +.command-finder label { display:block; margin-bottom:.35rem; font-weight:600; } +.command-finder select { width:100%; padding:.6rem; } +.command-finder .status { font-weight:700; } +.command-finder .status.unavailable { color:#b45309; } +.command-finder button { background:#1d4ed8; color:#fff; } +.deployment button { margin:.5rem 0; } +@media(max-width:1270px) { .site-layout { grid-template-columns:235px minmax(0,1fr); } .toc { display:none; } } +@media(max-width:900px) { + .site-header { height:62px; gap:.5rem; } + .top-nav { display:none; } + .site-layout { display:block; padding:0 1rem; } + .side-nav { display:none; position:fixed; z-index:30; top:62px; left:0; bottom:0; max-height:calc(100vh - 62px); width:min(335px,90vw); padding:1rem; border:1px solid var(--line); background:var(--background); box-shadow:var(--shadow); } + .side-nav.open { display:block; } + #menu-toggle { display:inline-block; } + .doc-content { margin:1.7rem auto; } + #search-open kbd { display:none; } +} +@media(max-width:480px) { + .site-header { padding:.25rem .55rem; } + .brand span { font-size:1rem; } + #search-open { font-size:0; padding:.4rem; min-width:42px; } + #search-open::before { content:"⌕"; font-size:1.35rem; } + .header-actions { gap:.15rem; } + .header-actions .language-link { padding:.25rem; } + .site-layout { padding:0 .8rem; } +} diff --git a/scripts/scarp-site/test.ts b/scripts/scarp-site/test.ts new file mode 100644 index 00000000..6f13ea05 --- /dev/null +++ b/scripts/scarp-site/test.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { buildSite, previewRoot } from './build.ts' +import { renderMarkdown, splitFrontMatter } from './markdown.ts' +import { escapeHtml, localLink, htmlPath, pairedRoutes, sourceRoute } from './routes.ts' +import { documentationPages } from '../lib/docs-pages.ts' +import { urlForPage } from '../../docs/.vitepress/site-meta.ts' +import { validateSitemap } from '../lib/sitemap-policy.ts' + +const root=resolve(fileURLToPath(new URL('../..',import.meta.url))) +const docs=join(root,'docs') +const site=join(previewRoot,'scarp-docs') +let assertions=0 +function check(condition:boolean,message:string):void{assert.ok(condition,message);assertions++} + +assert.equal(sourceRoute('index.md'),'/scarp-docs/') +assert.equal(sourceRoute('hi/index.md'),'/scarp-docs/hi/') +assert.equal(sourceRoute('examples/index.md'),'/scarp-docs/examples/') +assert.equal(sourceRoute('hi/examples/index.md'),'/scarp-docs/hi/examples/') +assert.equal(sourceRoute('guide/first-program.md'),'/scarp-docs/guide/first-program') +assert.equal(pairedRoutes('hi/guide/first-program.md').english, + 'https://scarp-lang.github.io/scarp-docs/guide/first-program') +assert.equal(htmlPath('hi/guide/first-program.md'),'hi/guide/first-program.html') +assert.equal(localLink('/guide/learning-path','index.md'),'/scarp-docs/guide/learning-path') +assert.equal(localLink('/hi/','index.md'),'/scarp-docs/hi/') +assert.equal(localLink('https://github.com/scarp-lang','index.md'), + 'https://github.com/scarp-lang') +assert.equal(localLink('javascript:alert(1)','index.md'),'#') +assert.equal(localLink('../guide/first-program.md','tutorials/test.md'), + '/scarp-docs/guide/first-program') +assert.equal(escapeHtml('', + '~~~scarp', + 'funcra sample() -> integer {', + ' // ', + ' return 42;', + '}', + '~~~', + '| One | Two |', + '| --- | --- |', + '| first | second |', + '::: warning ध्यान दें', + 'User content must be parsed safely.', + ':::', + '' +].join('\n'),'guide/test.md') +check(fixture.headings[0].id==='परीक्षण-heading','Devanagari heading must retain Unicode marks') +check(fixture.headings[1].id==='duplicate-test','Heading slug must be stable') +check(fixture.headings[2].id==='duplicate-test-1','Duplicate headings must get distinct IDs') +check(fixture.html.includes('href="/scarp-docs/guide/first-program"'),'Internal links must use GitHub Pages base') +check(fixture.html.includes('rel="noopener noreferrer"'),'External links should be hardened') +check(!fixture.html.includes('