diff --git a/public/wiki.css b/public/wiki.css index 02e28f3..f3be511 100644 --- a/public/wiki.css +++ b/public/wiki.css @@ -99,3 +99,5 @@ footer nav{display:flex;flex-wrap:wrap;gap:16px} } .wiring-figure{margin:28px 0}.wiring-figure img{display:block;width:100%;height:auto;background:#fff;border-radius:10px}.wiring-figure figcaption{font-size:13px;color:var(--muted);padding:12px 0}details code{overflow-wrap:anywhere} +.breadcrumbs{font-size:13px;color:var(--muted);margin:0 0 18px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}.breadcrumbs a{color:inherit}.breadcrumbs [aria-current]{color:var(--ink)}th[scope=row]{text-align:left;white-space:nowrap;vertical-align:top;width:1%} +.pinout-figure img{width:auto;max-width:min(100%,760px);margin:0 auto;padding:12px} diff --git a/scripts/build-wiki.mjs b/scripts/build-wiki.mjs index 6c4c6af..1031dc1 100644 --- a/scripts/build-wiki.mjs +++ b/scripts/build-wiki.mjs @@ -1,9 +1,9 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {articles, makerIntros} from '../data/wiki.mjs'; -import {hardwareLinks,documentationSummary,availabilityLabel} from '../src/hardware-links.mjs'; +import {hardwareLinks,documentationSummary,availabilityLabel,safeSourceURL} from '../src/hardware-links.mjs'; const appVersion=JSON.parse(await fs.readFile(new URL('../package.json',import.meta.url),'utf8')).version; -import {coverageLabels} from '../src/pin-coverage.mjs'; +import {coverageLabels,orderedAssets} from '../src/pin-coverage.mjs'; import {wiringHTML,wiringMarkdown} from './wiring-wiki.mjs'; const site='https://valleytech-black-wire-guide.pages.dev'; @@ -15,15 +15,18 @@ const list=(items,ordered=false)=>{const tag=ordered?'ol':'ul';return `<${tag}>$ export async function buildWiki(out,base='/') { const catalog=JSON.parse(await fs.readFile('public/catalog.json','utf8')); - const paths=[]; - const page=async(route,name,description,body,type='WebPage')=>{ + const paths=[],sitemapImages=new Map(); + // opts: pageTitle (full ), noindex (kept out of the sitemap), image (social card override), + // images (image-sitemap entries), schema (replaces the default JSON-LD), section (kicker override). + const page=async(route,name,description,body,type='WebPage',opts={})=>{ const url=site+base+route; - const schema={'@context':'https://schema.org','@type':type,name,description,url,isPartOf:{'@type':'WebSite',name:title,url:site+'/'}}; + const schema=opts.schema||{'@context':'https://schema.org','@type':type,name,description,url,isPartOf:{'@type':'WebSite',name:title,url:site+'/'}}; + const pageTitle=opts.pageTitle||`${name} | Black Wire`,ogImage=opts.image||`${site}/brand/social-card.png`; // Kicker label and active navigation item come from the route. - const section=route.startsWith('wiki/wiring/')?'Wiring':route.startsWith('wiki/boards/')?'Boards':route.startsWith('wiki/modules/')?'Modules':route==='wiki/'?'Wiki':'Guide'; + const section=opts.section||(route.startsWith('wiki/wiring/')?'Wiring':route.startsWith('wiki/boards/')?'Boards':route.startsWith('wiki/modules/')?'Modules':route==='wiki/'?'Wiki':'Guide'); const nav=[['wiki/','Wiki',['Wiki','Guide']],['wiki/wiring/','Wiring',['Wiring']],['wiki/boards/','Boards',['Boards']],['wiki/modules/','Modules',['Modules']]].map(([href,label,sections])=>`<a href="${base+href}"${sections.includes(section)?' aria-current="page"':''}>${label}</a>`).join(''); - const html=`<!doctype html><html lang="en" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(name)} | Black Wire
Black WireMaker's Technical Reference
${body}
`; - const dest=path.join(out,route,'index.html');await fs.mkdir(path.dirname(dest),{recursive:true});await fs.writeFile(dest,html);paths.push(url); + const html=`${escape(pageTitle)}
Black WireMaker's Technical Reference
${body}
`; + const dest=path.join(out,route,'index.html');await fs.mkdir(path.dirname(dest),{recursive:true});await fs.writeFile(dest,html);if(!opts.noindex){paths.push(url);if(opts.images?.length)sitemapImages.set(url,opts.images);} }; const cards=items=>`
${items.map(({url,title,description})=>`

${escape(title)}

${escape(description)}

`).join('')}
`; await page('wiki/','The maker reference wiki','Find a reference, understand its limits and get back to building. Guides and catalog directories you can read without opening the app.',cards([{url:base+'wiki/wiring/',title:'Wiring & protocols',description:'Connection diagrams for Ethernet, telephone jacks, serial buses, debugging and RFID/NFC.'},...articles.map(a=>({...a,url:base+'wiki/'+a.slug+'/'}))])+`

Browse the catalog

`+cards([{url:base+'wiki/boards/',title:'Board & device directory',description:`${catalog.boards.length.toLocaleString('en-US')} board and device listings, grouped by family, with documentation status.`},{url:base+'wiki/modules/',title:'Displays, sensors & modules',description:`${catalog.makerParts.length} maker records, grouped by purpose, including power and charging.`}])+`

These counts include family records and documentation gaps. They are not a count of fully verified pinouts. ${link(base+'wiki/coverage-and-sources/','Read the coverage policy')}.

`); @@ -33,19 +36,75 @@ export async function buildWiki(out,base='/') { const content=a.sections.map(s=>`

${escape(s.heading)}

${s.text?`

${escape(s.text)}

`:''}${s.items?list(s.items):''}${s.steps?list(s.steps,true):''}${(s.links||[]).map(([label,url])=>``).join('')}
`).join(''); await page('wiki/'+a.slug+'/',a.title,a.description,`
${content}
`); } - const recordTable=(records,kind)=>`

${[...records].sort((a,b)=>(a.brand+' '+a.name).localeCompare(b.brand+' '+b.name)).map(r=>``).join('')}
Model / manufacturerFamily / interfaceReference coverage
${link(base+(kind==='maker'?'?tab=makers&part=':'?board=')+encodeURIComponent(r.id),r.name)}${escape(r.brand)} · ${escape(r.revision||'Revision not identified')}${hardwareLinks(r).length?`
Datasheets & original links${hardwareLinks(r).map(s=>`${link(s.url,s.label)}${s.scope==='component'?' (chip/component)':''}${availabilityLabel(s.availability)?' · '+escape(availabilityLabel(s.availability)):''}`).join('')}
`:''}
${escape(kind==='maker'?(r.interfaces||[]).join(' / ')||r.technology||'See record':r.processor||r.family)}${kind==='maker'&&r.resolution?`${escape(r.resolution)}`:''}${escape(coverageLabels[r.pinoutCoverage?.status]||'Documentation needed')}${r.assets?.length||0} reference files · ${(r.pinoutCoverage?.purposeRows)||0} documented purpose rowsBoard datasheet: ${documentationSummary(r).boardDatasheet?'recorded':'not yet recorded'} · Original website: ${documentationSummary(r).website?'linked':'not yet identified'} · Visual: ${documentationSummary(r).visualCount?'available':'still needed'}
`; + const recordTable=(records,kind)=>`

${[...records].sort((a,b)=>(a.brand+' '+a.name).localeCompare(b.brand+' '+b.name)).map(r=>``).join('')}
Model / manufacturerFamily / interfaceReference coverage
${link(refURL(r),r.name)}${escape(r.brand)} · ${escape(r.revision||'Revision not identified')}${hardwareLinks(r).length?`
Datasheets & original links${hardwareLinks(r).map(s=>`${link(s.url,s.label)}${s.scope==='component'?' (chip/component)':''}${availabilityLabel(s.availability)?' · '+escape(availabilityLabel(s.availability)):''}`).join('')}
`:''}
${escape(kind==='maker'?(r.interfaces||[]).join(' / ')||r.technology||'See record':r.processor||r.family)}${kind==='maker'&&r.resolution?`${escape(r.resolution)}`:''}${escape(coverageLabels[r.pinoutCoverage?.status]||'Documentation needed')}${r.assets?.length||0} reference files · ${(r.pinoutCoverage?.purposeRows)||0} documented purpose rowsBoard datasheet: ${documentationSummary(r).boardDatasheet?'recorded':'not yet recorded'} · Original website: ${documentationSummary(r).website?'linked':'not yet identified'} · Visual: ${documentationSummary(r).visualCount?'available':'still needed'}
`; const boardIntros={CYD:'ESP32 display boards commonly grouped under CYD. Connector and display revisions differ; match the complete model code.',ESP32:'ESP32 and ESP8266 development boards. Match the module, board revision and USB or header layout.',RP2040:'RP2040 development boards and products. A shared microcontroller does not mean a shared header.',RP2350:'RP2350 development boards and products. Compare the exact board and revision.',Teensy:'Teensy boards and reference documents. Use the exact version, including any suffix.','Raspberry Pi SBC':'Raspberry Pi computers. Physical header numbering and GPIO signal numbers are different.','Other SBC':'Single-board computers from additional manufacturers. Connector assignments are model-specific.',Expansion:'I/O carriers, GPIO expanders, adapter and radio boards. Read host compatibility and connector scope.','GPIO device':'Devices exposing pins or GPIO. Match connector orientation and model revision.',FPGA:'FPGA development boards. Match the device, connector and I/O bank voltage before connecting external hardware.',Other:'Additional development boards. Search within this directory by model or manufacturer.','Source collection':'Manufacturer source records awaiting more detailed identification or review. A source record does not mean a complete pinout.','Chip reference':'Chip-package references. These locate contacts on the IC package and must not be treated as development-board header maps.'}; + // One static, crawlable page per board and maker record so searches such as + // " pinout" can land on the exact reference instead of the app shell. + const refSlug=new Map(),usedSlugs=new Set(),refTitles=new Set(); + const allRecords=[...catalog.boards.map(r=>['board',r]),...catalog.makerParts.map(r=>['maker',r])]; + for(const [,r] of allRecords){ + const candidates=[slug(r.name),slug(`${r.brand} ${r.name}`),slug(`${r.name} ${r.revision&&!/not identified/i.test(r.revision)?r.revision:''}`),slug(`${r.name} ${r.id}`)].filter(Boolean); + let s=candidates.find(c=>!usedSlugs.has(c));for(let i=2;!s;i++)if(!usedSlugs.has(`${candidates[0]}-${i}`))s=`${candidates[0]}-${i}`; + usedSlugs.add(s);refSlug.set(r.id,s); + } + const refURL=r=>base+'pinout/'+refSlug.get(r.id)+'/'; + const appURL=(r,kind)=>base+(kind==='maker'?'?tab=makers&part=':'?board=')+encodeURIComponent(r.id); + const groupOf=(r,kind)=>kind==='maker'?r.category:r.family; + const rootOf=kind=>kind==='maker'?'modules':'boards'; + const chipOf=(r,kind)=>kind==='maker'?(r.controllers||[]).join(', '):r.processor||''; + // Index a record only when it carries something a visitor can use; empty + // identification tasks stay reachable but are kept out of search results. + const indexable=(r,kind)=>(r.assets?.length||0)>0||documentationSummary(r).boardDatasheet||(kind==='maker'&&(r.pinLabels||[]).length>=3); + const clip=(s,n)=>s.length<=n?s:s.slice(0,n-1).replace(/\s+\S*$/,'')+'…'; + const dateOf=String(catalog.editionInfo?.snapshot||'').replace(/\./g,'-'); + const absolute=rel=>site+base+rel.replace(/^\//,''); + const refImages=r=>orderedAssets((r.assets||[]).filter(a=>a.thumb||/\.(png|jpe?g|webp|svg)$/i.test(a.file||''))); + const recordsByGroup=new Map(); + for(const [kind,r] of allRecords){const k=kind+'|'+groupOf(r,kind);if(!recordsByGroup.has(k))recordsByGroup.set(k,[]);recordsByGroup.get(k).push(r);} + let referencePages=0,indexedReferencePages=0; + for(const [kind,r] of allRecords){ + const group=groupOf(r,kind),chip=chipOf(r,kind),images=refImages(r),links=hardwareLinks(r),cov=r.pinoutCoverage||{}; + const isIndexed=indexable(r,kind); + const noun=kind==='maker'?'Pinout & Wiring Reference':'Pinout & GPIO Reference'; + let pageTitle=`${r.name} ${noun} | Black Wire`; + if(refTitles.has(pageTitle))pageTitle=`${r.name} (${r.brand}) ${noun} | Black Wire`; + if(refTitles.has(pageTitle))pageTitle=`${r.name} (${r.brand}, ${r.id}) ${noun} | Black Wire`; + refTitles.add(pageTitle); + const what=kind==='maker'?[chip&&`${chip}`,(r.interfaces||[]).length&&(r.interfaces||[]).join('/')+' interface',r.resolution].filter(Boolean).join(', '):[chip&&`${chip}`,group&&group!==chip&&`${group} board`].filter(Boolean).join(' '); + const by=r.brand&&!r.name.toLowerCase().startsWith(r.brand.toLowerCase())?` by ${r.brand}`:''; + const offer=[images.length&&`${images.length===1?'pinout image':images.length+' pinout images'}`,kind==='maker'&&(r.pinLabels||[]).length?'pin labels':'pin functions',links.length&&'datasheets & source links'].filter(Boolean); + const lead=`${r.name} pinout${by}${what?` (${what})`:''}`; + const description=lead.length>120?clip(lead,157):clip(`${lead}: ${offer.join(', ')}.`,158); + const facts=[['Manufacturer',r.brand],['Model',r.name],[kind==='maker'?'Controller / chip':'Processor / chip',chip],[kind==='maker'?'Category':'Board family',group],['Revision',r.revision],['Interfaces',(r.interfaces||[]).join(', ')],['Display',[r.technology,r.diagonalInches?r.diagonalInches+'"':'',r.resolution].filter(Boolean).join(' · ')],['Also known as',(r.aliases||[]).join(', ')],['Pin labels',(r.pinLabels||[]).join(', ')],['Reference coverage',(coverageLabels[cov.status]||'Documentation needed')+(r.coverage?` — ${r.coverage}`:'')]].filter(([,v])=>v&&String(v).trim()); + const crumbs=[['Wiki',base+'wiki/'],[kind==='maker'?'Modules':'Boards',base+`wiki/${rootOf(kind)}/`],[group,base+`wiki/${rootOf(kind)}/${slug(group)}/`],[r.name,refURL(r)]]; + const figures=images.slice(0,10).map(a=>{const alt=`${r.name} ${a.type||'reference'}${a.label&&!a.label.startsWith(r.name)?` – ${a.label}`:''}`;const src=base+'library/'+(a.thumb||a.file);return `
${escape(alt)}
${escape(a.label||a.type||'Reference image')}${a.review?` · ${escape(a.review)}`:''}${(a.sources||[]).filter(safeSourceURL).slice(0,1).map(u=>` · ${link(u,'Original source')} ↗`).join('')}${a.rights?`
${escape(a.rights)}`:''}
`;}).join(''); + const siblings=(recordsByGroup.get(kind+'|'+group)||[]).filter(o=>o.id!==r.id&&indexable(o,kind)).sort((a,b)=>(b.assets?.length||0)-(a.assets?.length||0)||a.name.localeCompare(b.name)).slice(0,12); + const body=``+ + ``+ + `

${escape(r.name)} specifications

${facts.map(([k,v])=>``).join('')}
${escape(k)}${escape(v)}
`+ + (figures?`

${escape(r.name)} pinout diagram${images.length===1?'':'s'}

${figures}${images.length>10?`

${images.length-10} more reference images are available ${link(appURL(r,kind),'in the guide')}.

`:''}`:`

${escape(r.name)} pinout status

A pinout image for this exact model has not been collected yet. ${link(base+'wiki/contributing/','Contribute a source')} or check the datasheets below.

`)+ + (links.length?`

${escape(r.name)} datasheets & original sources

`:'')+ + `

Before you wire the ${escape(r.name)}

`+ + (siblings.length?`

More ${escape(group)} pinouts

`+cards(siblings.map(o=>({url:refURL(o),title:o.name+' pinout',description:[o.brand,chipOf(o,kind)].filter(Boolean).join(' · ')||group}))):'')+ + ``; + const imageURLs=images.slice(0,10).map(a=>absolute('library/'+(a.thumb||a.file))); + const schema={'@context':'https://schema.org','@graph':[ + {'@type':'TechArticle','@id':site+refURL(r)+'#article',headline:clip(`${r.name} pinout`,110),name:pageTitle.replace(/ \| Black Wire$/,''),description,url:site+refURL(r),inLanguage:'en',...(dateOf?{dateModified:dateOf}:{}),...(imageURLs.length?{image:imageURLs}:{}),keywords:[`${r.name} pinout`,chip&&`${chip} pinout`,`${r.name} GPIO`,`${r.name} datasheet`,...(r.aliases||[]).slice(0,5)].filter(Boolean).join(', '),about:{'@type':'Product',name:r.name,...(r.brand?{brand:{'@type':'Brand',name:r.brand}}:{}),...(chip?{model:chip}:{})},author:{'@type':'Person',name:'Kal',url:'https://www.youtube.com/@valleytechsolutions'},publisher:{'@type':'Organization',name:'Valleytech Solutions',url:'https://valleytechsolutions.tech',logo:{'@type':'ImageObject',url:site+'/brand/black-wire-red.png'}},isPartOf:{'@type':'WebSite',name:title,url:site+'/'}}, + {'@type':'BreadcrumbList',itemListElement:crumbs.map(([t,u],i)=>({'@type':'ListItem',position:i+1,name:t,item:site+u}))}]}; + await page('pinout/'+refSlug.get(r.id)+'/',`${r.name} pinout`,description,body,'TechArticle',{pageTitle,noindex:!isIndexed,image:imageURLs[0],images:images.slice(0,10).map((a,i)=>({loc:imageURLs[i],title:`${r.name} ${a.type||'pinout'}`})),schema,section:kind==='maker'?'Modules':'Boards'}); + referencePages++;if(isIndexed)indexedReferencePages++; + } const makerCategories=new Set(catalog.makerParts.map(r=>r.category)),sharedGroups=new Set(catalog.boards.map(r=>r.family).filter(g=>makerCategories.has(g))); for(const [kind,records,field,root,label,intros,noun] of [['board',catalog.boards,'family','boards','Board & device directory',boardIntros,'board'],['maker',catalog.makerParts,'category','modules','Displays, sensors & modules',makerIntros,'module']]){ const groups=[...new Set(records.map(r=>r[field]))].sort(); await page(`wiki/${root}/`,label,`Browse ${records.length.toLocaleString('en-US')} catalog listings with source coverage and direct links to their references.`,cards(groups.map(g=>({url:base+`wiki/${root}/${slug(g)}/`,title:g,description:`${records.filter(r=>r[field]===g).length} records. ${intros[g]||'Compare the exact model and revision.'}`})))+`

A listing may be an exact model, a family, or an identification task. Coverage is shown for each record; no all-device completeness is implied.

`,'CollectionPage'); - for(const g of groups)await page(`wiki/${root}/${slug(g)}/`,g+(sharedGroups.has(g)?` ${noun}`:'')+' references',intros[g]||`Find ${g} references in the Black Wire guide.`,recordTable(records.filter(r=>r[field]===g),kind)+`

Physical source availability does not establish all-pin completeness or electrical compatibility. ${link(base+'wiki/coverage-and-sources/','Read the coverage labels')}.

`,'CollectionPage'); + for(const g of groups)await page(`wiki/${root}/${slug(g)}/`,g+(sharedGroups.has(g)?` ${noun}`:'')+' pinouts & references',intros[g]||`Find ${g} pinouts and references in the Black Wire guide.`,recordTable(records.filter(r=>r[field]===g),kind)+`

Physical source availability does not establish all-pin completeness or electrical compatibility. ${link(base+'wiki/coverage-and-sources/','Read the coverage labels')}.

`,'CollectionPage'); } - await fs.writeFile(path.join(out,'sitemap.xml'),`${[site+base,...paths].map(url=>`${escape(url)}`).join('')}`); + await fs.writeFile(path.join(out,'sitemap.xml'),`${[site+base,...paths].map(url=>`${escape(url)}${dateOf?`${dateOf}`:''}${(sitemapImages.get(url)||[]).map(i=>`${escape(i.loc)}`).join('')}`).join('')}`); await fs.writeFile(path.join(out,'robots.txt'),`User-agent: *\nAllow: /\nSitemap: ${site}${base}sitemap.xml\n`); const index=path.join(out,'index.html'); if(base!=='/')await fs.writeFile(index,(await fs.readFile(index,'utf8')).replaceAll('href="/wiki/',`href="${base}wiki/`).replaceAll('src="/theme-init.js"',`src="${base}theme-init.js"`).replaceAll(site+'/',site+base)); - console.log(`Built ${paths.length} static wiki and directory pages with sitemap.`); + console.log(`Built ${paths.length} indexed static pages (${referencePages} pinout pages, ${indexedReferencePages} indexed) with sitemap.`); return paths; } diff --git a/scripts/check-web-release.mjs b/scripts/check-web-release.mjs index 835ef15..6d7dddd 100644 --- a/scripts/check-web-release.mjs +++ b/scripts/check-web-release.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import {pathToFileURL} from 'node:url'; -const allowed=new Set(['assets','brand','library','licenses','pdf','wiki','_headers','404.html','catalog.json','index.html','robots.txt','sitemap.xml','theme-init.js','THIRD_PARTY_NOTICES.txt','wiki.css','wiki.js','build-info.json']); +const allowed=new Set(['assets','brand','library','licenses','pdf','wiki','pinout','_headers','404.html','catalog.json','index.html','robots.txt','sitemap.xml','theme-init.js','THIRD_PARTY_NOTICES.txt','wiki.css','wiki.js','build-info.json']); export async function checkWebRelease(root,{maxFiles=20000,maxFileBytes=25*1024*1024}={}){ root=path.resolve(root);let count=0,total=0; async function walk(dir){