upd8.js 246 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632
  1. #!/usr/bin/env node
  2. // HEY N8RDS!
  3. //
  4. // This is one of the 8ACKEND FILES. It's not used anywhere on the actual site
  5. // you are pro8a8ly using right now.
  6. //
  7. // Specifically, this one does all the actual work of the music wiki. The
  8. // process looks something like this:
  9. //
  10. // 1. Crawl the music directories. Well, not so much "crawl" as "look inside
  11. // the folders for each al8um, and read the metadata file descri8ing that
  12. // al8um and the tracks within."
  13. //
  14. // 2. Read that metadata. I'm writing this 8efore actually doing any of the
  15. // code, and I've gotta admit I have no idea what file format they're
  16. // going to 8e in. May8e JSON, 8ut more likely some weird custom format
  17. // which will 8e a lot easier to edit.
  18. //
  19. // 3. Generate the page files! They're just static index.html files, and are
  20. // what gh-pages (or wherever this is hosted) will show to clients.
  21. // Hopefully pretty minimalistic HTML, 8ut like, shrug. They'll reference
  22. // CSS (and maaaaaaaay8e JS) files, hard-coded somewhere near the root.
  23. //
  24. // 4. Print an awesome message which says the process is done. This is the
  25. // most important step.
  26. //
  27. // Oh yeah, like. Just run this through some relatively recent version of
  28. // node.js and you'll 8e fine. ...Within the project root. O8viously.
  29. // HEY FUTURE ME!!!!!!!! Don't forget to implement artist pages! Those are,
  30. // like, the coolest idea you've had yet, so DO NOT FORGET. (Remem8er, link
  31. // from track listings, etc!) --- Thanks, past me. To futurerer me: an al8um
  32. // listing page (a list of all the al8ums)! Make sure to sort these 8y date -
  33. // we'll need a new field for al8ums.
  34. // ^^^^^^^^ DID THAT! 8ut also, artist images. Pro8a8ly stolen from the fandom
  35. // wiki (I found half those images anywayz).
  36. // TRACK ART CREDITS. This is a must.
  37. // 2020-08-23
  38. // ATTENTION ALL 8*TCHES AND OTHER GENDER TRUCKERS: AS IT TURNS OUT, THIS CODE
  39. // ****SUCKS****. I DON'T THINK ANYTHING WILL EVER REDEEM IT, 8UT THAT DOESN'T
  40. // MEAN WE CAN'T TAKE SOME ACTION TO MAKE WRITING IT A LITTLE LESS TERRI8LE.
  41. // We're gonna start defining STRUCTURES to make things suck less!!!!!!!!
  42. // No classes 8ecause those are a huge pain and like, pro8a8ly 8ad performance
  43. // or whatever -- just some standard structures that should 8e followed
  44. // wherever reasona8le. Only one I need today is the contri8 one 8ut let's put
  45. // any new general-purpose structures here too, ok?
  46. //
  47. // Contri8ution: {who, what, date, thing}. D8 and thing are the new fields.
  48. //
  49. // Use these wisely, which is to say all the time and instead of whatever
  50. // terri8le new pseudo structure you're trying to invent!!!!!!!!
  51. //
  52. // Upd8 2021-01-03: Soooooooo we didn't actually really end up using these,
  53. // lol? Well there's still only one anyway. Kinda ended up doing a 8ig refactor
  54. // of all the o8ject structures today. It's not *especially* relevant 8ut feels
  55. // worth mentioning? I'd get rid of this comment 8lock 8ut I like it too much!
  56. // Even though I haven't actually reread it, lol. 8ut yeah, hopefully in the
  57. // spirit of this "make things more consistent" attitude I 8rought up 8ack in
  58. // August, stuff's lookin' 8etter than ever now. W00t!
  59. 'use strict';
  60. const fs = require('fs');
  61. const path = require('path');
  62. const util = require('util');
  63. // I made this dependency myself! A long, long time ago. It is pro8a8ly my
  64. // most useful li8rary ever. I'm not sure 8esides me actually uses it, though.
  65. const fixWS = require('fix-whitespace');
  66. // Wait nevermind, I forgot a8out why-do-kids-love-the-taste-of-cinnamon-toast-
  67. // crunch. THAT is my 8est li8rary.
  68. // The require function just returns whatever the module exports, so there's
  69. // no reason you can't wrap it in some decorator right out of the 8ox. Which is
  70. // exactly what we do here.
  71. const mkdirp = util.promisify(require('mkdirp'));
  72. // It stands for "HTML Entities", apparently. Cursed.
  73. const he = require('he');
  74. // This is the dum8est name for a function possi8le. Like, SURE, fine, may8e
  75. // the UNIX people had some valid reason to go with the weird truncated
  76. // lowercased convention they did. 8ut Node didn't have to ALSO use that
  77. // convention! Would it have 8een so hard to just name the function something
  78. // like fs.readDirectory???????? No, it wouldn't have 8een.
  79. const readdir = util.promisify(fs.readdir);
  80. // 8ut okay, like, look at me. DOING THE SAME THING. See, *I* could have named
  81. // my promisified function differently, and yet I did not. I literally cannot
  82. // explain why. We are all used to following in the 8ad decisions of our
  83. // ancestors, and never never never never never never never consider that hey,
  84. // may8e we don't need to make the exact same decisions they did. Even when
  85. // we're perfectly aware th8t's exactly what we're doing! Programmers,
  86. // including me, are all pretty stupid.
  87. // 8ut I mean, come on. Look. Node decided to use readFile, instead of like,
  88. // what, cat? Why couldn't they rename readdir too???????? As Johannes Kepler
  89. // once so elegantly put it: "Shrug."
  90. const readFile = util.promisify(fs.readFile);
  91. const writeFile = util.promisify(fs.writeFile);
  92. const access = util.promisify(fs.access);
  93. const symlink = util.promisify(fs.symlink);
  94. const unlink = util.promisify(fs.unlink);
  95. const {
  96. cacheOneArg,
  97. call,
  98. chunkByConditions,
  99. chunkByProperties,
  100. curry,
  101. decorateTime,
  102. filterEmptyLines,
  103. joinNoOxford,
  104. mapInPlace,
  105. logWarn,
  106. logInfo,
  107. logError,
  108. parseOptions,
  109. progressPromiseAll,
  110. queue,
  111. s,
  112. sortByName,
  113. splitArray,
  114. th,
  115. unique,
  116. withEntries
  117. } = require('./upd8-util');
  118. const genThumbs = require('./gen-thumbs');
  119. const C = require('./common/common');
  120. const CACHEBUST = 5;
  121. const WIKI_INFO_FILE = 'wiki-info.txt';
  122. const HOMEPAGE_INFO_FILE = 'homepage.txt';
  123. const ARTIST_DATA_FILE = 'artists.txt';
  124. const FLASH_DATA_FILE = 'flashes.txt';
  125. const NEWS_DATA_FILE = 'news.txt';
  126. const TAG_DATA_FILE = 'tags.txt';
  127. const GROUP_DATA_FILE = 'groups.txt';
  128. const STATIC_PAGE_DATA_FILE = 'static-pages.txt';
  129. const DEFAULT_STRINGS_FILE = 'strings-default.json';
  130. const CSS_FILE = 'site.css';
  131. // Shared varia8les! These are more efficient to access than a shared varia8le
  132. // (or at least I h8pe so), and are easier to pass across functions than a
  133. // 8unch of specific arguments.
  134. //
  135. // Upd8: Okay yeah these aren't actually any different. Still cleaner than
  136. // passing around a data object containing all this, though.
  137. let dataPath;
  138. let mediaPath;
  139. let langPath;
  140. let outputPath;
  141. let wikiInfo;
  142. let homepageInfo;
  143. let albumData;
  144. let trackData;
  145. let flashData;
  146. let flashActData;
  147. let newsData;
  148. let tagData;
  149. let groupData;
  150. let groupCategoryData;
  151. let staticPageData;
  152. let artistNames;
  153. let artistData;
  154. let artistAliasData;
  155. let officialAlbumData;
  156. let fandomAlbumData;
  157. let justEverythingMan; // tracks, albums, flashes -- don't forget to upd8 toAnythingMan!
  158. let justEverythingSortedByArtDateMan;
  159. let contributionData;
  160. let queueSize;
  161. let languages;
  162. const html = {
  163. // Non-comprehensive. ::::P
  164. selfClosingTags: ['br', 'img'],
  165. // Pass to tag() as an attri8utes key to make tag() return a 8lank string
  166. // if the provided content is empty. Useful for when you'll only 8e showing
  167. // an element according to the presence of content that would 8elong there.
  168. onlyIfContent: Symbol(),
  169. tag(tagName, ...args) {
  170. const selfClosing = html.selfClosingTags.includes(tagName);
  171. let openTag;
  172. let content;
  173. let attrs;
  174. if (typeof args[0] === 'object' && !Array.isArray(args[0])) {
  175. attrs = args[0];
  176. content = args[1];
  177. } else {
  178. content = args[0];
  179. }
  180. if (selfClosing && content) {
  181. throw new Error(`Tag <${tagName}> is self-closing but got content!`);
  182. }
  183. if (attrs?.[html.onlyIfContent] && !content) {
  184. return '';
  185. }
  186. if (attrs) {
  187. const attrString = html.attributes(args[0]);
  188. if (attrString) {
  189. openTag = `${tagName} ${attrString}`;
  190. }
  191. }
  192. if (!openTag) {
  193. openTag = tagName;
  194. }
  195. if (Array.isArray(content)) {
  196. content = content.filter(Boolean).join('\n');
  197. }
  198. if (content) {
  199. if (content.includes('\n')) {
  200. return fixWS`
  201. <${openTag}>
  202. ${content}
  203. </${tagName}>
  204. `;
  205. } else {
  206. return `<${openTag}>${content}</${tagName}>`;
  207. }
  208. } else {
  209. if (selfClosing) {
  210. return `<${openTag}>`;
  211. } else {
  212. return `<${openTag}></${tagName}>`;
  213. }
  214. }
  215. },
  216. escapeAttributeValue(value) {
  217. return value
  218. .replaceAll('"', '&quot;')
  219. .replaceAll("'", '&apos;');
  220. },
  221. attributes(attribs) {
  222. return Object.entries(attribs)
  223. .map(([ key, val ]) => {
  224. if (!val)
  225. return [key, val];
  226. else if (typeof val === 'string' || typeof val === 'boolean')
  227. return [key, val];
  228. else if (typeof val === 'number')
  229. return [key, val.toString()];
  230. else if (Array.isArray(val))
  231. return [key, val.join(' ')];
  232. else
  233. throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`);
  234. })
  235. .filter(([ key, val ]) => val)
  236. .map(([ key, val ]) => (typeof val === 'boolean'
  237. ? `${key}`
  238. : `${key}="${html.escapeAttributeValue(val)}"`))
  239. .join(' ');
  240. }
  241. };
  242. const urlSpec = {
  243. data: {
  244. prefix: 'data/',
  245. paths: {
  246. root: '',
  247. path: '<>',
  248. album: 'album/<>',
  249. artist: 'artist/<>',
  250. track: 'track/<>'
  251. }
  252. },
  253. localized: {
  254. // TODO: Implement this.
  255. // prefix: '_languageCode',
  256. paths: {
  257. root: '',
  258. path: '<>',
  259. home: '',
  260. album: 'album/<>/',
  261. albumCommentary: 'commentary/album/<>/',
  262. artist: 'artist/<>/',
  263. artistGallery: 'artist/<>/gallery/',
  264. commentaryIndex: 'commentary/',
  265. flashIndex: 'flash/',
  266. flash: 'flash/<>/',
  267. groupInfo: 'group/<>/',
  268. groupGallery: 'group/<>/gallery/',
  269. listingIndex: 'list/',
  270. listing: 'list/<>/',
  271. newsIndex: 'news/',
  272. newsEntry: 'news/<>/',
  273. staticPage: '<>/',
  274. tag: 'tag/<>/',
  275. track: 'track/<>/'
  276. }
  277. },
  278. shared: {
  279. paths: {
  280. root: '',
  281. path: '<>',
  282. commonFile: 'common/<>',
  283. staticFile: 'static/<>'
  284. }
  285. },
  286. media: {
  287. prefix: 'media/',
  288. paths: {
  289. root: '',
  290. path: '<>',
  291. albumCover: 'album-art/<>/cover.jpg',
  292. albumWallpaper: 'album-art/<>/bg.jpg',
  293. albumBanner: 'album-art/<>/banner.jpg',
  294. trackCover: 'album-art/<>/<>.jpg',
  295. artistAvatar: 'artist-avatar/<>.jpg',
  296. flashArt: 'flash-art/<>.jpg'
  297. }
  298. }
  299. };
  300. // This gets automatically switched in place when working from a baseDirectory,
  301. // so it should never be referenced manually.
  302. urlSpec.localizedWithBaseDirectory = {
  303. paths: withEntries(
  304. urlSpec.localized.paths,
  305. entries => entries.map(([key, path]) => [key, '<>/' + path])
  306. )
  307. };
  308. const linkHelper = (hrefFn, {color = true, attr = null} = {}) =>
  309. (thing, {
  310. strings, to,
  311. text = '',
  312. class: className = '',
  313. hash = ''
  314. }) => (
  315. html.tag('a', {
  316. ...attr ? attr(thing) : {},
  317. href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''),
  318. style: color ? getLinkThemeString(thing) : '',
  319. class: className
  320. }, text || thing.name)
  321. );
  322. const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) =>
  323. linkHelper((thing, {to}) => to('localized.' + key, thing.directory), {
  324. attr: thing => ({
  325. ...attr ? attr(thing) : {},
  326. ...expose ? {[expose]: thing.directory} : {}
  327. }),
  328. ...conf
  329. });
  330. const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf);
  331. const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf);
  332. const link = {
  333. album: linkDirectory('album'),
  334. albumCommentary: linkDirectory('albumCommentary'),
  335. artist: linkDirectory('artist', {color: false}),
  336. artistGallery: linkDirectory('artistGallery', {color: false}),
  337. commentaryIndex: linkIndex('commentaryIndex', {color: false}),
  338. flashIndex: linkIndex('flashIndex', {color: false}),
  339. flash: linkDirectory('flash'),
  340. groupInfo: linkDirectory('groupInfo'),
  341. groupGallery: linkDirectory('groupGallery'),
  342. home: linkIndex('home', {color: false}),
  343. listingIndex: linkIndex('listingIndex'),
  344. listing: linkDirectory('listing'),
  345. newsIndex: linkIndex('newsIndex', {color: false}),
  346. newsEntry: linkDirectory('newsEntry', {color: false}),
  347. staticPage: linkDirectory('staticPage', {color: false}),
  348. tag: linkDirectory('tag'),
  349. track: linkDirectory('track', {expose: 'data-track'}),
  350. media: linkPathname('media.path', {color: false}),
  351. root: linkPathname('shared.path', {color: false}),
  352. data: linkPathname('data.path', {color: false}),
  353. site: linkPathname('localized.path', {color: false})
  354. };
  355. const thumbnailHelper = name => file =>
  356. file.replace(/\.(jpg|png)$/, name + '.jpg');
  357. const thumb = {
  358. medium: thumbnailHelper('.medium'),
  359. small: thumbnailHelper('.small')
  360. };
  361. function generateURLs(fromPath) {
  362. const getValueForFullKey = (obj, fullKey, prop = null) => {
  363. const [ groupKey, subKey ] = fullKey.split('.');
  364. if (!groupKey || !subKey) {
  365. throw new Error(`Expected group key and subkey (got ${fullKey})`);
  366. }
  367. if (!obj.hasOwnProperty(groupKey)) {
  368. throw new Error(`Expected valid group key (got ${groupKey})`);
  369. }
  370. const group = obj[groupKey];
  371. if (!group.hasOwnProperty(subKey)) {
  372. throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`);
  373. }
  374. return {
  375. value: group[subKey],
  376. group
  377. };
  378. };
  379. const generateTo = (fromPath, fromGroup) => {
  380. const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length);
  381. const pathHelper = (toPath, toGroup) => {
  382. let target = toPath;
  383. let argIndex = 0;
  384. target = target.replaceAll('<>', () => `<${argIndex++}>`);
  385. if (toGroup.prefix !== fromGroup.prefix) {
  386. // TODO: Handle differing domains in prefixes.
  387. target = rebasePrefix + (toGroup.prefix || '') + target;
  388. }
  389. return (path.relative(fromPath, target)
  390. + (toPath.endsWith('/') ? '/' : ''));
  391. };
  392. const groupSymbol = Symbol();
  393. const groupHelper = urlGroup => ({
  394. [groupSymbol]: urlGroup,
  395. ...withEntries(urlGroup.paths, entries => entries
  396. .map(([key, path]) => [key, pathHelper(path, urlGroup)]))
  397. });
  398. const relative = withEntries(urlSpec, entries => entries
  399. .map(([key, urlGroup]) => [key, groupHelper(urlGroup)]));
  400. const to = (key, ...args) => {
  401. const { value: template, group: {[groupSymbol]: toGroup} } = getValueForFullKey(relative, key)
  402. let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]);
  403. // Kinda hacky lol, 8ut it works.
  404. const missing = result.match(/<([0-9]+)>/g);
  405. if (missing) {
  406. throw new Error(`Expected ${missing[missing.length - 1]} arguments, got ${args.length}`);
  407. }
  408. return result;
  409. };
  410. return {to, relative};
  411. };
  412. const generateFrom = () => {
  413. const map = withEntries(urlSpec, entries => entries
  414. .map(([key, group]) => [key, withEntries(group.paths, entries => entries
  415. .map(([key, path]) => [key, generateTo(path, group)])
  416. )]));
  417. const from = key => getValueForFullKey(map, key).value;
  418. return {from, map};
  419. };
  420. return generateFrom();
  421. }
  422. const urls = generateURLs();
  423. const searchHelper = (keys, dataFn, findFn) => ref => {
  424. if (!ref) return null;
  425. ref = ref.replace(new RegExp(`^(${keys.join('|')}):`), '');
  426. const found = findFn(ref, dataFn());
  427. if (!found) {
  428. logWarn`Didn't match anything for ${ref}! (${keys.join(', ')})`;
  429. }
  430. return found;
  431. };
  432. const matchDirectory = (ref, data) => data.find(({ directory }) => directory === ref);
  433. const matchDirectoryOrName = (ref, data) => {
  434. let thing;
  435. thing = matchDirectory(ref, data);
  436. if (thing) return thing;
  437. thing = data.find(({ name }) => name === ref);
  438. if (thing) return thing;
  439. thing = data.find(({ name }) => name.toLowerCase() === ref.toLowerCase());
  440. if (thing) {
  441. logWarn`Bad capitalization: ${'\x1b[31m' + ref} -> ${'\x1b[32m' + thing.name}`;
  442. return thing;
  443. }
  444. return null;
  445. };
  446. const search = {
  447. album: searchHelper(['album', 'album-commentary'], () => albumData, matchDirectoryOrName),
  448. artist: searchHelper(['artist', 'artist-gallery'], () => artistData, matchDirectoryOrName),
  449. flash: searchHelper(['flash'], () => flashData, matchDirectory),
  450. group: searchHelper(['group', 'group-gallery'], () => groupData, matchDirectoryOrName),
  451. listing: searchHelper(['listing'], () => listingSpec, matchDirectory),
  452. newsEntry: searchHelper(['news-entry'], () => newsData, matchDirectory),
  453. staticPage: searchHelper(['static'], () => staticPageData, matchDirectory),
  454. tag: searchHelper(['tag'], () => tagData, (ref, data) =>
  455. matchDirectoryOrName(ref.startsWith('cw: ') ? ref.slice(4) : ref, data)),
  456. track: searchHelper(['track'], () => trackData, matchDirectoryOrName)
  457. };
  458. // Localiz8tion time! Or l10n as the neeeeeeeerds call it. Which is a terri8le
  459. // name and not one I intend on using, thank you very much. (Don't even get me
  460. // started on """"a11y"""".)
  461. //
  462. // All the default strings are in strings-default.json, if you're curious what
  463. // those actually look like. Pretty much it's "I like {ANIMAL}" for example.
  464. // For each language, the o8ject gets turned into a single function of form
  465. // f(key, {args}). It searches for a key in the o8ject and uses the string it
  466. // finds (or the one in strings-default.json) as a templ8 evaluated with the
  467. // arguments passed. (This function gets treated as an o8ject too; it gets
  468. // the language code attached.)
  469. //
  470. // The function's also responsi8le for getting rid of dangerous characters
  471. // (quotes and angle tags), though only within the templ8te (not the args),
  472. // and it converts the keys of the arguments o8ject from camelCase to
  473. // CONSTANT_CASE too.
  474. function genStrings(stringsJSON, defaultJSON = null) {
  475. // genStrings will only 8e called once for each language, and it happens
  476. // right at the start of the program (or at least 8efore 8uilding pages).
  477. // So, now's a good time to valid8te the strings and let any warnings be
  478. // known.
  479. // May8e contrary to the argument name, the arguments should 8e o8jects,
  480. // not actual JSON-formatted strings!
  481. if (typeof stringsJSON !== 'object' || stringsJSON.constructor !== Object) {
  482. return {error: `Expected an object (parsed JSON) for stringsJSON.`};
  483. }
  484. if (typeof defaultJSON !== 'object') { // typeof null === object. I h8 JS.
  485. return {error: `Expected an object (parsed JSON) or null for defaultJSON.`};
  486. }
  487. // All languages require a language code.
  488. const code = stringsJSON['meta.languageCode'];
  489. if (!code) {
  490. return {error: `Missing language code.`};
  491. }
  492. if (typeof code !== 'string') {
  493. return {error: `Expected language code to be a string.`};
  494. }
  495. // Every value on the provided o8ject should be a string.
  496. // (This is lazy, but we only 8other checking this on stringsJSON, on the
  497. // assumption that defaultJSON was passed through this function too, and so
  498. // has already been valid8ted.)
  499. {
  500. let err = false;
  501. for (const [ key, value ] of Object.entries(stringsJSON)) {
  502. if (typeof value !== 'string') {
  503. logError`(${code}) The value for ${key} should be a string.`;
  504. err = true;
  505. }
  506. }
  507. if (err) {
  508. return {error: `Expected all values to be a string.`};
  509. }
  510. }
  511. // Checking is generally done against the default JSON, so we'll skip out
  512. // if that isn't provided (which should only 8e the case when it itself is
  513. // 8eing processed as the first loaded language).
  514. if (defaultJSON) {
  515. // Warn for keys that are missing or unexpected.
  516. const expectedKeys = Object.keys(defaultJSON);
  517. const presentKeys = Object.keys(stringsJSON);
  518. for (const key of presentKeys) {
  519. if (!expectedKeys.includes(key)) {
  520. logWarn`(${code}) Unexpected translation key: ${key} - this won't be used!`;
  521. }
  522. }
  523. for (const key of expectedKeys) {
  524. if (!presentKeys.includes(key)) {
  525. logWarn`(${code}) Missing translation key: ${key} - this won't be localized!`;
  526. }
  527. }
  528. }
  529. // Valid8tion is complete, 8ut We can still do a little caching to make
  530. // repeated actions faster.
  531. // We're gonna 8e mut8ting the strings dictionary o8ject from here on out.
  532. // We make a copy so we don't mess with the one which was given to us.
  533. stringsJSON = Object.assign({}, stringsJSON);
  534. // Preemptively pass everything through HTML encoding. This will prevent
  535. // strings from embedding HTML tags or accidentally including characters
  536. // that throw HTML parsers off.
  537. for (const key of Object.keys(stringsJSON)) {
  538. stringsJSON[key] = he.encode(stringsJSON[key], {useNamedReferences: true});
  539. }
  540. // It's time to cre8te the actual langauge function!
  541. // In the function, we don't actually distinguish 8etween the primary and
  542. // default (fall8ack) strings - any relevant warnings have already 8een
  543. // presented a8ove, at the time the language JSON is processed. Now we'll
  544. // only 8e using them for indexing strings to use as templ8tes, and we can
  545. // com8ine them for that.
  546. const stringIndex = Object.assign({}, defaultJSON, stringsJSON);
  547. // We do still need the list of valid keys though. That's 8ased upon the
  548. // default strings. (Or stringsJSON, 8ut only if the defaults aren't
  549. // provided - which indic8tes that the single o8ject provided *is* the
  550. // default.)
  551. const validKeys = Object.keys(defaultJSON || stringsJSON);
  552. const invalidKeysFound = [];
  553. const strings = (key, args = {}) => {
  554. // Ok, with the warning out of the way, it's time to get to work.
  555. // First make sure we're even accessing a valid key. (If not, return
  556. // an error string as su8stitute.)
  557. if (!validKeys.includes(key)) {
  558. // We only want to warn a8out a given key once. More than that is
  559. // just redundant!
  560. if (!invalidKeysFound.includes(key)) {
  561. invalidKeysFound.push(key);
  562. logError`(${code}) Accessing invalid key ${key}. Fix a typo or provide this in strings-default.json!`;
  563. }
  564. return `MISSING: ${key}`;
  565. }
  566. const template = stringIndex[key];
  567. // Convert the keys on the args dict from camelCase to CONSTANT_CASE.
  568. // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut
  569. // like, who cares, dude?) Also, this is an array, 8ecause it's handy
  570. // for the iterating we're a8out to do.
  571. const processedArgs = Object.entries(args)
  572. .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]);
  573. // Replacement time! Woot. Reduce comes in handy here!
  574. const output = processedArgs.reduce(
  575. (x, [ k, v ]) => x.replaceAll(`{${k}}`, v),
  576. template);
  577. // Post-processing: if any expected arguments *weren't* replaced, that
  578. // is almost definitely an error.
  579. if (output.match(/\{[A-Z_]+\}/)) {
  580. logError`(${code}) Args in ${key} were missing - output: ${output}`;
  581. }
  582. return output;
  583. };
  584. // And lastly, we add some utility stuff to the strings function.
  585. // Store the language code, for convenience of access.
  586. strings.code = code;
  587. // Store the strings dictionary itself, also for convenience.
  588. strings.json = stringsJSON;
  589. // Store Intl o8jects that can 8e reused for value formatting.
  590. strings.intl = {
  591. date: new Intl.DateTimeFormat(code, {full: true}),
  592. number: new Intl.NumberFormat(code),
  593. list: {
  594. conjunction: new Intl.ListFormat(code, {type: 'conjunction'}),
  595. disjunction: new Intl.ListFormat(code, {type: 'disjunction'}),
  596. unit: new Intl.ListFormat(code, {type: 'unit'})
  597. },
  598. plural: {
  599. cardinal: new Intl.PluralRules(code, {type: 'cardinal'}),
  600. ordinal: new Intl.PluralRules(code, {type: 'ordinal'})
  601. }
  602. };
  603. const bindUtilities = (obj, bind) => Object.fromEntries(Object.entries(obj).map(
  604. ([ key, fn ]) => [key, (value, opts = {}) => fn(value, {...bind, ...opts})]
  605. ));
  606. // There are a 8unch of handy count functions which expect a strings value;
  607. // for a more terse syntax, we'll stick 'em on the strings function itself,
  608. // with automatic 8inding for the strings argument.
  609. strings.count = bindUtilities(count, {strings});
  610. // The link functions also expect the strings o8ject(*). May as well hand
  611. // 'em over here too! Keep in mind they still expect {to} though, and that
  612. // isn't something we have access to from this scope (so calls such as
  613. // strings.link.album(...) still need to provide it themselves).
  614. //
  615. // (*) At time of writing, it isn't actually used for anything, 8ut future-
  616. // proofing, ok????????
  617. strings.link = bindUtilities(link, {strings});
  618. // List functions, too!
  619. strings.list = bindUtilities(list, {strings});
  620. return strings;
  621. };
  622. const countHelper = (stringKey, argName = stringKey) => (value, {strings, unit = false}) => strings(
  623. (unit
  624. ? `count.${stringKey}.withUnit.` + strings.intl.plural.cardinal.select(value)
  625. : `count.${stringKey}`),
  626. {[argName]: strings.intl.number.format(value)});
  627. const count = {
  628. date: (date, {strings}) => {
  629. return strings.intl.date.format(date);
  630. },
  631. dateRange: ([startDate, endDate], {strings}) => {
  632. return strings.intl.date.formatRange(startDate, endDate);
  633. },
  634. duration: (secTotal, {strings, approximate = false, unit = false}) => {
  635. if (secTotal === 0) {
  636. return strings('count.duration.missing');
  637. }
  638. const hour = Math.floor(secTotal / 3600);
  639. const min = Math.floor((secTotal - hour * 3600) / 60);
  640. const sec = Math.floor(secTotal - hour * 3600 - min * 60);
  641. const pad = val => val.toString().padStart(2, '0');
  642. const stringSubkey = unit ? '.withUnit' : '';
  643. const duration = (hour > 0
  644. ? strings('count.duration.hours' + stringSubkey, {
  645. hours: hour,
  646. minutes: pad(min),
  647. seconds: pad(sec)
  648. })
  649. : strings('count.duration.minutes' + stringSubkey, {
  650. minutes: min,
  651. seconds: pad(sec)
  652. }));
  653. return (approximate
  654. ? strings('count.duration.approximate', {duration})
  655. : duration);
  656. },
  657. index: (value, {strings}) => {
  658. return strings('count.index.' + strings.intl.plural.ordinal.select(value), {index: value});
  659. },
  660. number: value => strings.intl.number.format(value),
  661. words: (value, {strings, unit = false}) => {
  662. const num = strings.intl.number.format(value > 1000
  663. ? Math.floor(value / 100) / 10
  664. : value);
  665. const words = (value > 1000
  666. ? strings('count.words.thousand', {words: num})
  667. : strings('count.words', {words: num}));
  668. return strings('count.words.withUnit.' + strings.intl.plural.cardinal.select(value), {words});
  669. },
  670. albums: countHelper('albums'),
  671. commentaryEntries: countHelper('commentaryEntries', 'entries'),
  672. contributions: countHelper('contributions'),
  673. coverArts: countHelper('coverArts'),
  674. timesReferenced: countHelper('timesReferenced'),
  675. timesUsed: countHelper('timesUsed'),
  676. tracks: countHelper('tracks')
  677. };
  678. const listHelper = type => (list, {strings}) => strings.intl.list[type].format(list);
  679. const list = {
  680. unit: listHelper('unit'),
  681. or: listHelper('disjunction'),
  682. and: listHelper('conjunction')
  683. };
  684. // Note there isn't a 'find track data files' function. I plan on including the
  685. // data for all tracks within an al8um collected in the single metadata file
  686. // for that al8um. Otherwise there'll just 8e way too many files, and I'd also
  687. // have to worry a8out linking track files to al8um files (which would contain
  688. // only the track listing, not track data itself), and dealing with errors of
  689. // missing track files (or track files which are not linked to al8ums). All a
  690. // 8unch of stuff that's a pain to deal with for no apparent 8enefit.
  691. async function findFiles(dataPath, filter = f => true) {
  692. return (await readdir(dataPath))
  693. .map(file => path.join(dataPath, file))
  694. .filter(file => filter(file));
  695. }
  696. function* getSections(lines) {
  697. // ::::)
  698. const isSeparatorLine = line => /^-{8,}$/.test(line);
  699. yield* splitArray(lines, isSeparatorLine);
  700. }
  701. function getBasicField(lines, name) {
  702. const line = lines.find(line => line.startsWith(name + ':'));
  703. return line && line.slice(name.length + 1).trim();
  704. }
  705. function getBooleanField(lines, name) {
  706. // The ?? oper8tor (which is just, hilariously named, lol) can 8e used to
  707. // specify a default!
  708. const value = getBasicField(lines, name);
  709. switch (value) {
  710. case 'yes':
  711. case 'true':
  712. return true;
  713. case 'no':
  714. case 'false':
  715. return false;
  716. default:
  717. return null;
  718. }
  719. }
  720. function getListField(lines, name) {
  721. let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
  722. // If callers want to default to an empty array, they should stick
  723. // "|| []" after the call.
  724. if (startIndex === -1) {
  725. return null;
  726. }
  727. // We increment startIndex 8ecause we don't want to include the
  728. // "heading" line (e.g. "URLs:") in the actual data.
  729. startIndex++;
  730. let endIndex = lines.findIndex((line, index) => index >= startIndex && !line.startsWith('- '));
  731. if (endIndex === -1) {
  732. endIndex = lines.length;
  733. }
  734. if (endIndex === startIndex) {
  735. // If there is no list that comes after the heading line, treat the
  736. // heading line itself as the comma-separ8ted array value, using
  737. // the 8asic field function to do that. (It's l8 and my 8rain is
  738. // sleepy. Please excuse any unhelpful comments I may write, or may
  739. // have already written, in this st8. Thanks!)
  740. const value = getBasicField(lines, name);
  741. return value && value.split(',').map(val => val.trim());
  742. }
  743. const listLines = lines.slice(startIndex, endIndex);
  744. return listLines.map(line => line.slice(2));
  745. };
  746. function getContributionField(section, name) {
  747. let contributors = getListField(section, name);
  748. if (!contributors) {
  749. return null;
  750. }
  751. if (contributors.length === 1 && contributors[0].startsWith('<i>')) {
  752. const arr = [];
  753. arr.textContent = contributors[0];
  754. return arr;
  755. }
  756. contributors = contributors.map(contrib => {
  757. // 8asically, the format is "Who (What)", or just "Who". 8e sure to
  758. // keep in mind that "what" doesn't necessarily have a value!
  759. const match = contrib.match(/^(.*?)( \((.*)\))?$/);
  760. if (!match) {
  761. return contrib;
  762. }
  763. const who = match[1];
  764. const what = match[3] || null;
  765. return {who, what};
  766. });
  767. const badContributor = contributors.find(val => typeof val === 'string');
  768. if (badContributor) {
  769. return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`};
  770. }
  771. if (contributors.length === 1 && contributors[0].who === 'none') {
  772. return null;
  773. }
  774. return contributors;
  775. };
  776. function getMultilineField(lines, name) {
  777. // All this code is 8asically the same as the getListText - just with a
  778. // different line prefix (four spaces instead of a dash and a space).
  779. let startIndex = lines.findIndex(line => line.startsWith(name + ':'));
  780. if (startIndex === -1) {
  781. return null;
  782. }
  783. startIndex++;
  784. let endIndex = lines.findIndex((line, index) => index >= startIndex && line.length && !line.startsWith(' '));
  785. if (endIndex === -1) {
  786. endIndex = lines.length;
  787. }
  788. // If there aren't any content lines, don't return anything!
  789. if (endIndex === startIndex) {
  790. return null;
  791. }
  792. // We also join the lines instead of returning an array.
  793. const listLines = lines.slice(startIndex, endIndex);
  794. return listLines.map(line => line.slice(4)).join('\n');
  795. };
  796. const replacerSpec = {
  797. 'album': {
  798. search: 'album',
  799. link: 'album'
  800. },
  801. 'album-commentary': {
  802. search: 'album',
  803. link: 'albumCommentary'
  804. },
  805. 'artist': {
  806. search: 'artist',
  807. link: 'artist'
  808. },
  809. 'artist-gallery': {
  810. search: 'artist',
  811. link: 'artistGallery'
  812. },
  813. 'commentary-index': {
  814. search: null,
  815. link: 'commentaryIndex'
  816. },
  817. 'date': {
  818. search: null,
  819. value: ref => new Date(ref),
  820. html: (date, {strings}) => `<time datetime="${date.toString()}">${strings.count.date(date)}</time>`
  821. },
  822. 'flash': {
  823. search: 'flash',
  824. link: 'flash',
  825. transformName(name, search, offset, text) {
  826. const nextCharacter = text[offset + search.length];
  827. const lastCharacter = name[name.length - 1];
  828. if (
  829. ![' ', '\n', '<'].includes(nextCharacter) &&
  830. lastCharacter === '.'
  831. ) {
  832. return name.slice(0, -1);
  833. } else {
  834. return name;
  835. }
  836. }
  837. },
  838. 'group': {
  839. search: 'group',
  840. link: 'groupInfo'
  841. },
  842. 'group-gallery': {
  843. search: 'group',
  844. link: 'groupGallery'
  845. },
  846. 'listing-index': {
  847. search: null,
  848. link: 'listingIndex'
  849. },
  850. 'listing': {
  851. search: 'listing',
  852. link: 'listing'
  853. },
  854. 'media': {
  855. search: null,
  856. link: 'media'
  857. },
  858. 'news-index': {
  859. search: null,
  860. link: 'newsIndex'
  861. },
  862. 'news-entry': {
  863. search: 'newsEntry',
  864. link: 'newsEntry'
  865. },
  866. 'root': {
  867. search: null,
  868. link: 'root'
  869. },
  870. 'site': {
  871. search: null,
  872. link: 'site'
  873. },
  874. 'static': {
  875. search: 'staticPage',
  876. link: 'staticPage'
  877. },
  878. 'tag': {
  879. search: 'tag',
  880. link: 'tag'
  881. },
  882. 'track': {
  883. search: 'track',
  884. link: 'track'
  885. }
  886. };
  887. {
  888. let error = false;
  889. for (const [key, {link: linkKey, search: searchKey, value, html}] of Object.entries(replacerSpec)) {
  890. if (!html && !link[linkKey]) {
  891. logError`The replacer spec ${key} has invalid link key ${linkKey}! Specify it in link specs or fix typo.`;
  892. error = true;
  893. }
  894. if (searchKey && !search[searchKey]) {
  895. logError`The replacer spec ${key} has invalid search key ${searchKey}! Specify it in search specs or fix typo.`;
  896. error = true;
  897. }
  898. }
  899. if (error) process.exit();
  900. const categoryPart = Object.keys(replacerSpec).join('|');
  901. transformInline.regexp = new RegExp(String.raw`(?<!\\)\[\[((${categoryPart}):)?(.+?)((?<! )#.+?)?(\|(.+?))?\]\]`, 'g');
  902. }
  903. function transformInline(text, {strings, to}) {
  904. return text.replace(transformInline.regexp, (match, _1, category, ref, hash, _2, enteredName, offset) => {
  905. if (!category) {
  906. category = 'track';
  907. }
  908. const {
  909. search: searchKey,
  910. link: linkKey,
  911. value: valueFn,
  912. html: htmlFn,
  913. transformName
  914. } = replacerSpec[category];
  915. const value = (
  916. valueFn ? valueFn(ref) :
  917. searchKey ? search[searchKey](ref) :
  918. {
  919. directory: ref.replace(category + ':', ''),
  920. name: null
  921. });
  922. if (!value) {
  923. logWarn`The link ${match} does not match anything!`;
  924. return match;
  925. }
  926. const label = (enteredName
  927. || transformName && transformName(value.name, match, offset, text)
  928. || value.name);
  929. if (!valueFn && !label) {
  930. logWarn`The link ${match} requires a label be entered!`;
  931. return match;
  932. }
  933. const fn = (htmlFn
  934. ? htmlFn
  935. : strings.link[linkKey]);
  936. try {
  937. return fn(value, {text: label, hash, strings, to});
  938. } catch (error) {
  939. logError`The link ${match} failed to be processed: ${error}`;
  940. return match;
  941. }
  942. }).replaceAll(String.raw`\[[`, '[[');
  943. }
  944. function parseAttributes(string, {to}) {
  945. const attributes = Object.create(null);
  946. const skipWhitespace = i => {
  947. const ws = /\s/;
  948. if (ws.test(string[i])) {
  949. const match = string.slice(i).match(/[^\s]/);
  950. if (match) {
  951. return i + match.index;
  952. } else {
  953. return string.length;
  954. }
  955. } else {
  956. return i;
  957. }
  958. };
  959. for (let i = 0; i < string.length;) {
  960. i = skipWhitespace(i);
  961. const aStart = i;
  962. const aEnd = i + string.slice(i).match(/[\s=]|$/).index;
  963. const attribute = string.slice(aStart, aEnd);
  964. i = skipWhitespace(aEnd);
  965. if (string[i] === '=') {
  966. i = skipWhitespace(i + 1);
  967. let end, endOffset;
  968. if (string[i] === '"' || string[i] === "'") {
  969. end = string[i];
  970. endOffset = 1;
  971. i++;
  972. } else {
  973. end = '\\s';
  974. endOffset = 0;
  975. }
  976. const vStart = i;
  977. const vEnd = i + string.slice(i).match(new RegExp(`${end}|$`)).index;
  978. const value = string.slice(vStart, vEnd);
  979. i = vEnd + endOffset;
  980. if (attribute === 'src' && value.startsWith('media/')) {
  981. attributes[attribute] = to('media.path', value.slice('media/'.length));
  982. } else {
  983. attributes[attribute] = value;
  984. }
  985. } else {
  986. attributes[attribute] = attribute;
  987. }
  988. }
  989. return Object.fromEntries(Object.entries(attributes).map(([ key, val ]) => [
  990. key,
  991. val === 'true' ? true :
  992. val === 'false' ? false :
  993. val === key ? true :
  994. val
  995. ]));
  996. }
  997. function transformMultiline(text, {strings, to}) {
  998. // Heck yes, HTML magics.
  999. text = transformInline(text.trim(), {strings, to});
  1000. const outLines = [];
  1001. const indentString = ' '.repeat(4);
  1002. let levelIndents = [];
  1003. const openLevel = indent => {
  1004. // opening a sublist is a pain: to be semantically *and* visually
  1005. // correct, we have to append the <ul> at the end of the existing
  1006. // previous <li>
  1007. const previousLine = outLines[outLines.length - 1];
  1008. if (previousLine?.endsWith('</li>')) {
  1009. // we will re-close the <li> later
  1010. outLines[outLines.length - 1] = previousLine.slice(0, -5) + ' <ul>';
  1011. } else {
  1012. // if the previous line isn't a list item, this is the opening of
  1013. // the first list level, so no need for indent
  1014. outLines.push('<ul>');
  1015. }
  1016. levelIndents.push(indent);
  1017. };
  1018. const closeLevel = () => {
  1019. levelIndents.pop();
  1020. if (levelIndents.length) {
  1021. // closing a sublist, so close the list item containing it too
  1022. outLines.push(indentString.repeat(levelIndents.length) + '</ul></li>');
  1023. } else {
  1024. // closing the final list level! no need for indent here
  1025. outLines.push('</ul>');
  1026. }
  1027. };
  1028. // okay yes we should support nested formatting, more than one blockquote
  1029. // layer, etc, but hear me out here: making all that work would basically
  1030. // be the same as implementing an entire markdown converter, which im not
  1031. // interested in doing lol. sorry!!!
  1032. let inBlockquote = false;
  1033. for (let line of text.split(/\r|\n|\r\n/)) {
  1034. const imageLine = line.startsWith('<img');
  1035. line = line.replace(/<img (.*?)>/g, (match, attributes) => img({
  1036. lazy: true,
  1037. link: true,
  1038. thumb: 'medium',
  1039. ...parseAttributes(attributes, {to})
  1040. }));
  1041. let indentThisLine = 0;
  1042. let lineContent = line;
  1043. let lineTag = 'p';
  1044. const listMatch = line.match(/^( *)- *(.*)$/);
  1045. if (listMatch) {
  1046. // is a list item!
  1047. if (!levelIndents.length) {
  1048. // first level is always indent = 0, regardless of actual line
  1049. // content (this is to avoid going to a lesser indent than the
  1050. // initial level)
  1051. openLevel(0);
  1052. } else {
  1053. // find level corresponding to indent
  1054. const indent = listMatch[1].length;
  1055. let i;
  1056. for (i = levelIndents.length - 1; i >= 0; i--) {
  1057. if (levelIndents[i] <= indent) break;
  1058. }
  1059. // note: i cannot equal -1 because the first indentation level
  1060. // is always 0, and the minimum indentation is also 0
  1061. if (levelIndents[i] === indent) {
  1062. // same indent! return to that level
  1063. while (levelIndents.length - 1 > i) closeLevel();
  1064. // (if this is already the current level, the above loop
  1065. // will do nothing)
  1066. } else if (levelIndents[i] < indent) {
  1067. // lesser indent! branch based on index
  1068. if (i === levelIndents.length - 1) {
  1069. // top level is lesser: add a new level
  1070. openLevel(indent);
  1071. } else {
  1072. // lower level is lesser: return to that level
  1073. while (levelIndents.length - 1 > i) closeLevel();
  1074. }
  1075. }
  1076. }
  1077. // finally, set variables for appending content line
  1078. indentThisLine = levelIndents.length;
  1079. lineContent = listMatch[2];
  1080. lineTag = 'li';
  1081. } else {
  1082. // not a list item! close any existing list levels
  1083. while (levelIndents.length) closeLevel();
  1084. // like i said, no nested shenanigans - quotes only appear outside
  1085. // of lists. sorry!
  1086. const quoteMatch = line.match(/^> *(.*)$/);
  1087. if (quoteMatch) {
  1088. // is a quote! open a blockquote tag if it doesnt already exist
  1089. if (!inBlockquote) {
  1090. inBlockquote = true;
  1091. outLines.push('<blockquote>');
  1092. }
  1093. indentThisLine = 1;
  1094. lineContent = quoteMatch[1];
  1095. } else if (inBlockquote) {
  1096. // not a quote! close a blockquote tag if it exists
  1097. inBlockquote = false;
  1098. outLines.push('</blockquote>');
  1099. }
  1100. }
  1101. if (lineTag === 'p') {
  1102. // certain inline element tags should still be postioned within a
  1103. // paragraph; other elements (e.g. headings) should be added as-is
  1104. const elementMatch = line.match(/^<(.*?)[ >]/);
  1105. if (elementMatch && !imageLine && !['a', 'abbr', 'b', 'bdo', 'br', 'cite', 'code', 'data', 'datalist', 'del', 'dfn', 'em', 'i', 'img', 'ins', 'kbd', 'mark', 'output', 'picture', 'q', 'ruby', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'svg', 'time', 'var', 'wbr'].includes(elementMatch[1])) {
  1106. lineTag = '';
  1107. }
  1108. }
  1109. let pushString = indentString.repeat(indentThisLine);
  1110. if (lineTag) {
  1111. pushString += `<${lineTag}>${lineContent}</${lineTag}>`;
  1112. } else {
  1113. pushString += lineContent;
  1114. }
  1115. outLines.push(pushString);
  1116. }
  1117. // after processing all lines...
  1118. // if still in a list, close all levels
  1119. while (levelIndents.length) closeLevel();
  1120. // if still in a blockquote, close its tag
  1121. if (inBlockquote) {
  1122. inBlockquote = false;
  1123. outLines.push('</blockquote>');
  1124. }
  1125. return outLines.join('\n');
  1126. }
  1127. function transformLyrics(text, {strings, to}) {
  1128. // Different from transformMultiline 'cuz it joins multiple lines together
  1129. // with line 8reaks (<br>); transformMultiline treats each line as its own
  1130. // complete paragraph (or list, etc).
  1131. // If it looks like old data, then like, oh god.
  1132. // Use the normal transformMultiline tool.
  1133. if (text.includes('<br')) {
  1134. return transformMultiline(text, {strings, to});
  1135. }
  1136. text = transformInline(text.trim(), {strings, to});
  1137. let buildLine = '';
  1138. const addLine = () => outLines.push(`<p>${buildLine}</p>`);
  1139. const outLines = [];
  1140. for (const line of text.split('\n')) {
  1141. if (line.length) {
  1142. if (buildLine.length) {
  1143. buildLine += '<br>';
  1144. }
  1145. buildLine += line;
  1146. } else if (buildLine.length) {
  1147. addLine();
  1148. buildLine = '';
  1149. }
  1150. }
  1151. if (buildLine.length) {
  1152. addLine();
  1153. }
  1154. return outLines.join('\n');
  1155. }
  1156. function getCommentaryField(lines) {
  1157. const text = getMultilineField(lines, 'Commentary');
  1158. if (text) {
  1159. const lines = text.split('\n');
  1160. if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) {
  1161. return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`};
  1162. }
  1163. return text;
  1164. } else {
  1165. return null;
  1166. }
  1167. };
  1168. async function processAlbumDataFile(file) {
  1169. let contents;
  1170. try {
  1171. contents = await readFile(file, 'utf-8');
  1172. } catch (error) {
  1173. // This function can return "error o8jects," which are really just
  1174. // ordinary o8jects with an error message attached. I'm not 8othering
  1175. // with error codes here or anywhere in this function; while this would
  1176. // normally 8e 8ad coding practice, it doesn't really matter here,
  1177. // 8ecause this isn't an API getting consumed 8y other services (e.g.
  1178. // translaction functions). If we return an error, the caller will just
  1179. // print the attached message in the output summary.
  1180. return {error: `Could not read ${file} (${error.code}).`};
  1181. }
  1182. // We're pro8a8ly supposed to, like, search for a header somewhere in the
  1183. // al8um contents, to make sure it's trying to 8e the intended structure
  1184. // and is a valid utf-8 (or at least ASCII) file. 8ut like, whatever.
  1185. // We'll just return more specific errors if it's missing necessary data
  1186. // fields.
  1187. const contentLines = contents.split('\n');
  1188. // In this line of code I defeat the purpose of using a generator in the
  1189. // first place. Sorry!!!!!!!!
  1190. const sections = Array.from(getSections(contentLines));
  1191. const albumSection = sections[0];
  1192. const album = {};
  1193. album.name = getBasicField(albumSection, 'Album');
  1194. album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist');
  1195. album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art');
  1196. album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style');
  1197. album.bannerArtists = getContributionField(albumSection, 'Banner Art');
  1198. album.bannerStyle = getMultilineField(albumSection, 'Banner Style');
  1199. album.date = getBasicField(albumSection, 'Date');
  1200. album.trackArtDate = getBasicField(albumSection, 'Track Art Date') || album.date;
  1201. album.coverArtDate = getBasicField(albumSection, 'Cover Art Date') || album.date;
  1202. album.dateAdded = getBasicField(albumSection, 'Date Added');
  1203. album.coverArtists = getContributionField(albumSection, 'Cover Art');
  1204. album.hasTrackArt = getBooleanField(albumSection, 'Has Track Art') ?? true;
  1205. album.trackCoverArtists = getContributionField(albumSection, 'Track Art');
  1206. album.artTags = getListField(albumSection, 'Art Tags') || [];
  1207. album.commentary = getCommentaryField(albumSection);
  1208. album.urls = getListField(albumSection, 'URLs') || [];
  1209. album.groups = getListField(albumSection, 'Groups') || [];
  1210. album.directory = getBasicField(albumSection, 'Directory');
  1211. album.isMajorRelease = getBooleanField(albumSection, 'Major Release') ?? false;
  1212. if (album.artists && album.artists.error) {
  1213. return {error: `${album.artists.error} (in ${album.name})`};
  1214. }
  1215. if (album.coverArtists && album.coverArtists.error) {
  1216. return {error: `${album.coverArtists.error} (in ${album.name})`};
  1217. }
  1218. if (album.commentary && album.commentary.error) {
  1219. return {error: `${album.commentary.error} (in ${album.name})`};
  1220. }
  1221. if (album.trackCoverArtists && album.trackCoverArtists.error) {
  1222. return {error: `${album.trackCoverArtists.error} (in ${album.name})`};
  1223. }
  1224. if (!album.coverArtists) {
  1225. return {error: `The album "${album.name}" is missing the "Cover Art" field.`};
  1226. }
  1227. album.color = (
  1228. getBasicField(albumSection, 'Color') ||
  1229. getBasicField(albumSection, 'FG')
  1230. );
  1231. if (!album.name) {
  1232. return {error: `Expected "Album" (name) field!`};
  1233. }
  1234. if (!album.date) {
  1235. return {error: `Expected "Date" field! (in ${album.name})`};
  1236. }
  1237. if (!album.dateAdded) {
  1238. return {error: `Expected "Date Added" field! (in ${album.name})`};
  1239. }
  1240. if (isNaN(Date.parse(album.date))) {
  1241. return {error: `Invalid Date field: "${album.date}" (in ${album.name})`};
  1242. }
  1243. if (isNaN(Date.parse(album.trackArtDate))) {
  1244. return {error: `Invalid Track Art Date field: "${album.trackArtDate}" (in ${album.name})`};
  1245. }
  1246. if (isNaN(Date.parse(album.coverArtDate))) {
  1247. return {error: `Invalid Cover Art Date field: "${album.coverArtDate}" (in ${album.name})`};
  1248. }
  1249. if (isNaN(Date.parse(album.dateAdded))) {
  1250. return {error: `Invalid Date Added field: "${album.dateAdded}" (in ${album.name})`};
  1251. }
  1252. album.date = new Date(album.date);
  1253. album.trackArtDate = new Date(album.trackArtDate);
  1254. album.coverArtDate = new Date(album.coverArtDate);
  1255. album.dateAdded = new Date(album.dateAdded);
  1256. if (!album.directory) {
  1257. album.directory = C.getKebabCase(album.name);
  1258. }
  1259. album.tracks = [];
  1260. // will be overwritten if a group section is found!
  1261. album.trackGroups = null;
  1262. let group = null;
  1263. let trackIndex = 0;
  1264. for (const section of sections.slice(1)) {
  1265. // Just skip empty sections. Sometimes I paste a 8unch of dividers,
  1266. // and this lets the empty sections doing that creates (temporarily)
  1267. // exist without raising an error.
  1268. if (!section.filter(Boolean).length) {
  1269. continue;
  1270. }
  1271. const groupName = getBasicField(section, 'Group');
  1272. if (groupName) {
  1273. group = {
  1274. name: groupName,
  1275. color: (
  1276. getBasicField(section, 'Color') ||
  1277. getBasicField(section, 'FG') ||
  1278. album.color
  1279. ),
  1280. startIndex: trackIndex,
  1281. tracks: []
  1282. };
  1283. if (album.trackGroups) {
  1284. album.trackGroups.push(group);
  1285. } else {
  1286. album.trackGroups = [group];
  1287. }
  1288. continue;
  1289. }
  1290. trackIndex++;
  1291. const track = {};
  1292. track.name = getBasicField(section, 'Track');
  1293. track.commentary = getCommentaryField(section);
  1294. track.lyrics = getMultilineField(section, 'Lyrics');
  1295. track.originalDate = getBasicField(section, 'Original Date');
  1296. track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate;
  1297. track.references = getListField(section, 'References') || [];
  1298. track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist');
  1299. track.coverArtists = getContributionField(section, 'Track Art');
  1300. track.artTags = getListField(section, 'Art Tags') || [];
  1301. track.contributors = getContributionField(section, 'Contributors') || [];
  1302. track.directory = getBasicField(section, 'Directory');
  1303. track.aka = getBasicField(section, 'AKA');
  1304. if (!track.name) {
  1305. return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`};
  1306. }
  1307. let durationString = getBasicField(section, 'Duration') || '0:00';
  1308. track.duration = getDurationInSeconds(durationString);
  1309. if (track.contributors.error) {
  1310. return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`};
  1311. }
  1312. if (track.commentary && track.commentary.error) {
  1313. return {error: `${track.commentary.error} (in ${track.name}, ${album.name})`};
  1314. }
  1315. if (!track.artists) {
  1316. // If an al8um has an artist specified (usually 8ecause it's a solo
  1317. // al8um), let tracks inherit that artist. We won't display the
  1318. // "8y <artist>" string on the al8um listing.
  1319. if (album.artists) {
  1320. track.artists = album.artists;
  1321. } else {
  1322. return {error: `The track "${track.name}" is missing the "Artist" field (in ${album.name}).`};
  1323. }
  1324. }
  1325. if (!track.coverArtists) {
  1326. if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) {
  1327. if (album.trackCoverArtists) {
  1328. track.coverArtists = album.trackCoverArtists;
  1329. } else {
  1330. return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`};
  1331. }
  1332. }
  1333. }
  1334. if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') {
  1335. track.coverArtists = null;
  1336. }
  1337. if (!track.directory) {
  1338. track.directory = C.getKebabCase(track.name);
  1339. }
  1340. if (track.originalDate) {
  1341. if (isNaN(Date.parse(track.originalDate))) {
  1342. return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`};
  1343. }
  1344. track.date = new Date(track.originalDate);
  1345. } else {
  1346. track.date = album.date;
  1347. }
  1348. track.coverArtDate = new Date(track.coverArtDate);
  1349. const hasURLs = getBooleanField(section, 'Has URLs') ?? true;
  1350. track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean);
  1351. if (hasURLs && !track.urls.length) {
  1352. return {error: `The track "${track.name}" should have at least one URL specified.`};
  1353. }
  1354. // 8ack-reference the al8um o8ject! This is very useful for when
  1355. // we're outputting the track pages.
  1356. track.album = album;
  1357. if (group) {
  1358. track.color = group.color;
  1359. group.tracks.push(track);
  1360. } else {
  1361. track.color = album.color;
  1362. }
  1363. album.tracks.push(track);
  1364. }
  1365. return album;
  1366. }
  1367. async function processArtistDataFile(file) {
  1368. let contents;
  1369. try {
  1370. contents = await readFile(file, 'utf-8');
  1371. } catch (error) {
  1372. return {error: `Could not read ${file} (${error.code}).`};
  1373. }
  1374. const contentLines = contents.split('\n');
  1375. const sections = Array.from(getSections(contentLines));
  1376. return sections.filter(s => s.filter(Boolean).length).map(section => {
  1377. const name = getBasicField(section, 'Artist');
  1378. const urls = (getListField(section, 'URLs') || []).filter(Boolean);
  1379. const alias = getBasicField(section, 'Alias');
  1380. const hasAvatar = getBooleanField(section, 'Has Avatar') ?? false;
  1381. const note = getMultilineField(section, 'Note');
  1382. let directory = getBasicField(section, 'Directory');
  1383. if (!name) {
  1384. return {error: 'Expected "Artist" (name) field!'};
  1385. }
  1386. if (!directory) {
  1387. directory = C.getArtistDirectory(name);
  1388. }
  1389. if (alias) {
  1390. return {name, directory, alias};
  1391. } else {
  1392. return {name, directory, urls, note, hasAvatar};
  1393. }
  1394. });
  1395. }
  1396. async function processFlashDataFile(file) {
  1397. let contents;
  1398. try {
  1399. contents = await readFile(file, 'utf-8');
  1400. } catch (error) {
  1401. return {error: `Could not read ${file} (${error.code}).`};
  1402. }
  1403. const contentLines = contents.split('\n');
  1404. const sections = Array.from(getSections(contentLines));
  1405. let act, color;
  1406. return sections.map(section => {
  1407. if (getBasicField(section, 'ACT')) {
  1408. act = getBasicField(section, 'ACT');
  1409. color = (
  1410. getBasicField(section, 'Color') ||
  1411. getBasicField(section, 'FG')
  1412. );
  1413. const anchor = getBasicField(section, 'Anchor');
  1414. const jump = getBasicField(section, 'Jump');
  1415. const jumpColor = getBasicField(section, 'Jump Color') || color;
  1416. return {act8r8k: true, name: act, color, anchor, jump, jumpColor};
  1417. }
  1418. const name = getBasicField(section, 'Flash');
  1419. let page = getBasicField(section, 'Page');
  1420. let directory = getBasicField(section, 'Directory');
  1421. let date = getBasicField(section, 'Date');
  1422. const jiff = getBasicField(section, 'Jiff');
  1423. const tracks = getListField(section, 'Tracks') || [];
  1424. const contributors = getContributionField(section, 'Contributors') || [];
  1425. const urls = (getListField(section, 'URLs') || []).filter(Boolean);
  1426. if (!name) {
  1427. return {error: 'Expected "Flash" (name) field!'};
  1428. }
  1429. if (!page && !directory) {
  1430. return {error: 'Expected "Page" or "Directory" field!'};
  1431. }
  1432. if (!directory) {
  1433. directory = page;
  1434. }
  1435. if (!date) {
  1436. return {error: 'Expected "Date" field!'};
  1437. }
  1438. if (isNaN(Date.parse(date))) {
  1439. return {error: `Invalid Date field: "${date}"`};
  1440. }
  1441. date = new Date(date);
  1442. return {name, page, directory, date, contributors, tracks, urls, act, color, jiff};
  1443. });
  1444. }
  1445. async function processNewsDataFile(file) {
  1446. let contents;
  1447. try {
  1448. contents = await readFile(file, 'utf-8');
  1449. } catch (error) {
  1450. return {error: `Could not read ${file} (${error.code}).`};
  1451. }
  1452. const contentLines = contents.split('\n');
  1453. const sections = Array.from(getSections(contentLines));
  1454. return sections.map(section => {
  1455. const name = getBasicField(section, 'Name');
  1456. if (!name) {
  1457. return {error: 'Expected "Name" field!'};
  1458. }
  1459. const directory = getBasicField(section, 'Directory') || getBasicField(section, 'ID');
  1460. if (!directory) {
  1461. return {error: 'Expected "Directory" field!'};
  1462. }
  1463. let body = getMultilineField(section, 'Body');
  1464. if (!body) {
  1465. return {error: 'Expected "Body" field!'};
  1466. }
  1467. let date = getBasicField(section, 'Date');
  1468. if (!date) {
  1469. return {error: 'Expected "Date" field!'};
  1470. }
  1471. if (isNaN(Date.parse(date))) {
  1472. return {error: `Invalid date field: "${date}"`};
  1473. }
  1474. date = new Date(date);
  1475. let bodyShort = body.split('<hr class="split">')[0];
  1476. return {
  1477. name,
  1478. directory,
  1479. body,
  1480. bodyShort,
  1481. date
  1482. };
  1483. });
  1484. }
  1485. async function processTagDataFile(file) {
  1486. let contents;
  1487. try {
  1488. contents = await readFile(file, 'utf-8');
  1489. } catch (error) {
  1490. if (error.code === 'ENOENT') {
  1491. return [];
  1492. } else {
  1493. return {error: `Could not read ${file} (${error.code}).`};
  1494. }
  1495. }
  1496. const contentLines = contents.split('\n');
  1497. const sections = Array.from(getSections(contentLines));
  1498. return sections.map(section => {
  1499. let isCW = false;
  1500. let name = getBasicField(section, 'Tag');
  1501. if (!name) {
  1502. name = getBasicField(section, 'CW');
  1503. isCW = true;
  1504. if (!name) {
  1505. return {error: 'Expected "Tag" or "CW" field!'};
  1506. }
  1507. }
  1508. let color;
  1509. if (!isCW) {
  1510. color = getBasicField(section, 'Color');
  1511. if (!color) {
  1512. return {error: 'Expected "Color" field!'};
  1513. }
  1514. }
  1515. const directory = C.getKebabCase(name);
  1516. return {
  1517. name,
  1518. directory,
  1519. isCW,
  1520. color
  1521. };
  1522. });
  1523. }
  1524. async function processGroupDataFile(file) {
  1525. let contents;
  1526. try {
  1527. contents = await readFile(file, 'utf-8');
  1528. } catch (error) {
  1529. if (error.code === 'ENOENT') {
  1530. return [];
  1531. } else {
  1532. return {error: `Could not read ${file} (${error.code}).`};
  1533. }
  1534. }
  1535. const contentLines = contents.split('\n');
  1536. const sections = Array.from(getSections(contentLines));
  1537. let category, color;
  1538. return sections.map(section => {
  1539. if (getBasicField(section, 'Category')) {
  1540. category = getBasicField(section, 'Category');
  1541. color = getBasicField(section, 'Color');
  1542. return {isCategory: true, name: category, color};
  1543. }
  1544. const name = getBasicField(section, 'Group');
  1545. if (!name) {
  1546. return {error: 'Expected "Group" field!'};
  1547. }
  1548. let directory = getBasicField(section, 'Directory');
  1549. if (!directory) {
  1550. directory = C.getKebabCase(name);
  1551. }
  1552. let description = getMultilineField(section, 'Description');
  1553. if (!description) {
  1554. return {error: 'Expected "Description" field!'};
  1555. }
  1556. let descriptionShort = description.split('<hr class="split">')[0];
  1557. const urls = (getListField(section, 'URLs') || []).filter(Boolean);
  1558. return {
  1559. isGroup: true,
  1560. name,
  1561. directory,
  1562. description,
  1563. descriptionShort,
  1564. urls,
  1565. category,
  1566. color
  1567. };
  1568. });
  1569. }
  1570. async function processStaticPageDataFile(file) {
  1571. let contents;
  1572. try {
  1573. contents = await readFile(file, 'utf-8');
  1574. } catch (error) {
  1575. if (error.code === 'ENOENT') {
  1576. return [];
  1577. } else {
  1578. return {error: `Could not read ${file} (${error.code}).`};
  1579. }
  1580. }
  1581. const contentLines = contents.split('\n');
  1582. const sections = Array.from(getSections(contentLines));
  1583. return sections.map(section => {
  1584. const name = getBasicField(section, 'Name');
  1585. if (!name) {
  1586. return {error: 'Expected "Name" field!'};
  1587. }
  1588. const shortName = getBasicField(section, 'Short Name') || name;
  1589. let directory = getBasicField(section, 'Directory');
  1590. if (!directory) {
  1591. return {error: 'Expected "Directory" field!'};
  1592. }
  1593. let content = getMultilineField(section, 'Content');
  1594. if (!content) {
  1595. return {error: 'Expected "Content" field!'};
  1596. }
  1597. let stylesheet = getMultilineField(section, 'Style') || '';
  1598. let listed = getBooleanField(section, 'Listed') ?? true;
  1599. return {
  1600. name,
  1601. shortName,
  1602. directory,
  1603. content,
  1604. stylesheet,
  1605. listed
  1606. };
  1607. });
  1608. }
  1609. async function processWikiInfoFile(file) {
  1610. let contents;
  1611. try {
  1612. contents = await readFile(file, 'utf-8');
  1613. } catch (error) {
  1614. return {error: `Could not read ${file} (${error.code}).`};
  1615. }
  1616. // Unlike other data files, the site info data file isn't 8roken up into
  1617. // more than one entry. So we operate on the plain old contentLines array,
  1618. // rather than dividing into sections like we usually do!
  1619. const contentLines = contents.split('\n');
  1620. const name = getBasicField(contentLines, 'Name');
  1621. if (!name) {
  1622. return {error: 'Expected "Name" field!'};
  1623. }
  1624. const shortName = getBasicField(contentLines, 'Short Name') || name;
  1625. const color = getBasicField(contentLines, 'Color') || '#0088ff';
  1626. // This is optional! Without it, <meta rel="canonical"> tags won't 8e
  1627. // gener8ted.
  1628. const canonicalBase = getBasicField(contentLines, 'Canonical Base');
  1629. // This is optional! Without it, the site will default to 8uilding in
  1630. // English. (This is only really relevant if you've provided string files
  1631. // for non-English languages.)
  1632. const defaultLanguage = getBasicField(contentLines, 'Default Language');
  1633. // Also optional! In charge of <meta rel="description">.
  1634. const description = getBasicField(contentLines, 'Description');
  1635. const footer = getMultilineField(contentLines, 'Footer') || '';
  1636. // We've had a comment lying around for ages, just reading:
  1637. // "Might ena8le this later... we'll see! Eventually. May8e."
  1638. // We still haven't! 8ut hey, the option's here.
  1639. const enableArtistAvatars = getBooleanField(contentLines, 'Enable Artist Avatars') ?? false;
  1640. const enableFlashesAndGames = getBooleanField(contentLines, 'Enable Flashes & Games') ?? false;
  1641. const enableListings = getBooleanField(contentLines, 'Enable Listings') ?? false;
  1642. const enableNews = getBooleanField(contentLines, 'Enable News') ?? false;
  1643. const enableArtTagUI = getBooleanField(contentLines, 'Enable Art Tag UI') ?? false;
  1644. const enableGroupUI = getBooleanField(contentLines, 'Enable Group UI') ?? false;
  1645. return {
  1646. name,
  1647. shortName,
  1648. color,
  1649. canonicalBase,
  1650. defaultLanguage,
  1651. description,
  1652. footer,
  1653. features: {
  1654. artistAvatars: enableArtistAvatars,
  1655. flashesAndGames: enableFlashesAndGames,
  1656. listings: enableListings,
  1657. news: enableNews,
  1658. artTagUI: enableArtTagUI,
  1659. groupUI: enableGroupUI
  1660. }
  1661. };
  1662. }
  1663. async function processHomepageInfoFile(file) {
  1664. let contents;
  1665. try {
  1666. contents = await readFile(file, 'utf-8');
  1667. } catch (error) {
  1668. return {error: `Could not read ${file} (${error.code}).`};
  1669. }
  1670. const contentLines = contents.split('\n');
  1671. const sections = Array.from(getSections(contentLines));
  1672. const [ firstSection, ...rowSections ] = sections;
  1673. const sidebar = getMultilineField(firstSection, 'Sidebar');
  1674. const validRowTypes = ['albums'];
  1675. const rows = rowSections.map(section => {
  1676. const name = getBasicField(section, 'Row');
  1677. if (!name) {
  1678. return {error: 'Expected "Row" (name) field!'};
  1679. }
  1680. const color = getBasicField(section, 'Color');
  1681. const type = getBasicField(section, 'Type');
  1682. if (!type) {
  1683. return {error: 'Expected "Type" field!'};
  1684. }
  1685. if (!validRowTypes.includes(type)) {
  1686. return {error: `Expected "Type" field to be one of: ${validRowTypes.join(', ')}`};
  1687. }
  1688. const row = {name, color, type};
  1689. switch (type) {
  1690. case 'albums': {
  1691. const group = getBasicField(section, 'Group') || null;
  1692. const albums = getListField(section, 'Albums') || [];
  1693. if (!group && !albums) {
  1694. return {error: 'Expected "Group" and/or "Albums" field!'};
  1695. }
  1696. let groupCount = getBasicField(section, 'Count');
  1697. if (group && !groupCount) {
  1698. return {error: 'Expected "Count" field!'};
  1699. }
  1700. if (groupCount) {
  1701. if (isNaN(parseInt(groupCount))) {
  1702. return {error: `Invalid Count field: "${groupCount}"`};
  1703. }
  1704. groupCount = parseInt(groupCount);
  1705. }
  1706. const actions = getListField(section, 'Actions') || [];
  1707. return {...row, group, groupCount, albums, actions};
  1708. }
  1709. }
  1710. });
  1711. return {sidebar, rows};
  1712. }
  1713. function getDurationInSeconds(string) {
  1714. const parts = string.split(':').map(n => parseInt(n))
  1715. if (parts.length === 3) {
  1716. return parts[0] * 3600 + parts[1] * 60 + parts[2]
  1717. } else if (parts.length === 2) {
  1718. return parts[0] * 60 + parts[1]
  1719. } else {
  1720. return 0
  1721. }
  1722. }
  1723. function getTotalDuration(tracks) {
  1724. return tracks.reduce((duration, track) => duration + track.duration, 0);
  1725. }
  1726. const stringifyIndent = 0;
  1727. const toRefs = (label, objectOrArray) => {
  1728. if (Array.isArray(objectOrArray)) {
  1729. return objectOrArray.filter(Boolean).map(x => `${label}:${x.directory}`);
  1730. } else if (objectOrArray.directory) {
  1731. throw new Error('toRefs should not be passed a single object with directory');
  1732. } else if (typeof objectOrArray === 'object') {
  1733. return Object.fromEntries(Object.entries(objectOrArray)
  1734. .map(([ key, value ]) => [key, toRefs(key, value)]));
  1735. } else {
  1736. throw new Error('toRefs should be passed an array or object of arrays');
  1737. }
  1738. };
  1739. function stringifyRefs(key, value) {
  1740. switch (key) {
  1741. case 'tracks':
  1742. case 'references':
  1743. case 'referencedBy':
  1744. return toRefs('track', value);
  1745. case 'artists':
  1746. case 'contributors':
  1747. case 'coverArtists':
  1748. case 'trackCoverArtists':
  1749. return value && value.map(({ who, what }) => ({who: `artist:${who.directory}`, what}));
  1750. case 'albums': return toRefs('album', value);
  1751. case 'flashes': return toRefs('flash', value);
  1752. case 'groups': return toRefs('group', value);
  1753. case 'artTags': return toRefs('tag', value);
  1754. case 'aka': return value && `track:${value.directory}`;
  1755. default:
  1756. return value;
  1757. }
  1758. }
  1759. function stringifyAlbumData() {
  1760. return JSON.stringify(albumData, (key, value) => {
  1761. switch (key) {
  1762. case 'commentary':
  1763. return '';
  1764. default:
  1765. return stringifyRefs(key, value);
  1766. }
  1767. }, stringifyIndent);
  1768. }
  1769. function stringifyTrackData() {
  1770. return JSON.stringify(trackData, (key, value) => {
  1771. switch (key) {
  1772. case 'album':
  1773. case 'commentary':
  1774. case 'otherReleases':
  1775. return undefined;
  1776. default:
  1777. return stringifyRefs(key, value);
  1778. }
  1779. }, stringifyIndent);
  1780. }
  1781. function stringifyFlashData() {
  1782. return JSON.stringify(flashData, (key, value) => {
  1783. switch (key) {
  1784. case 'act':
  1785. case 'commentary':
  1786. return undefined;
  1787. default:
  1788. return stringifyRefs(key, value);
  1789. }
  1790. }, stringifyIndent);
  1791. }
  1792. function stringifyArtistData() {
  1793. return JSON.stringify(artistData, (key, value) => {
  1794. switch (key) {
  1795. case 'asAny':
  1796. return;
  1797. case 'asArtist':
  1798. case 'asContributor':
  1799. case 'asCoverArtist':
  1800. return toRefs('track', value);
  1801. default:
  1802. return stringifyRefs(key, value);
  1803. }
  1804. }, stringifyIndent);
  1805. }
  1806. function img({
  1807. src = '',
  1808. alt = '',
  1809. thumb: thumbKey = '',
  1810. reveal = '',
  1811. id = '',
  1812. class: className = '',
  1813. width = '',
  1814. height = '',
  1815. link = false,
  1816. lazy = false,
  1817. square = false
  1818. }) {
  1819. const willSquare = square;
  1820. const willLink = typeof link === 'string' || link;
  1821. const originalSrc = src;
  1822. const thumbSrc = thumbKey ? thumb[thumbKey](src) : src;
  1823. const imgAttributes = html.attributes({
  1824. id: link ? '' : id,
  1825. class: className,
  1826. alt,
  1827. width,
  1828. height
  1829. });
  1830. const nonlazyHTML = wrap(`<img src="${thumbSrc}" ${imgAttributes}>`);
  1831. const lazyHTML = lazy && wrap(`<img class="lazy" data-original="${thumbSrc}" ${imgAttributes}>`, true);
  1832. if (lazy) {
  1833. return fixWS`
  1834. <noscript>${nonlazyHTML}</noscript>
  1835. ${lazyHTML}
  1836. `;
  1837. } else {
  1838. return nonlazyHTML;
  1839. }
  1840. function wrap(input, hide = false) {
  1841. let wrapped = input;
  1842. wrapped = `<div class="image-inner-area">${wrapped}</div>`;
  1843. wrapped = `<div class="image-container">${wrapped}</div>`;
  1844. if (reveal) {
  1845. wrapped = fixWS`
  1846. <div class="reveal">
  1847. ${wrapped}
  1848. <span class="reveal-text">${reveal}</span>
  1849. </div>
  1850. `;
  1851. }
  1852. if (willSquare) {
  1853. wrapped = html.tag('div', {class: 'square-content'}, wrapped);
  1854. wrapped = html.tag('div', {class: ['square', hide && !willLink && 'js-hide']}, wrapped);
  1855. }
  1856. if (willLink) {
  1857. wrapped = html.tag('a', {
  1858. id,
  1859. class: ['box', hide && 'js-hide'],
  1860. href: typeof link === 'string' ? link : originalSrc
  1861. }, wrapped);
  1862. }
  1863. return wrapped;
  1864. }
  1865. }
  1866. function serializeImagePaths(original) {
  1867. return {
  1868. original,
  1869. medium: thumb.medium(original),
  1870. small: thumb.small(original)
  1871. };
  1872. }
  1873. function serializeLink(thing) {
  1874. const ret = {};
  1875. ret.name = thing.name;
  1876. ret.directory = thing.directory;
  1877. if (thing.color) ret.color = thing.color;
  1878. return ret;
  1879. }
  1880. function serializeContribs(contribs) {
  1881. return contribs.map(({ who, what }) => {
  1882. const ret = {};
  1883. ret.artist = serializeLink(who);
  1884. if (what) ret.contribution = what;
  1885. return ret;
  1886. });
  1887. }
  1888. function serializeCover(thing, pathFunction) {
  1889. const coverPath = pathFunction(thing, {
  1890. to: urls.from('media.root').to
  1891. });
  1892. const { artTags } = thing;
  1893. const cwTags = artTags.filter(tag => tag.isCW);
  1894. const linkTags = artTags.filter(tag => !tag.isCW);
  1895. return {
  1896. paths: serializeImagePaths(coverPath),
  1897. tags: linkTags.map(serializeLink),
  1898. warnings: cwTags.map(tag => tag.name)
  1899. };
  1900. }
  1901. function serializeGroupsForAlbum(album) {
  1902. return album.groups.map(group => {
  1903. const index = group.albums.indexOf(album);
  1904. const next = group.albums[index + 1] || null;
  1905. const previous = group.albums[index - 1] || null;
  1906. return {group, index, next, previous};
  1907. }).map(({group, index, next, previous}) => ({
  1908. link: serializeLink(group),
  1909. descriptionShort: group.descriptionShort,
  1910. albumIndex: index,
  1911. nextAlbum: next && serializeLink(next),
  1912. previousAlbum: previous && serializeLink(previous),
  1913. urls: group.urls
  1914. }));
  1915. }
  1916. function serializeGroupsForTrack(track) {
  1917. return track.album.groups.map(group => ({
  1918. link: serializeLink(group),
  1919. urls: group.urls,
  1920. }));
  1921. }
  1922. function validateWritePath(path, urlGroup) {
  1923. if (!Array.isArray(path)) {
  1924. return {error: `Expected array, got ${path}`};
  1925. }
  1926. const { paths } = urlGroup;
  1927. const definedKeys = Object.keys(paths);
  1928. const specifiedKey = path[0];
  1929. if (!definedKeys.includes(specifiedKey)) {
  1930. return {error: `Specified key ${specifiedKey} isn't defined`};
  1931. }
  1932. const expectedArgs = paths[specifiedKey].match(/<>/g).length;
  1933. const specifiedArgs = path.length - 1;
  1934. if (specifiedArgs !== expectedArgs) {
  1935. return {error: `Expected ${expectedArgs} arguments, got ${specifiedArgs}`};
  1936. }
  1937. return {success: true};
  1938. }
  1939. function validateWriteObject(obj) {
  1940. if (typeof obj !== 'object') {
  1941. return {error: `Expected object, got ${typeof obj}`};
  1942. }
  1943. if (typeof obj.type !== 'string') {
  1944. return {error: `Expected type to be string, got ${obj.type}`};
  1945. }
  1946. switch (obj.type) {
  1947. case 'legacy': {
  1948. if (typeof obj.write !== 'function') {
  1949. return {error: `Expected write to be string, got ${obj.write}`};
  1950. }
  1951. break;
  1952. }
  1953. case 'page': {
  1954. const path = validateWritePath(obj.path, urlSpec.localized);
  1955. if (path.error) {
  1956. return {error: `Path validation failed: ${path.error}`};
  1957. }
  1958. if (typeof obj.page !== 'function') {
  1959. return {error: `Expected page to be function, got ${obj.content}`};
  1960. }
  1961. break;
  1962. }
  1963. case 'data': {
  1964. const path = validateWritePath(obj.path, urlSpec.data);
  1965. if (path.error) {
  1966. return {error: `Path validation failed: ${path.error}`};
  1967. }
  1968. if (typeof obj.data !== 'function') {
  1969. return {error: `Expected data to be function, got ${obj.data}`};
  1970. }
  1971. break;
  1972. }
  1973. default: {
  1974. return {error: `Unknown type: ${obj.type}`};
  1975. }
  1976. }
  1977. return {success: true};
  1978. }
  1979. async function writeData(subKey, directory, file, data) {
  1980. const paths = writePage.paths('', 'data.' + subKey, directory, {file});
  1981. await writePage.write(JSON.stringify(data), {paths});
  1982. }
  1983. async function writePage(strings, baseDirectory, pageSubKey, directory, pageFn) {
  1984. // Generally this function shouldn't 8e called directly - instead use the
  1985. // shadowed version provided 8y wrapLanguages, which automatically provides
  1986. // the appropriate baseDirectory and strings arguments. (The utility
  1987. // functions attached to this function are generally useful, though!)
  1988. const paths = writePage.paths(baseDirectory, 'localized.' + pageSubKey, directory);
  1989. const to = (targetFullKey, ...args) => {
  1990. const [ groupKey, subKey ] = targetFullKey.split('.')[0];
  1991. let path = paths.subdirectoryPrefix
  1992. // When linking to *outside* the localized area of the site, we need to
  1993. // make sure the result is correctly relative to the 8ase directory.
  1994. if (groupKey !== 'localized' && baseDirectory) {
  1995. path += urls.from('localizedWithBaseDirectory.' + pageSubKey).to(targetFullKey, ...args);
  1996. } else {
  1997. // If we're linking inside the localized area (or there just is no
  1998. // 8ase directory), the 8ase directory doesn't matter.
  1999. path += urls.from('localized.' + pageSubKey).to(targetFullKey, ...args);
  2000. }
  2001. // console.log(pageSubKey, '->', targetFullKey, '=', path);
  2002. return path;
  2003. };
  2004. const content = writePage.html(pageFn, {paths, strings, to});
  2005. await writePage.write(content, {paths});
  2006. }
  2007. writePage.html = (pageFn, {paths, strings, to}) => {
  2008. let {
  2009. title = '',
  2010. meta = {},
  2011. theme = '',
  2012. stylesheet = '',
  2013. // missing properties are auto-filled, see below!
  2014. body = {},
  2015. banner = {},
  2016. main = {},
  2017. sidebarLeft = {},
  2018. sidebarRight = {},
  2019. nav = {},
  2020. footer = {}
  2021. } = pageFn({to});
  2022. body.style ??= '';
  2023. theme = theme || getThemeString(wikiInfo);
  2024. banner ||= {};
  2025. banner.classes ??= [];
  2026. banner.src ??= '';
  2027. banner.position ??= '';
  2028. main.classes ??= [];
  2029. main.content ??= '';
  2030. sidebarLeft ??= {};
  2031. sidebarRight ??= {};
  2032. for (const sidebar of [sidebarLeft, sidebarRight]) {
  2033. sidebar.classes ??= [];
  2034. sidebar.content ??= '';
  2035. sidebar.collapse ??= true;
  2036. }
  2037. nav.classes ??= [];
  2038. nav.content ??= '';
  2039. nav.links ??= [];
  2040. footer.classes ??= [];
  2041. footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer, {strings, to}) : '');
  2042. const canonical = (wikiInfo.canonicalBase
  2043. ? wikiInfo.canonicalBase + paths.pathname
  2044. : '');
  2045. const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false);
  2046. const mainHTML = main.content && fixWS`
  2047. <main id="content" ${classes(...main.classes || [])}>
  2048. ${main.content}
  2049. </main>
  2050. `;
  2051. const footerHTML = footer.content && fixWS`
  2052. <footer id="footer" ${classes(...footer.classes || [])}>
  2053. ${footer.content}
  2054. </footer>
  2055. `;
  2056. const generateSidebarHTML = (id, {
  2057. content,
  2058. multiple,
  2059. classes: sidebarClasses = [],
  2060. collapse = true,
  2061. wide = false
  2062. }) => (content ? fixWS`
  2063. <div id="${id}" ${classes(
  2064. 'sidebar-column',
  2065. 'sidebar',
  2066. wide && 'wide',
  2067. !collapse && 'no-hide',
  2068. ...sidebarClasses
  2069. )}>
  2070. ${content}
  2071. </div>
  2072. ` : multiple ? fixWS`
  2073. <div id="${id}" ${classes(
  2074. 'sidebar-column',
  2075. 'sidebar-multiple',
  2076. wide && 'wide',
  2077. !collapse && 'no-hide'
  2078. )}>
  2079. ${multiple.map(content => fixWS`
  2080. <div ${classes(
  2081. 'sidebar',
  2082. ...sidebarClasses
  2083. )}>
  2084. ${content}
  2085. </div>
  2086. `).join('\n')}
  2087. </div>
  2088. ` : '');
  2089. const sidebarLeftHTML = generateSidebarHTML('sidebar-left', sidebarLeft);
  2090. const sidebarRightHTML = generateSidebarHTML('sidebar-right', sidebarRight);
  2091. if (nav.simple) {
  2092. nav.links = [
  2093. {
  2094. href: to('localized.home'),
  2095. title: wikiInfo.shortName
  2096. },
  2097. {
  2098. href: '',
  2099. title
  2100. }
  2101. ];
  2102. }
  2103. const links = (nav.links || []).filter(Boolean);
  2104. const navLinkParts = [];
  2105. for (let i = 0; i < links.length; i++) {
  2106. const link = links[i];
  2107. const prev = links[i - 1];
  2108. const next = links[i + 1];
  2109. const { html, href, title, divider = true } = link;
  2110. let part = prev && divider ? '/ ' : '';
  2111. if (typeof href === 'string') {
  2112. part += `<a href="${href}" ${classes(i === links.length - 1 && 'current')}>${title}</a>`;
  2113. } else if (html) {
  2114. part += `<span>${html}</span>`;
  2115. }
  2116. navLinkParts.push(part);
  2117. }
  2118. const navHTML = html.tag('nav', {
  2119. [html.onlyIfContent]: true,
  2120. id: 'header',
  2121. class: nav.classes
  2122. }, [
  2123. links.length && html.tag('h2', {class: 'highlight-last-link'}, navLinkParts),
  2124. nav.content
  2125. ]);
  2126. const bannerHTML = banner.position && banner.src && html.tag('div',
  2127. {
  2128. id: 'banner',
  2129. class: banner.classes
  2130. },
  2131. html.tag('img', {
  2132. src: banner.src,
  2133. alt: banner.alt,
  2134. width: 1100,
  2135. height: 200
  2136. })
  2137. );
  2138. const layoutHTML = [
  2139. navHTML,
  2140. banner.position === 'top' && bannerHTML,
  2141. (sidebarLeftHTML || sidebarRightHTML) ? fixWS`
  2142. <div ${classes('layout-columns', !collapseSidebars && 'vertical-when-thin')}>
  2143. ${sidebarLeftHTML}
  2144. ${mainHTML}
  2145. ${sidebarRightHTML}
  2146. </div>
  2147. ` : mainHTML,
  2148. banner.position === 'bottom' && bannerHTML,
  2149. footerHTML
  2150. ].filter(Boolean).join('\n');
  2151. const infoCardHTML = fixWS`
  2152. <div id="info-card-container">
  2153. <div class="info-card-decor">
  2154. <div class="info-card">
  2155. <div class="info-card-art-container no-reveal">
  2156. ${img({
  2157. class: 'info-card-art',
  2158. src: '',
  2159. link: true,
  2160. square: true
  2161. })}
  2162. </div>
  2163. <div class="info-card-art-container reveal">
  2164. ${img({
  2165. class: 'info-card-art',
  2166. src: '',
  2167. link: true,
  2168. square: true,
  2169. reveal: getRevealStringFromWarnings('<span class="info-card-art-warnings"></span>', {strings})
  2170. })}
  2171. </div>
  2172. <h1 class="info-card-name"><a></a></h1>
  2173. <p class="info-card-album">${strings('releaseInfo.from', {album: '<a></a>'})}</p>
  2174. <p class="info-card-artists">${strings('releaseInfo.by', {artists: '<span></span>'})}</p>
  2175. <p class="info-card-cover-artists">${strings('releaseInfo.coverArtBy', {artists: '<span></span>'})}</p>
  2176. </div>
  2177. </div>
  2178. </div>
  2179. `;
  2180. return filterEmptyLines(fixWS`
  2181. <!DOCTYPE html>
  2182. <html ${html.attributes({
  2183. lang: strings.code,
  2184. 'data-rebase-localized': to('localized.root'),
  2185. 'data-rebase-shared': to('shared.root'),
  2186. 'data-rebase-media': to('media.root'),
  2187. 'data-rebase-data': to('data.root')
  2188. })}>
  2189. <head>
  2190. <title>${title}</title>
  2191. <meta charset="utf-8">
  2192. <meta name="viewport" content="width=device-width, initial-scale=1">
  2193. ${Object.entries(meta).filter(([ key, value ]) => value).map(([ key, value ]) => `<meta ${key}="${html.escapeAttributeValue(value)}">`).join('\n')}
  2194. ${canonical && `<link rel="canonical" href="${canonical}">`}
  2195. <link rel="stylesheet" href="${to('shared.staticFile', `site.css?${CACHEBUST}`)}">
  2196. ${(theme || stylesheet) && fixWS`
  2197. <style>
  2198. ${theme}
  2199. ${stylesheet}
  2200. </style>
  2201. `}
  2202. <script src="${to('shared.staticFile', `lazy-loading.js?${CACHEBUST}`)}"></script>
  2203. </head>
  2204. <body ${html.attributes({style: body.style || ''})}>
  2205. <div id="page-container">
  2206. ${mainHTML && fixWS`
  2207. <div id="skippers">
  2208. ${[
  2209. ['#content', strings('misc.skippers.skipToContent')],
  2210. sidebarLeftHTML && ['#sidebar-left', (sidebarRightHTML
  2211. ? strings('misc.skippers.skipToSidebar.left')
  2212. : strings('misc.skippers.skipToSidebar'))],
  2213. sidebarRightHTML && ['#sidebar-right', (sidebarLeftHTML
  2214. ? strings('misc.skippers.skipToSidebar.right')
  2215. : strings('misc.skippers.skipToSidebar'))],
  2216. footerHTML && ['#footer', strings('misc.skippers.skipToFooter')]
  2217. ].filter(Boolean).map(([ href, title ]) => fixWS`
  2218. <span class="skipper"><a href="${href}">${title}</a></span>
  2219. `).join('\n')}
  2220. </div>
  2221. `}
  2222. ${layoutHTML}
  2223. </div>
  2224. ${infoCardHTML}
  2225. <script src="${to('shared.commonFile', `common.js?${CACHEBUST}`)}"></script>
  2226. <script src="${to('shared.staticFile', `client.js?${CACHEBUST}`)}"></script>
  2227. </body>
  2228. </html>
  2229. `);
  2230. };
  2231. writePage.write = async (content, {paths}) => {
  2232. await mkdirp(paths.outputDirectory);
  2233. await writeFile(paths.outputFile, content);
  2234. };
  2235. // TODO: This only supports one <>-style argument.
  2236. writePage.paths = (baseDirectory, fullKey, directory, {
  2237. file = 'index.html'
  2238. } = {}) => {
  2239. const [ groupKey, subKey ] = fullKey.split('.');
  2240. const pathname = (groupKey === 'localized' && baseDirectory
  2241. ? urls.from('shared.root').to('localizedWithBaseDirectory.' + subKey, baseDirectory, directory)
  2242. : urls.from('shared.root').to(fullKey, directory));
  2243. // Needed for the rare directory which itself contains a slash, e.g. for
  2244. // listings, with directories like 'albums/by-name'.
  2245. const subdirectoryPrefix = '../'.repeat(directory.split('/').length - 1);
  2246. const outputDirectory = path.join(outputPath, pathname);
  2247. const outputFile = path.join(outputDirectory, file);
  2248. return {
  2249. pathname,
  2250. subdirectoryPrefix,
  2251. outputDirectory, outputFile
  2252. };
  2253. };
  2254. function getGridHTML({
  2255. strings,
  2256. entries,
  2257. srcFn,
  2258. hrefFn,
  2259. altFn = () => '',
  2260. detailsFn = null,
  2261. lazy = true
  2262. }) {
  2263. return entries.map(({ large, item }, i) => fixWS`
  2264. <a ${classes('grid-item', 'box', large && 'large-grid-item')} href="${hrefFn(item)}" style="${getLinkThemeString(item)}">
  2265. ${img({
  2266. src: srcFn(item),
  2267. alt: altFn(item),
  2268. thumb: 'small',
  2269. lazy: (typeof lazy === 'number' ? i >= lazy : lazy),
  2270. square: true,
  2271. reveal: getRevealStringFromTags(item.artTags, {strings})
  2272. })}
  2273. <span>${item.name}</span>
  2274. ${detailsFn && `<span>${detailsFn(item)}</span>`}
  2275. </a>
  2276. `).join('\n');
  2277. }
  2278. function getAlbumGridHTML({strings, to, details = false, ...props}) {
  2279. return getGridHTML({
  2280. strings,
  2281. srcFn: album => getAlbumCover(album, {to}),
  2282. hrefFn: album => to('localized.album', album.directory),
  2283. detailsFn: details && (album => strings('misc.albumGridDetails', {
  2284. tracks: strings.count.tracks(album.tracks.length, {unit: true}),
  2285. time: strings.count.duration(getTotalDuration(album.tracks))
  2286. })),
  2287. ...props
  2288. });
  2289. }
  2290. function getFlashGridHTML({strings, to, ...props}) {
  2291. return getGridHTML({
  2292. strings,
  2293. srcFn: flash => to('media.flashArt', flash.directory),
  2294. hrefFn: flash => to('localized.flash', flash.directory),
  2295. ...props
  2296. });
  2297. }
  2298. function getNewReleases(numReleases) {
  2299. const latestFirst = albumData.slice().reverse();
  2300. const majorReleases = latestFirst.filter(album => album.isMajorRelease);
  2301. majorReleases.splice(1);
  2302. const otherReleases = latestFirst
  2303. .filter(album => !majorReleases.includes(album))
  2304. .slice(0, numReleases - majorReleases.length);
  2305. return [
  2306. ...majorReleases.map(album => ({large: true, item: album})),
  2307. ...otherReleases.map(album => ({large: false, item: album}))
  2308. ];
  2309. }
  2310. function getNewAdditions(numAlbums) {
  2311. // Sort al8ums, in descending order of priority, 8y...
  2312. //
  2313. // * D8te of addition to the wiki (descending).
  2314. // * Major releases first.
  2315. // * D8te of release (descending).
  2316. //
  2317. // Major releases go first to 8etter ensure they show up in the list (and
  2318. // are usually at the start of the final output for a given d8 of release
  2319. // too).
  2320. const sortedAlbums = albumData.slice().sort((a, b) => {
  2321. if (a.dateAdded > b.dateAdded) return -1;
  2322. if (a.dateAdded < b.dateAdded) return 1;
  2323. if (a.isMajorRelease && !b.isMajorRelease) return -1;
  2324. if (!a.isMajorRelease && b.isMajorRelease) return 1;
  2325. if (a.date > b.date) return -1;
  2326. if (a.date < b.date) return 1;
  2327. });
  2328. // When multiple al8ums are added to the wiki at a time, we want to show
  2329. // all of them 8efore pulling al8ums from the next (earlier) date. We also
  2330. // want to show a diverse selection of al8ums - with limited space, we'd
  2331. // rather not show only the latest al8ums, if those happen to all 8e
  2332. // closely rel8ted!
  2333. //
  2334. // Specifically, we're concerned with avoiding too much overlap amongst
  2335. // the primary (first/top-most) group. We do this 8y collecting every
  2336. // primary group present amongst the al8ums for a given d8 into one
  2337. // (ordered) array, initially sorted (inherently) 8y latest al8um from
  2338. // the group. Then we cycle over the array, adding one al8um from each
  2339. // group until all the al8ums from that release d8 have 8een added (or
  2340. // we've met the total target num8er of al8ums). Once we've added all the
  2341. // al8ums for a given group, it's struck from the array (so the groups
  2342. // with the most additions on one d8 will have their oldest releases
  2343. // collected more towards the end of the list).
  2344. const albums = [];
  2345. let i = 0;
  2346. outerLoop: while (i < sortedAlbums.length) {
  2347. // 8uild up a list of groups and their al8ums 8y order of decending
  2348. // release, iter8ting until we're on a different d8. (We use a map for
  2349. // indexing so we don't have to iter8te through the entire array each
  2350. // time we access one of its entries. This is 8asically unnecessary
  2351. // since this will never 8e an expensive enough task for that to
  2352. // matter.... 8ut it's nicer code. BBBB) )
  2353. const currentDate = sortedAlbums[i].dateAdded;
  2354. const groupMap = new Map();
  2355. const groupArray = [];
  2356. for (let album; (album = sortedAlbums[i]) && +album.dateAdded === +currentDate; i++) {
  2357. const primaryGroup = album.groups[0];
  2358. if (groupMap.has(primaryGroup)) {
  2359. groupMap.get(primaryGroup).push(album);
  2360. } else {
  2361. const entry = [album]
  2362. groupMap.set(primaryGroup, entry);
  2363. groupArray.push(entry);
  2364. }
  2365. }
  2366. // Then cycle over that sorted array, adding one al8um from each to
  2367. // the main array until we've run out or have met the target num8er
  2368. // of al8ums.
  2369. while (groupArray.length) {
  2370. let j = 0;
  2371. while (j < groupArray.length) {
  2372. const entry = groupArray[j];
  2373. const album = entry.shift();
  2374. albums.push(album);
  2375. // This is the only time we ever add anything to the main al8um
  2376. // list, so it's also the only place we need to check if we've
  2377. // met the target length.
  2378. if (albums.length === numAlbums) {
  2379. // If we've met it, 8r8k out of the outer loop - we're done
  2380. // here!
  2381. break outerLoop;
  2382. }
  2383. if (entry.length) {
  2384. j++;
  2385. } else {
  2386. groupArray.splice(j, 1);
  2387. }
  2388. }
  2389. }
  2390. }
  2391. // Finally, do some quick mapping shenanigans to 8etter display the result
  2392. // in a grid. (This should pro8a8ly 8e a separ8te, shared function, 8ut
  2393. // whatevs.)
  2394. return albums.map(album => ({large: album.isMajorRelease, item: album}));
  2395. }
  2396. function writeSymlinks() {
  2397. return progressPromiseAll('Writing site symlinks.', [
  2398. link(path.join(__dirname, C.COMMON_DIRECTORY), C.COMMON_DIRECTORY),
  2399. link(path.join(__dirname, C.STATIC_DIRECTORY), C.STATIC_DIRECTORY),
  2400. link(mediaPath, C.MEDIA_DIRECTORY)
  2401. ]);
  2402. async function link(directory, target) {
  2403. const file = path.join(outputPath, target);
  2404. try {
  2405. await unlink(file);
  2406. } catch (error) {
  2407. if (error.code !== 'ENOENT') {
  2408. throw error;
  2409. }
  2410. }
  2411. await symlink(path.resolve(directory), file);
  2412. }
  2413. }
  2414. function writeSharedFilesAndPages({strings}) {
  2415. const redirect = async (title, from, urlKey, directory) => {
  2416. const target = path.relative(from, urls.from('shared.root').to(urlKey, directory));
  2417. const content = generateRedirectPage(title, target, {strings});
  2418. await mkdirp(path.join(outputPath, from));
  2419. await writeFile(path.join(outputPath, from, 'index.html'), content);
  2420. };
  2421. return progressPromiseAll(`Writing files & pages shared across languages.`, [
  2422. groupData?.some(group => group.directory === 'fandom') &&
  2423. redirect('Fandom - Gallery', 'albums/fandom', 'localized.groupGallery', 'fandom'),
  2424. groupData?.some(group => group.directory === 'official') &&
  2425. redirect('Official - Gallery', 'albums/official', 'localized.groupGallery', 'official'),
  2426. wikiInfo.features.listings &&
  2427. redirect('Album Commentary', 'list/all-commentary', 'localized.commentaryIndex', ''),
  2428. writeFile(path.join(outputPath, 'data.json'), fixWS`
  2429. {
  2430. "albumData": ${stringifyAlbumData()},
  2431. ${wikiInfo.features.flashesAndGames && `"flashData": ${stringifyFlashData()},`}
  2432. "artistData": ${stringifyArtistData()}
  2433. }
  2434. `)
  2435. ].filter(Boolean));
  2436. }
  2437. function writeHomepage() {
  2438. return ({strings, writePage}) => writePage('home', '', ({to}) => ({
  2439. title: wikiInfo.name,
  2440. meta: {
  2441. description: wikiInfo.description
  2442. },
  2443. main: {
  2444. classes: ['top-index'],
  2445. content: fixWS`
  2446. <h1>${wikiInfo.name}</h1>
  2447. ${homepageInfo.rows.map((row, i) => fixWS`
  2448. <section class="row" style="${getLinkThemeString(row)}">
  2449. <h2>${row.name}</h2>
  2450. ${row.type === 'albums' && fixWS`
  2451. <div class="grid-listing">
  2452. ${getAlbumGridHTML({
  2453. strings, to,
  2454. entries: (
  2455. row.group === 'new-releases' ? getNewReleases(row.groupCount) :
  2456. row.group === 'new-additions' ? getNewAdditions(row.groupCount) :
  2457. ((search.group(row.group)?.albums || [])
  2458. .slice()
  2459. .reverse()
  2460. .slice(0, row.groupCount)
  2461. .map(album => ({item: album})))
  2462. ).concat(row.albums
  2463. .map(search.album)
  2464. .map(album => ({item: album}))
  2465. ),
  2466. lazy: i > 0
  2467. })}
  2468. ${row.actions.length && fixWS`
  2469. <div class="grid-actions">
  2470. ${row.actions.map(action => transformInline(action, {strings, to})
  2471. .replace('<a', '<a class="box grid-item"')).join('\n')}
  2472. </div>
  2473. `}
  2474. </div>
  2475. `}
  2476. </section>
  2477. `).join('\n')}
  2478. `
  2479. },
  2480. sidebarLeft: homepageInfo.sidebar && {
  2481. wide: true,
  2482. collapse: false,
  2483. // This is a pretty filthy hack! 8ut otherwise, the [[news]] part
  2484. // gets treated like it's a reference to the track named "news",
  2485. // which o8viously isn't what we're going for. Gotta catch that
  2486. // 8efore we pass it to transformMultiline, 'cuz otherwise it'll
  2487. // get repl8ced with just the word "news" (or anything else that
  2488. // transformMultiline does with references it can't match) -- and
  2489. // we can't match that for replacing it with the news column!
  2490. //
  2491. // And no, I will not make [[news]] into part of transformMultiline
  2492. // (even though that would 8e hilarious).
  2493. content: transformMultiline(homepageInfo.sidebar.replace('[[news]]', '__GENERATE_NEWS__'), {strings, to}).replace('<p>__GENERATE_NEWS__</p>', wikiInfo.features.news ? fixWS`
  2494. <h1>${strings('homepage.news.title')}</h1>
  2495. ${newsData.slice(0, 3).map((entry, i) => fixWS`
  2496. <article ${classes('news-entry', i === 0 && 'first-news-entry')}>
  2497. <h2><time>${strings.count.date(entry.date)}</time> ${strings.link.newsEntry(entry, {to})}</h2>
  2498. ${transformMultiline(entry.bodyShort, {strings, to})}
  2499. ${entry.bodyShort !== entry.body && strings.link.newsEntry(entry, {
  2500. to,
  2501. text: strings('homepage.news.entry.viewRest')
  2502. })}
  2503. </article>
  2504. `).join('\n')}
  2505. ` : `<p><i>News requested in content description but this feature isn't enabled</i></p>`)
  2506. },
  2507. nav: {
  2508. content: fixWS`
  2509. <h2 class="dot-between-spans">
  2510. ${[
  2511. strings.link.home('', {text: wikiInfo.shortName, class: 'current', to}),
  2512. wikiInfo.features.listings &&
  2513. strings.link.listingIndex('', {text: strings('listingIndex.title'), to}),
  2514. wikiInfo.features.news &&
  2515. strings.link.newsIndex('', {text: strings('newsIndex.title'), to}),
  2516. wikiInfo.features.flashesAndGames &&
  2517. strings.link.flashIndex('', {text: strings('flashIndex.title'), to}),
  2518. ...staticPageData.filter(page => page.listed).map(page => strings.link.staticPage(page, {to}))
  2519. ].filter(Boolean).map(link => `<span>${link}</span>`).join('\n')}
  2520. </h2>
  2521. `
  2522. }
  2523. }));
  2524. }
  2525. function writeMiscellaneousPages() {
  2526. return [
  2527. writeHomepage()
  2528. ];
  2529. }
  2530. function writeNewsPages() {
  2531. if (!wikiInfo.features.news) {
  2532. return;
  2533. }
  2534. return [
  2535. writeNewsIndex(),
  2536. ...newsData.map(writeNewsEntryPage)
  2537. ];
  2538. }
  2539. function writeNewsIndex() {
  2540. return ({strings, writePage}) => writePage('newsIndex', '', ({to}) => ({
  2541. title: strings('newsIndex.title'),
  2542. main: {
  2543. content: fixWS`
  2544. <div class="long-content news-index">
  2545. <h1>${strings('newsIndex.title')}</h1>
  2546. ${newsData.map(entry => fixWS`
  2547. <article id="${entry.directory}">
  2548. <h2><time>${strings.count.date(entry.date)}</time> ${strings.link.newsEntry(entry, {to})}</h2>
  2549. ${transformMultiline(entry.bodyShort, {strings, to})}
  2550. ${entry.bodyShort !== entry.body && fixWS`
  2551. <p>${strings.link.newsEntry(entry, {
  2552. to,
  2553. text: strings('newsIndex.entry.viewRest')
  2554. })}</p>
  2555. `}
  2556. </article>
  2557. `).join('\n')}
  2558. </div>
  2559. `
  2560. },
  2561. nav: {simple: true}
  2562. }));
  2563. }
  2564. function writeNewsEntryPage(entry) {
  2565. return ({strings, writePage}) => writePage('newsEntry', entry.directory, ({to}) => ({
  2566. title: strings('newsEntryPage.title', {entry: entry.name}),
  2567. main: {
  2568. content: fixWS`
  2569. <div class="long-content">
  2570. <h1>${strings('newsEntryPage.title', {entry: entry.name})}</h1>
  2571. <p>${strings('newsEntryPage.published', {date: strings.count.date(entry.date)})}</p>
  2572. ${transformMultiline(entry.body, {strings, to})}
  2573. </div>
  2574. `
  2575. },
  2576. nav: generateNewsEntryNav(entry, {strings, to})
  2577. }));
  2578. }
  2579. function generateNewsEntryNav(entry, {strings, to}) {
  2580. // The newsData list is sorted reverse chronologically (newest ones first),
  2581. // so the way we find next/previous entries is flipped from normal.
  2582. const previousNextLinks = generatePreviousNextLinks('localized.newsEntry', entry, newsData.slice().reverse(), {strings, to});
  2583. return {
  2584. links: [
  2585. {
  2586. href: to('localized.home'),
  2587. title: wikiInfo.shortName
  2588. },
  2589. {
  2590. href: to('localized.newsIndex'),
  2591. title: strings('newsEntryPage.nav.news')
  2592. },
  2593. {
  2594. html: strings('newsEntryPage.nav.entry', {
  2595. date: strings.count.date(entry.date),
  2596. entry: strings.link.newsEntry(entry, {class: 'current', to})
  2597. })
  2598. },
  2599. previousNextLinks &&
  2600. {
  2601. divider: false,
  2602. html: `(${previousNextLinks})`
  2603. }
  2604. ]
  2605. };
  2606. }
  2607. function writeStaticPages() {
  2608. return staticPageData.map(writeStaticPage);
  2609. }
  2610. function writeStaticPage(staticPage) {
  2611. return ({strings, writePage}) => writePage('staticPage', staticPage.directory, ({to}) => ({
  2612. title: staticPage.name,
  2613. stylesheet: staticPage.stylesheet,
  2614. main: {
  2615. content: fixWS`
  2616. <div class="long-content">
  2617. <h1>${staticPage.name}</h1>
  2618. ${transformMultiline(staticPage.content, {strings, to})}
  2619. </div>
  2620. `
  2621. },
  2622. nav: {simple: true}
  2623. }));
  2624. }
  2625. function getRevealStringFromWarnings(warnings, {strings}) {
  2626. return strings('misc.contentWarnings', {warnings}) + `<br><span class="reveal-interaction">${strings('misc.contentWarnings.reveal')}</span>`
  2627. }
  2628. function getRevealStringFromTags(tags, {strings}) {
  2629. return tags && tags.some(tag => tag.isCW) && (
  2630. getRevealStringFromWarnings(strings.list.unit(tags.filter(tag => tag.isCW).map(tag => tag.name)), {strings}));
  2631. }
  2632. function generateCoverLink({
  2633. strings, to,
  2634. src,
  2635. alt,
  2636. tags = []
  2637. }) {
  2638. return fixWS`
  2639. <div id="cover-art-container">
  2640. ${img({
  2641. src,
  2642. alt,
  2643. thumb: 'medium',
  2644. id: 'cover-art',
  2645. link: true,
  2646. square: true,
  2647. reveal: getRevealStringFromTags(tags, {strings})
  2648. })}
  2649. ${wikiInfo.features.artTagUI && tags.filter(tag => !tag.isCW).length && fixWS`
  2650. <p class="tags">
  2651. ${strings('releaseInfo.artTags')}
  2652. ${(tags
  2653. .filter(tag => !tag.isCW)
  2654. .map(tag => strings.link.tag(tag, {to}))
  2655. .join(',\n'))}
  2656. </p>
  2657. `}
  2658. </div>
  2659. `;
  2660. }
  2661. // This function title is my gr8test work of art.
  2662. // (The 8ehavior... well, um. Don't tell anyone, 8ut it's even 8etter.)
  2663. /* // RIP, 2k20-2k20.
  2664. function writeIndexAndTrackPagesForAlbum(album) {
  2665. return [
  2666. () => writeAlbumPage(album),
  2667. ...album.tracks.map(track => () => writeTrackPage(track))
  2668. ];
  2669. }
  2670. */
  2671. function writeAlbumPages() {
  2672. return albumData.map(writeAlbumPage);
  2673. }
  2674. function writeAlbumPage(album) {
  2675. const trackToListItem = (track, {strings, to}) => {
  2676. const itemOpts = {
  2677. duration: strings.count.duration(track.duration),
  2678. track: strings.link.track(track, {to})
  2679. };
  2680. const content = (track.artists === album.artists
  2681. ? strings('trackList.item.withDuration', itemOpts)
  2682. : strings('trackList.item.withDuration.withArtists', {
  2683. ...itemOpts,
  2684. by: html.tag('span', {class: 'by'},
  2685. strings('trackList.item.withArtists.by', {
  2686. artists: getArtistString(track.artists, {strings, to})
  2687. })
  2688. )
  2689. }));
  2690. return `<li style="${getLinkThemeString(track)}">${content}</li>`;
  2691. };
  2692. const commentaryEntries = [album, ...album.tracks].filter(x => x.commentary).length;
  2693. const albumDuration = getTotalDuration(album.tracks);
  2694. const listTag = getAlbumListTag(album);
  2695. const data = {
  2696. type: 'data',
  2697. path: ['album', album.directory],
  2698. data: () => ({
  2699. name: album.name,
  2700. directory: album.directory,
  2701. dates: {
  2702. released: album.date,
  2703. trackArtAdded: album.trackArtDate,
  2704. coverArtAdded: album.coverArtDate,
  2705. addedToWiki: album.dateAdded
  2706. },
  2707. duration: albumDuration,
  2708. color: album.color,
  2709. cover: serializeCover(album, getAlbumCover),
  2710. artists: serializeContribs(album.artists || []),
  2711. coverArtists: serializeContribs(album.coverArtists || []),
  2712. wallpaperArtists: serializeContribs(album.wallpaperArtists || []),
  2713. bannerArtists: serializeContribs(album.bannerArtists || []),
  2714. groups: serializeGroupsForAlbum(album),
  2715. trackGroups: album.trackGroups?.map(trackGroup => ({
  2716. name: trackGroup.name,
  2717. color: trackGroup.color,
  2718. tracks: trackGroup.tracks.map(track => track.directory)
  2719. })),
  2720. tracks: album.tracks.map(track => ({
  2721. link: serializeLink(track),
  2722. duration: track.duration
  2723. }))
  2724. })
  2725. };
  2726. const page = {type: 'page', path: ['album', album.directory], page: ({strings, to}) => ({
  2727. title: strings('albumPage.title', {album: album.name}),
  2728. stylesheet: getAlbumStylesheet(album, {to}),
  2729. theme: getThemeString(album, [
  2730. `--album-directory: ${album.directory}`
  2731. ]),
  2732. banner: album.bannerArtists && {
  2733. src: to('media.albumBanner', album.directory),
  2734. alt: strings('misc.alt.albumBanner'),
  2735. position: 'top'
  2736. },
  2737. main: {
  2738. content: call(() => {
  2739. const releaseInfoParts = [
  2740. album.artists && strings('releaseInfo.by', {
  2741. artists: getArtistString(album.artists, {
  2742. strings, to,
  2743. showContrib: true,
  2744. showIcons: true
  2745. })
  2746. }),
  2747. album.coverArtists && strings('releaseInfo.coverArtBy', {
  2748. artists: getArtistString(album.coverArtists, {
  2749. strings, to,
  2750. showContrib: true,
  2751. showIcons: true
  2752. })
  2753. }),
  2754. album.wallpaperArtists && strings('releaseInfo.wallpaperArtBy', {
  2755. artists: getArtistString(album.wallpaperArtists, {
  2756. strings, to,
  2757. showContrib: true,
  2758. showIcons: true
  2759. })
  2760. }),
  2761. album.bannerArtists && strings('releaseInfo.bannerArtBy', {
  2762. artists: getArtistString(album.bannerArtists, {
  2763. strings, to,
  2764. showContrib: true,
  2765. showIcons: true
  2766. })
  2767. }),
  2768. strings('releaseInfo.released', {
  2769. date: strings.count.date(album.date)
  2770. }),
  2771. +album.coverArtDate !== +album.date && strings('releaseInfo.artReleased', {
  2772. date: strings.count.date(album.coverArtDate)
  2773. }),
  2774. strings('releaseInfo.duration', {
  2775. duration: strings.count.duration(albumDuration, {approximate: album.tracks.length > 1})
  2776. })
  2777. ].filter(Boolean);
  2778. const trackListPart = (album.trackGroups
  2779. ? html.tag('dl', {class: 'album-group-list'},
  2780. album.trackGroups.map(({ name, color, startIndex, tracks }) => {
  2781. const label = strings('trackList.group', {
  2782. duration: strings.count.duration(getTotalDuration(tracks), {approximate: tracks.length > 1}),
  2783. group: name
  2784. });
  2785. return fixWS`
  2786. <dt>${label}</dt>
  2787. <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
  2788. ${tracks.map(t => trackToListItem(t, {strings, to})).join('\n')}
  2789. </${listTag}></dd>
  2790. `;
  2791. })
  2792. )
  2793. : fixWS`
  2794. <${listTag}>
  2795. ${album.tracks.map(t => trackToListItem(t, {strings, to})).join('\n')}
  2796. </${listTag}>
  2797. `);
  2798. return [
  2799. generateCoverLink({
  2800. strings, to,
  2801. src: to('media.albumCover', album.directory),
  2802. alt: strings('misc.alt.albumCover'),
  2803. tags: album.artTags
  2804. }),
  2805. `<h1>${strings('albumPage.title', {album: album.name})}</h1>`,
  2806. html.tag('p', releaseInfoParts.join('<br>\n')),
  2807. commentaryEntries && html.tag('p',
  2808. strings('releaseInfo.viewCommentary', {
  2809. link: `<a href="${to('localized.albumCommentary', album.directory)}">${
  2810. strings('releaseInfo.viewCommentary.link')
  2811. }</a>`
  2812. })
  2813. ),
  2814. album.urls.length && html.tag('p',
  2815. strings('releaseInfo.listenOn', {
  2816. links: strings.list.or(album.urls.map(url => fancifyURL(url, {album: true, strings})))
  2817. })
  2818. ),
  2819. trackListPart,
  2820. html.tag('p', [
  2821. strings('releaseInfo.addedToWiki', {
  2822. date: strings.count.date(album.dateAdded)
  2823. })
  2824. ].filter(Boolean).join('<br>\n')),
  2825. album.commentary && fixWS`
  2826. <p>${strings('releaseInfo.artistCommentary')}</p>
  2827. ${html.tag('blockquote', transformMultiline(album.commentary, {strings, to}))}
  2828. `
  2829. ].filter(Boolean).join('\n');
  2830. })
  2831. },
  2832. sidebarLeft: generateSidebarForAlbum(album, null, {strings, to}),
  2833. nav: {
  2834. links: [
  2835. {
  2836. href: to('localized.home'),
  2837. title: wikiInfo.shortName
  2838. },
  2839. {
  2840. html: strings('albumPage.nav.album', {
  2841. album: strings.link.album(album, {class: 'current', to})
  2842. })
  2843. },
  2844. {
  2845. divider: false,
  2846. html: generateAlbumNavLinks(album, null, {strings, to})
  2847. }
  2848. ],
  2849. content: fixWS`
  2850. <div>
  2851. ${generateAlbumChronologyLinks(album, null, {strings, to})}
  2852. </div>
  2853. `
  2854. }
  2855. })};
  2856. return [page, data];
  2857. }
  2858. function getAlbumStylesheet(album, {to}) {
  2859. return [
  2860. album.wallpaperArtists && fixWS`
  2861. body::before {
  2862. background-image: url("${to('media.albumWallpaper', album.directory)}");
  2863. ${album.wallpaperStyle}
  2864. }
  2865. `,
  2866. album.bannerStyle && fixWS`
  2867. #banner img {
  2868. ${album.bannerStyle}
  2869. }
  2870. `
  2871. ].filter(Boolean).join('\n');
  2872. }
  2873. function writeTrackPages() {
  2874. return trackData.map(writeTrackPage);
  2875. }
  2876. function writeTrackPage(track) {
  2877. const { album } = track;
  2878. const tracksThatReference = track.referencedBy;
  2879. const useDividedReferences = groupData.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY);
  2880. const ttrFanon = (useDividedReferences &&
  2881. tracksThatReference.filter(t => t.album.groups.every(group => group.directory !== C.OFFICIAL_GROUP_DIRECTORY)));
  2882. const ttrOfficial = (useDividedReferences &&
  2883. tracksThatReference.filter(t => t.album.groups.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY)));
  2884. const tracksReferenced = track.references;
  2885. const otherReleases = track.otherReleases;
  2886. const listTag = getAlbumListTag(album);
  2887. let flashesThatFeature;
  2888. if (wikiInfo.features.flashesAndGames) {
  2889. flashesThatFeature = C.sortByDate([track, ...otherReleases]
  2890. .flatMap(track => track.flashes.map(flash => ({flash, as: track}))));
  2891. }
  2892. const generateTrackList = (tracks, {strings, to}) => html.tag('ul',
  2893. tracks.map(track => {
  2894. const line = strings('trackList.item.withArtists', {
  2895. track: strings.link.track(track, {to}),
  2896. by: `<span class="by">${strings('trackList.item.withArtists.by', {
  2897. artists: getArtistString(track.artists, {strings, to})
  2898. })}</span>`
  2899. });
  2900. return (track.aka
  2901. ? `<li class="rerelease">${strings('trackList.item.rerelease', {track: line})}</li>`
  2902. : `<li>${line}</li>`);
  2903. })
  2904. );
  2905. const hasCommentary = track.commentary || otherReleases.some(t => t.commentary);
  2906. const generateCommentary = ({strings, to}) => transformMultiline(
  2907. [
  2908. track.commentary,
  2909. ...otherReleases.map(track =>
  2910. (track.commentary?.split('\n')
  2911. .filter(line => line.replace(/<\/b>/g, '').includes(':</i>'))
  2912. .map(line => fixWS`
  2913. ${line}
  2914. ${strings('releaseInfo.artistCommentary.seeOriginalRelease', {
  2915. original: strings.link.track(track, {to})
  2916. })}
  2917. `)
  2918. .join('\n')))
  2919. ].filter(Boolean).join('\n'),
  2920. {strings, to});
  2921. const data = {
  2922. type: 'data',
  2923. path: ['track', track.directory],
  2924. data: () => ({
  2925. name: track.name,
  2926. directory: track.directory,
  2927. dates: {
  2928. released: track.date,
  2929. originallyReleased: track.originalDate,
  2930. coverArtAdded: track.coverArtDate
  2931. },
  2932. duration: track.duration,
  2933. color: track.color,
  2934. cover: serializeCover(track, getTrackCover),
  2935. artists: serializeContribs(track.artists),
  2936. contributors: serializeContribs(track.contributors),
  2937. coverArtists: serializeContribs(track.coverArtists || []),
  2938. album: serializeLink(track.album),
  2939. groups: serializeGroupsForTrack(track),
  2940. references: track.references.map(serializeLink),
  2941. referencedBy: track.referencedBy.map(serializeLink),
  2942. alsoReleasedAs: otherReleases.map(track => ({
  2943. track: serializeLink(track),
  2944. album: serializeLink(track.album)
  2945. }))
  2946. })
  2947. };
  2948. const page = {type: 'page', path: ['track', track.directory], page: ({strings, to}) => ({
  2949. title: strings('trackPage.title', {track: track.name}),
  2950. stylesheet: getAlbumStylesheet(album, {to}),
  2951. theme: getThemeString(track, [
  2952. `--album-directory: ${album.directory}`,
  2953. `--track-directory: ${track.directory}`
  2954. ]),
  2955. // disabled for now! shifting banner position per height of page is disorienting
  2956. /*
  2957. banner: album.bannerArtists && {
  2958. classes: ['dim'],
  2959. src: to('media.albumBanner', album.directory),
  2960. alt: strings('misc.alt.albumBanner'),
  2961. position: 'bottom'
  2962. },
  2963. */
  2964. main: {
  2965. content: call(() => {
  2966. const releaseInfoParts = [
  2967. strings('releaseInfo.by', {
  2968. artists: getArtistString(track.artists, {
  2969. strings, to,
  2970. showContrib: true,
  2971. showIcons: true
  2972. })
  2973. }),
  2974. track.coverArtists && strings('releaseInfo.coverArtBy', {
  2975. artists: getArtistString(track.coverArtists, {
  2976. strings, to,
  2977. showContrib: true,
  2978. showIcons: true
  2979. })
  2980. }),
  2981. album.directory !== C.UNRELEASED_TRACKS_DIRECTORY && strings('releaseInfo.released', {
  2982. date: strings.count.date(track.date)
  2983. }),
  2984. +track.coverArtDate !== +track.date && strings('releaseInfo.artReleased', {
  2985. date: strings.count.date(track.coverArtDate)
  2986. }),
  2987. track.duration && strings('releaseInfo.duration', {
  2988. duration: strings.count.duration(track.duration)
  2989. })
  2990. ].filter(Boolean);
  2991. return [
  2992. generateCoverLink({
  2993. strings, to,
  2994. src: getTrackCover(track, {to}),
  2995. alt: strings('misc.alt.trackCover'),
  2996. tags: track.artTags
  2997. }),
  2998. `<h1>${strings('trackPage.title', {track: track.name})}</h1>`,
  2999. html.tag('p', releaseInfoParts.join('<br>\n')),
  3000. html.tag('p',
  3001. (track.urls.length
  3002. ? strings('releaseInfo.listenOn', {
  3003. links: strings.list.or(track.urls.map(url => fancifyURL(url, {strings})))
  3004. })
  3005. : strings('releaseInfo.listenOn.noLinks'))
  3006. ),
  3007. otherReleases.length && fixWS`
  3008. <p>${strings('releaseInfo.alsoReleasedAs')}</p>
  3009. ${html.tag('ul',
  3010. otherReleases.map(track => fixWS`
  3011. <li>${strings('releaseInfo.alsoReleasedAs.item', {
  3012. track: strings.link.track(track, {to}),
  3013. album: strings.link.album(track.album, {to})
  3014. })}</li>
  3015. `)
  3016. )}
  3017. `,
  3018. track.contributors.textContent && html.tag('p', [
  3019. strings('releaseInfo.contributors'),
  3020. '<br>',
  3021. transformInline(track.contributors.textContent, {strings, to})
  3022. ]),
  3023. track.contributors.length && fixWS`
  3024. <p>${strings('releaseInfo.contributors')}</p>
  3025. ${html.tag('p',
  3026. track.contributors.map(contrib => html.tag('li',
  3027. getArtistString([contrib], {
  3028. strings, to,
  3029. showContrib: true,
  3030. showIcons: true
  3031. })
  3032. ))
  3033. )}
  3034. `,
  3035. ...tracksReferenced.length ? [
  3036. html.tag('p', strings('releaseInfo.tracksReferenced', {
  3037. track: `<i>${track.name}</i>`
  3038. })),
  3039. generateTrackList(tracksReferenced, {strings, to})
  3040. ] : [],
  3041. ...tracksThatReference.length ? [
  3042. html.tag('p', strings('releaseInfo.tracksThatReference', {
  3043. track: `<i>${track.name}</i>`
  3044. })),
  3045. (useDividedReferences
  3046. ? html.tag('dl', [
  3047. ...ttrOfficial.length ? [
  3048. `<dt>${strings('trackPage.referenceList.official')}</dt>`,
  3049. `<dd>${generateTrackList(ttrOfficial, {strings, to})}</dd>`
  3050. ] : [],
  3051. ...ttrFanon.length ? [
  3052. `<dt>${strings('trackPage.referenceList.fandom')}</dt>`,
  3053. `<dd>${generateTrackList(ttrFanon, {strings, to})}</dd>`
  3054. ] : []
  3055. ])
  3056. : generateTrackList(tracksThatReference, {strings, to}))
  3057. ] : [],
  3058. ...wikiInfo.features.flashesAndGames && flashesThatFeature.length ? [
  3059. `<p>${strings('releaseInfo.flashesThatFeature', {track: `<i>${track.name}</i>`})}</p>`,
  3060. html.tag('ul',
  3061. flashesThatFeature.map(({ flash, as }) => html.tag('li',
  3062. {class: [as !== track && 'rerelease']},
  3063. (as === track
  3064. ? strings('releaseInfo.flashesThatFeature.item', {
  3065. flash: strings.link.flash(flash, {to})
  3066. })
  3067. : strings('releaseInfo.flashesThatFeature.item.asDifferentRelease', {
  3068. flash: strings.link.flash(flash, {to}),
  3069. track: strings.link.track(as, {to})
  3070. }))
  3071. ))
  3072. )
  3073. ] : [],
  3074. ...track.lyrics ? [
  3075. `<p>${strings('releaseInfo.lyrics')}</p>`,
  3076. html.tag('blockquote', transformLyrics(track.lyrics, {strings, to}))
  3077. ] : [],
  3078. ...hasCommentary ? [
  3079. `<p>${strings('releaseInfo.artistCommentary')}</p>`,
  3080. html.tag('blockquote', generateCommentary({strings, to}))
  3081. ] : []
  3082. ].filter(Boolean).join('\n');
  3083. })
  3084. },
  3085. sidebarLeft: generateSidebarForAlbum(album, track, {strings, to}),
  3086. nav: {
  3087. links: [
  3088. {
  3089. href: to('localized.home'),
  3090. title: wikiInfo.shortName
  3091. },
  3092. {
  3093. href: to('localized.album', album.directory),
  3094. title: album.name
  3095. },
  3096. listTag === 'ol' ? {
  3097. html: strings('trackPage.nav.track.withNumber', {
  3098. number: album.tracks.indexOf(track) + 1,
  3099. track: strings.link.track(track, {class: 'current', to})
  3100. })
  3101. } : {
  3102. html: strings('trackPage.nav.track', {
  3103. track: strings.link.track(track, {class: 'current', to})
  3104. })
  3105. },
  3106. {
  3107. divider: false,
  3108. html: generateAlbumNavLinks(album, track, {strings, to})
  3109. }
  3110. ].filter(Boolean),
  3111. content: fixWS`
  3112. <div>
  3113. ${generateAlbumChronologyLinks(album, track, {strings, to})}
  3114. </div>
  3115. `
  3116. }
  3117. })};
  3118. return [data, page];
  3119. }
  3120. function writeArtistPages() {
  3121. return [
  3122. ...artistData.map(writeArtistPage),
  3123. ...artistAliasData.map(writeArtistAliasPage)
  3124. ];
  3125. }
  3126. function writeArtistPage(artist) {
  3127. const {
  3128. name,
  3129. urls = [],
  3130. note = ''
  3131. } = artist;
  3132. const artThingsAll = C.sortByDate(unique([...artist.albums.asCoverArtist, ...artist.albums.asWallpaperArtist, ...artist.albums.asBannerArtist, ...artist.tracks.asCoverArtist]));
  3133. const artThingsGallery = C.sortByDate([...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]);
  3134. const commentaryThings = C.sortByDate([...artist.albums.asCommentator, ...artist.tracks.asCommentator]);
  3135. const hasGallery = artThingsGallery.length > 0;
  3136. const getArtistsAndContrib = (thing, key) => ({
  3137. artists: thing[key]?.filter(({ who }) => who !== artist),
  3138. contrib: thing[key]?.find(({ who }) => who === artist),
  3139. thing,
  3140. key
  3141. });
  3142. const artListChunks = chunkByProperties(artThingsAll.flatMap(thing =>
  3143. (['coverArtists', 'wallpaperArtists', 'bannerArtists']
  3144. .map(key => getArtistsAndContrib(thing, key))
  3145. .filter(({ contrib }) => contrib)
  3146. .map(props => ({
  3147. album: thing.album || thing,
  3148. track: thing.album ? thing : null,
  3149. date: +(thing.coverArtDate || thing.date),
  3150. ...props
  3151. })))
  3152. ), ['date', 'album']);
  3153. const commentaryListChunks = chunkByProperties(commentaryThings.map(thing => ({
  3154. album: thing.album || thing,
  3155. track: thing.album ? thing : null
  3156. })), ['album']);
  3157. const allTracks = C.sortByDate(unique([...artist.tracks.asArtist, ...artist.tracks.asContributor]));
  3158. const unreleasedTracks = allTracks.filter(track => track.album.directory === C.UNRELEASED_TRACKS_DIRECTORY);
  3159. const releasedTracks = allTracks.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
  3160. const chunkTracks = tracks => (
  3161. chunkByProperties(tracks.map(track => ({
  3162. track,
  3163. date: +track.date,
  3164. album: track.album,
  3165. duration: track.duration,
  3166. artists: (track.artists.some(({ who }) => who === artist)
  3167. ? track.artists.filter(({ who }) => who !== artist)
  3168. : track.contributors.filter(({ who }) => who !== artist)),
  3169. contrib: {
  3170. who: artist,
  3171. what: [
  3172. track.artists.find(({ who }) => who === artist)?.what,
  3173. track.contributors.find(({ who }) => who === artist)?.what
  3174. ].filter(Boolean).join(', ')
  3175. }
  3176. })), ['date', 'album'])
  3177. .map(({date, album, chunk}) => ({
  3178. date, album, chunk,
  3179. duration: getTotalDuration(chunk),
  3180. })));
  3181. const unreleasedTrackListChunks = chunkTracks(unreleasedTracks);
  3182. const releasedTrackListChunks = chunkTracks(releasedTracks);
  3183. const totalReleasedDuration = getTotalDuration(releasedTracks);
  3184. const countGroups = things => {
  3185. const usedGroups = things.flatMap(thing => thing.groups || thing.album?.groups || []);
  3186. return groupData
  3187. .map(group => ({
  3188. group,
  3189. contributions: usedGroups.filter(g => g === group).length
  3190. }))
  3191. .filter(({ contributions }) => contributions > 0)
  3192. .sort((a, b) => b.contributions - a.contributions);
  3193. };
  3194. const musicGroups = countGroups(releasedTracks);
  3195. const artGroups = countGroups(artThingsAll);
  3196. let flashes, flashListChunks;
  3197. if (wikiInfo.features.flashesAndGames) {
  3198. flashes = C.sortByDate(artist.flashes.asContributor.slice());
  3199. flashListChunks = (
  3200. chunkByProperties(flashes.map(flash => ({
  3201. act: flash.act,
  3202. flash,
  3203. date: flash.date,
  3204. // Manual artists/contrib properties here, 8ecause we don't
  3205. // want to show the full list of other contri8utors inline.
  3206. // (It can often 8e very, very large!)
  3207. artists: [],
  3208. contrib: flash.contributors.find(({ who }) => who === artist)
  3209. })), ['act'])
  3210. .map(({ act, chunk }) => ({
  3211. act, chunk,
  3212. dateFirst: chunk[0].date,
  3213. dateLast: chunk[chunk.length - 1].date
  3214. })));
  3215. }
  3216. const generateEntryAccents = ({ aka, entry, artists, contrib, strings, to }) =>
  3217. (aka
  3218. ? strings('artistPage.creditList.entry.rerelease', {entry})
  3219. : (artists.length
  3220. ? (contrib.what
  3221. ? strings('artistPage.creditList.entry.withArtists.withContribution', {
  3222. entry,
  3223. artists: getArtistString(artists, {strings, to}),
  3224. contribution: contrib.what
  3225. })
  3226. : strings('artistPage.creditList.entry.withArtists', {
  3227. entry,
  3228. artists: getArtistString(artists, {strings, to})
  3229. }))
  3230. : (contrib.what
  3231. ? strings('artistPage.creditList.entry.withContribution', {
  3232. entry,
  3233. contribution: contrib.what
  3234. })
  3235. : entry)));
  3236. const generateTrackList = (chunks, {strings, to}) => fixWS`
  3237. <dl>
  3238. ${chunks.map(({date, album, chunk, duration}) => fixWS`
  3239. <dt>${strings('artistPage.creditList.album.withDate.withDuration', {
  3240. album: strings.link.album(album, {to}),
  3241. date: strings.count.date(date),
  3242. duration: strings.count.duration(duration, {approximate: true})
  3243. })}</dt>
  3244. <dd><ul>
  3245. ${(chunk
  3246. .map(({track, ...props}) => ({
  3247. aka: track.aka,
  3248. entry: strings('artistPage.creditList.entry.track.withDuration', {
  3249. track: strings.link.track(track, {to}),
  3250. duration: strings.count.duration(track.duration, {to})
  3251. }),
  3252. ...props
  3253. }))
  3254. .map(({aka, ...opts}) => `<li ${classes(aka && 'rerelease')}>${generateEntryAccents({strings, to, aka, ...opts})}</li>`)
  3255. .join('\n'))}
  3256. </ul></dd>
  3257. `).join('\n')}
  3258. </dl>
  3259. `;
  3260. const serializeArtistsAndContrib = key => thing => {
  3261. const { artists, contrib } = getArtistsAndContrib(thing, key);
  3262. const ret = {};
  3263. ret.link = serializeLink(thing);
  3264. if (contrib.what) ret.contribution = contrib.what;
  3265. if (artists.length) ret.otherArtists = serializeContribs(artists);
  3266. return ret;
  3267. };
  3268. const serializeTrackListChunks = chunks =>
  3269. chunks.map(({date, album, chunk, duration}) => ({
  3270. album: serializeLink(album),
  3271. date,
  3272. duration,
  3273. tracks: chunk.map(({ track }) => ({
  3274. link: serializeLink(track),
  3275. duration: track.duration
  3276. }))
  3277. }));
  3278. const data = {
  3279. type: 'data',
  3280. path: ['artist', artist.directory],
  3281. data: () => ({
  3282. albums: {
  3283. asCoverArtist: artist.albums.asCoverArtist.map(serializeArtistsAndContrib('coverArtists')),
  3284. asWallpaperArtist: artist.albums.asWallpaperArtist.map(serializeArtistsAndContrib('wallpaperArtists')),
  3285. asBannerArtist: artist.albums.asBannerArtist.map(serializeArtistsAndContrib('bannerArtists'))
  3286. },
  3287. flashes: wikiInfo.features.flashesAndGames ? {
  3288. asContributor: artist.flashes.asContributor
  3289. .map(flash => getArtistsAndContrib(flash, 'contributors'))
  3290. .map(({ contrib, thing: flash }) => ({
  3291. link: serializeLink(flash),
  3292. contribution: contrib.what
  3293. }))
  3294. } : null,
  3295. tracks: {
  3296. asArtist: artist.tracks.asArtist.map(serializeArtistsAndContrib('artists')),
  3297. asContributor: artist.tracks.asContributor.map(serializeArtistsAndContrib('contributors')),
  3298. chunked: {
  3299. released: serializeTrackListChunks(releasedTrackListChunks),
  3300. unreleased: serializeTrackListChunks(unreleasedTrackListChunks)
  3301. }
  3302. }
  3303. })
  3304. };
  3305. const infoPage = {
  3306. type: 'page',
  3307. path: ['artist', artist.directory],
  3308. page: ({strings, to}) => ({
  3309. title: strings('artistPage.title', {artist: name}),
  3310. main: {
  3311. content: [
  3312. artist.hasAvatar && generateCoverLink({
  3313. strings, to,
  3314. src: to('localized.artistAvatar', artist.directory),
  3315. alt: strings('misc.alt.artistAvatar')
  3316. }),
  3317. `<h1>${strings('artistPage.title', {artist: name})}</h1>`,
  3318. ...note ? [
  3319. `<p>${strings('releaseInfo.note')}</p>`,
  3320. html.tag('blockquote', transformMultiline(note, {strings, to})),
  3321. `<hr>`
  3322. ],
  3323. urls.length && html.tag('p', strings('releaseInfo.visitOn', {
  3324. links: strings.list.or(urls.map(url => fancifyURL(url, {strings})))
  3325. })),
  3326. hasGallery && html.tag('p', strings('artistPage.viewArtGallery', {
  3327. link: strings.link.artistGallery(artist, {
  3328. to,
  3329. text: strings('artistPage.viewArtGallery.link')
  3330. })
  3331. })),
  3332. html.tag('p', strings('misc.jumpTo.withLinks', {
  3333. links: strings.list.unit([
  3334. [
  3335. [...releasedTracks, ...unreleasedTracks].length && `<a href="#tracks">${strings('artistPage.trackList.title')}</a>`,
  3336. unreleasedTracks.length && `(<a href="#unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</a>)`
  3337. ].filter(Boolean).join(' '),
  3338. artThingsAll.length && `<a href="#art">${strings('artistPage.artList.title')}</a>`,
  3339. wikiInfo.features.flashesAndGames && flashes.length && `<a href="#flashes">${strings('artistPage.flashList.title')}</a>`,
  3340. commentaryThings.length && `<a href="#commentary">${strings('artistPage.commentaryList.title')}</a>`
  3341. ].filter(Boolean))
  3342. })),
  3343. (releasedTracks.length || unreleasedTracks.length) && (
  3344. `<h2 id="tracks">${strings('artistPage.trackList.title')}</h2>`),
  3345. ...releasedTracks.length ? [
  3346. html.tag('p', strings('artistPage.contributedDurationLine', {
  3347. artist: artist.name,
  3348. duration: strings.count.duration(totalReleasedDuration, {approximate: true, unit: true})
  3349. })),
  3350. html.tag('p', strings('artistPage.musicGroupsLine', {
  3351. groups: strings.list.unit(musicGroups
  3352. .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
  3353. group: strings.link.groupInfo(group, {to}),
  3354. contributions: strings.count.contributions(contributions)
  3355. })))
  3356. })),
  3357. generateTrackList(releasedTrackListChunks, {strings, to})
  3358. ] : [],
  3359. ...unreleasedTracks.length ? [
  3360. `<h3 id="unreleased-tracks">${strings('artistPage.unreleasedTrackList.title')}</h3>`,
  3361. generateTrackList(unreleasedTrackListChunks, {strings, to})
  3362. ] : [],
  3363. ...artThingsAll.length ? [
  3364. `<h2 id="art">${strings('artistPage.artList.title')}</h2>`,
  3365. hasGallery && html.tag('p', strings('artistPage.viewArtGallery.orBrowseList', {
  3366. link: strings.link.artistGallery(artist, {
  3367. to,
  3368. text: strings('artistPage.viewArtGallery.link')
  3369. })
  3370. })),
  3371. html.tag('p', strings('artistPage.artGroupsLine', {
  3372. groups: strings.list.unit(artGroups
  3373. .map(({ group, contributions }) => strings('artistPage.groupsLine.item', {
  3374. group: strings.link.groupInfo(group, {to}),
  3375. contributions: strings.count.contributions(contributions)
  3376. })))
  3377. }))
  3378. html.tag('dl',
  3379. artListChunks.flatMap(({date, album, chunk}) => [
  3380. html.tag('dt', strings('artistPage.creditList.album.withDate', {
  3381. album: strings.link.album(album, {to}),
  3382. date: strings.count.date(date)
  3383. })),
  3384. html.tag('dd', html.tag('ul',
  3385. (chunk
  3386. .map(({album, track, key, ...props}) => ({
  3387. entry: (track
  3388. ? strings('artistPage.creditList.entry.track', {
  3389. track: strings.link.track(track, {to})
  3390. })
  3391. : `<i>${strings('artistPage.creditList.entry.album.' + {
  3392. wallpaperArtists: 'wallpaperArt',
  3393. bannerArtists: 'bannerArt',
  3394. coverArtists: 'coverArt'
  3395. }[key])}</i>`),
  3396. ...props
  3397. }))
  3398. .map(opts => generateEntryAccents({strings, to, ...opts}))
  3399. .map(row => `<li>${row}</li>`)
  3400. .join('\n'))
  3401. ))
  3402. ])
  3403. )
  3404. ] : [],
  3405. ...wikiInfo.features.flashesAndGames && flashes.length ? [
  3406. `<h2 id="flashes">${strings('artistPage.flashList.title')}</h2>`,
  3407. html.tag('dl',
  3408. flashListChunks.flatMap(({act, chunk, dateFirst, dateLast}) => [
  3409. html.tag('dt', strings('artistPage.creditList.flashAct.withDateRange', {
  3410. act: strings.link.flash(chunk[0].flash, {to, text: act.name}),
  3411. dateRange: strings.count.dateRange([dateFirst, dateLast])
  3412. })),
  3413. html.tag('dd', html.tag('ul',
  3414. (chunk
  3415. .map(({flash, ...props}) => ({
  3416. entry: strings('artistPage.creditList.entry.flash', {
  3417. flash: strings.link.flash(flash, {to})
  3418. }),
  3419. ...props
  3420. }))
  3421. .map(opts => generateEntryAccents({strings, to, ...opts}))
  3422. .map(row => `<li>${row}</li>`)
  3423. .join('\n'))
  3424. ))
  3425. ])
  3426. )
  3427. ] : [],
  3428. ...commentaryThings.length ? [
  3429. `<h2 id="commentary">${strings('artistPage.commentaryList.title')}</h2>`,
  3430. html.tag('dl',
  3431. commentaryListChunks.flatMap(({album, chunk}) => [
  3432. html.tag('dt', strings('artistPage.creditList.album', {
  3433. album: strings.link.album(album, {to})
  3434. })),
  3435. html.tag('dd', html.tag('ul',
  3436. (chunk
  3437. .map(({album, track, ...props}) => track
  3438. ? strings('artistPage.creditList.entry.track', {
  3439. track: strings.link.track(track, {to})
  3440. })
  3441. : `<i>${strings('artistPage.creditList.entry.album.commentary')}</i>`)
  3442. .map(row => `<li>${row}</li>`)
  3443. .join('\n'))
  3444. ))
  3445. ])
  3446. )
  3447. ] : []
  3448. ].filter(Boolean).join('\n')
  3449. },
  3450. nav: generateNavForArtist(artist, {strings, to, isGallery: false, hasGallery})
  3451. })
  3452. };
  3453. const galleryPage = hasGallery && {
  3454. type: 'page',
  3455. path: ['artistGallery', artist.directory],
  3456. page: ({strings, to}) => ({
  3457. title: strings('artistGalleryPage.title', {artist: name}),
  3458. main: {
  3459. classes: ['top-index'],
  3460. content: [
  3461. `<h1>${strings('artistGalleryPage.title', {artist: name})}</h1>`,
  3462. html.tag('p', {class: 'quick-info'},
  3463. strings('artistGalleryPage.infoLine', {
  3464. coverArts: strings.count.coverArts(artThingsGallery.length, {unit: true})
  3465. })
  3466. ),
  3467. html.tag('div', {class: 'grid-listing'},
  3468. getGridHTML({
  3469. strings, to,
  3470. entries: artThingsGallery.map(item => ({item})),
  3471. srcFn: thing => (thing.album
  3472. ? getTrackCover(thing, {to})
  3473. : getAlbumCover(thing, {to})),
  3474. hrefFn: thing => (thing.album
  3475. ? to('localized.track', thing.directory)
  3476. : to('localized.album', thing.directory))
  3477. })
  3478. )
  3479. ].filter(Boolean).join('\n')
  3480. },
  3481. nav: generateNavForArtist(artist, {strings, to, isGallery: true, hasGallery})
  3482. })
  3483. };
  3484. return [data, infoPage, galleryPage].filter(Boolean);
  3485. }
  3486. function generateNavForArtist(artist, {strings, to, isGallery, hasGallery}) {
  3487. const infoGalleryLinks = (hasGallery &&
  3488. generateInfoGalleryLinks('artist', 'artistGallery', artist, isGallery, {strings, to}))
  3489. return {
  3490. links: [
  3491. {
  3492. href: to('localized.home'),
  3493. title: wikiInfo.shortName
  3494. },
  3495. wikiInfo.features.listings &&
  3496. {
  3497. href: to('localized.listingIndex'),
  3498. title: strings('listingIndex.title')
  3499. },
  3500. {
  3501. html: strings('artistPage.nav.artist', {
  3502. artist: strings.link.artist(artist, {class: 'current', to})
  3503. })
  3504. },
  3505. hasGallery &&
  3506. {
  3507. divider: false,
  3508. html: `(${infoGalleryLinks})`
  3509. }
  3510. ]
  3511. };
  3512. }
  3513. function writeArtistAliasPage(artist) {
  3514. const { alias } = artist;
  3515. return async ({baseDirectory, strings, writePage}) => {
  3516. const { code } = strings;
  3517. const paths = writePage.paths(baseDirectory, 'artist', alias.directory);
  3518. const content = generateRedirectPage(alias.name, paths.pathname, {strings});
  3519. await writePage.write(content, {paths});
  3520. };
  3521. }
  3522. function generateRedirectPage(title, target, {strings}) {
  3523. return fixWS`
  3524. <!DOCTYPE html>
  3525. <html>
  3526. <head>
  3527. <title>${strings('redirectPage.title', {title})}</title>
  3528. <meta charset="utf-8">
  3529. <meta http-equiv="refresh" content="0;url=${target}">
  3530. <link rel="canonical" href="${target}">
  3531. <link rel="stylesheet" href="static/site-basic.css">
  3532. </head>
  3533. <body>
  3534. <main>
  3535. <h1>${strings('redirectPage.title', {title})}</h1>
  3536. <p>${strings('redirectPage.infoLine', {
  3537. target: `<a href="${target}">${target}</a>`
  3538. })}</p>
  3539. </main>
  3540. </body>
  3541. </html>
  3542. `;
  3543. }
  3544. function writeFlashPages() {
  3545. if (!wikiInfo.features.flashesAndGames) {
  3546. return;
  3547. }
  3548. return [
  3549. writeFlashIndex(),
  3550. ...flashData.map(writeFlashPage)
  3551. ];
  3552. }
  3553. function writeFlashIndex() {
  3554. return ({strings, writePage}) => writePage('flashIndex', '', ({to}) => ({
  3555. title: strings('flashIndex.title'),
  3556. main: {
  3557. classes: ['flash-index'],
  3558. content: fixWS`
  3559. <h1>${strings('flashIndex.title')}</h1>
  3560. <div class="long-content">
  3561. <p class="quick-info">${strings('misc.jumpTo')}</p>
  3562. <ul class="quick-info">
  3563. ${flashActData.filter(act => act.jump).map(({ anchor, jump, jumpColor }) => fixWS`
  3564. <li><a href="#${anchor}" style="${getLinkThemeString({color: jumpColor})}">${jump}</a></li>
  3565. `).join('\n')}
  3566. </ul>
  3567. </div>
  3568. ${flashActData.map((act, i) => fixWS`
  3569. <h2 id="${act.anchor}" style="${getLinkThemeString(act)}"><a href="${to('localized.flash', act.flashes[0].directory)}">${act.name}</a></h2>
  3570. <div class="grid-listing">
  3571. ${getFlashGridHTML({
  3572. strings, to,
  3573. entries: act.flashes.map(flash => ({item: flash})),
  3574. lazy: i === 0 ? 4 : true
  3575. })}
  3576. </div>
  3577. `).join('\n')}
  3578. `
  3579. },
  3580. nav: {simple: true}
  3581. }));
  3582. }
  3583. function writeFlashPage(flash) {
  3584. return ({strings, writePage}) => writePage('flash', flash.directory, ({to}) => ({
  3585. title: strings('flashPage.title', {flash: flash.name}),
  3586. theme: getThemeString(flash, [
  3587. `--flash-directory: ${flash.directory}`
  3588. ]),
  3589. main: {
  3590. content: fixWS`
  3591. <h1>${strings('flashPage.title', {flash: flash.name})}</h1>
  3592. ${generateCoverLink({
  3593. strings, to,
  3594. src: to('media.flashArt', flash.directory),
  3595. alt: strings('misc.alt.flashArt')
  3596. })}
  3597. <p>${strings('releaseInfo.released', {date: strings.count.date(flash.date)})}</p>
  3598. ${(flash.page || flash.urls.length) && `<p>${strings('releaseInfo.playOn', {
  3599. links: strings.list.or([
  3600. flash.page && getFlashLink(flash),
  3601. ...flash.urls
  3602. ].map(url => fancifyFlashURL(url, flash, {strings})))
  3603. })}</p>`}
  3604. ${flash.tracks.length && fixWS`
  3605. <p>Tracks featured in <i>${flash.name.replace(/\.$/, '')}</i>:</p>
  3606. <ul>
  3607. ${(flash.tracks
  3608. .map(track => strings('trackList.item.withArtists', {
  3609. track: strings.link.track(track, {strings, to}),
  3610. by: `<span class="by">${
  3611. strings('trackList.item.withArtists.by', {
  3612. artists: getArtistString(track.artists, {strings, to})
  3613. })
  3614. }</span>`
  3615. }))
  3616. .map(row => `<li>${row}</li>`)
  3617. .join('\n'))}
  3618. </ul>
  3619. `}
  3620. ${flash.contributors.textContent && fixWS`
  3621. <p>
  3622. ${strings('releaseInfo.contributors')}
  3623. <br>
  3624. ${transformInline(flash.contributors.textContent, {strings, to})}
  3625. </p>
  3626. `}
  3627. ${flash.contributors.length && fixWS`
  3628. <p>${strings('releaseInfo.contributors')}</p>
  3629. <ul>
  3630. ${flash.contributors
  3631. .map(contrib => `<li>${getArtistString([contrib], {
  3632. strings, to,
  3633. showContrib: true,
  3634. showIcons: true
  3635. })}</li>`)
  3636. .join('\n')}
  3637. </ul>
  3638. `}
  3639. `
  3640. },
  3641. sidebarLeft: generateSidebarForFlash(flash, {strings, to}),
  3642. nav: generateNavForFlash(flash, {strings, to})
  3643. }));
  3644. }
  3645. function generateNavForFlash(flash, {strings, to}) {
  3646. const previousNextLinks = generatePreviousNextLinks('localized.flash', flash, flashData, {strings, to});
  3647. return {
  3648. links: [
  3649. {
  3650. href: to('localized.home'),
  3651. title: wikiInfo.shortName
  3652. },
  3653. {
  3654. href: to('localized.flashIndex'),
  3655. title: strings('flashIndex.title')
  3656. },
  3657. {
  3658. html: strings('flashPage.nav.flash', {
  3659. flash: strings.link.flash(flash, {class: 'current', to})
  3660. })
  3661. },
  3662. previousNextLinks &&
  3663. {
  3664. divider: false,
  3665. html: `(${previousNextLinks})`
  3666. }
  3667. ],
  3668. content: fixWS`
  3669. <div>
  3670. ${chronologyLinks(flash, {
  3671. strings, to,
  3672. headingString: 'misc.chronology.heading.flash',
  3673. contribKey: 'contributors',
  3674. getThings: artist => artist.flashes.asContributor
  3675. })}
  3676. </div>
  3677. `
  3678. };
  3679. }
  3680. function generateSidebarForFlash(flash, {strings, to}) {
  3681. // all hard-coded, sorry :(
  3682. // this doesnt have a super portable implementation/design...yet!!
  3683. const act6 = flashActData.findIndex(act => act.name.startsWith('Act 6'));
  3684. const postCanon = flashActData.findIndex(act => act.name.includes('Post Canon'));
  3685. const outsideCanon = postCanon + flashActData.slice(postCanon).findIndex(act => !act.name.includes('Post Canon'));
  3686. const actIndex = flashActData.indexOf(flash.act);
  3687. const side = (
  3688. (actIndex < 0) ? 0 :
  3689. (actIndex < act6) ? 1 :
  3690. (actIndex <= outsideCanon) ? 2 :
  3691. 3
  3692. );
  3693. const currentAct = flash && flash.act;
  3694. return {
  3695. content: fixWS`
  3696. <h1>${strings.link.flashIndex('', {to, text: strings('flashIndex.title')})}</h1>
  3697. <dl>
  3698. ${flashActData.filter(act =>
  3699. act.name.startsWith('Act 1') ||
  3700. act.name.startsWith('Act 6 Act 1') ||
  3701. act.name.startsWith('Hiveswap') ||
  3702. // Sorry not sorry -Yiffy
  3703. (({index = flashActData.indexOf(act)} = {}) => (
  3704. index < act6 ? side === 1 :
  3705. index < outsideCanon ? side === 2 :
  3706. true
  3707. ))()
  3708. ).flatMap(act => [
  3709. act.name.startsWith('Act 1') && `<dt ${classes('side', side === 1 && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="--primary-color: #4ac925">Side 1 (Acts 1-5)</a></dt>`
  3710. || act.name.startsWith('Act 6 Act 1') && `<dt ${classes('side', side === 2 && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="--primary-color: #1076a2">Side 2 (Acts 6-7)</a></dt>`
  3711. || act.name.startsWith('Hiveswap Act 1') && `<dt ${classes('side', side === 3 && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="--primary-color: #008282">Outside Canon (Misc. Games)</a></dt>`,
  3712. (({index = flashActData.indexOf(act)} = {}) => (
  3713. index < act6 ? side === 1 :
  3714. index < outsideCanon ? side === 2 :
  3715. true
  3716. ))()
  3717. && `<dt ${classes(act === currentAct && 'current')}><a href="${to('localized.flash', act.flashes[0].directory)}" style="${getLinkThemeString(act)}">${act.name}</a></dt>`,
  3718. act === currentAct && fixWS`
  3719. <dd><ul>
  3720. ${act.flashes.map(f => fixWS`
  3721. <li ${classes(f === flash && 'current')}>${strings.link.flash(f, {to})}</li>
  3722. `).join('\n')}
  3723. </ul></dd>
  3724. `
  3725. ]).filter(Boolean).join('\n')}
  3726. </dl>
  3727. `
  3728. };
  3729. }
  3730. const listingSpec = [
  3731. {
  3732. directory: 'albums/by-name',
  3733. title: ({strings}) => strings('listingPage.listAlbums.byName.title'),
  3734. data() {
  3735. return albumData.slice()
  3736. .sort(sortByName);
  3737. },
  3738. row(album, {strings, to}) {
  3739. return strings('listingPage.listAlbums.byName.item', {
  3740. album: strings.link.album(album, {to}),
  3741. tracks: strings.count.tracks(album.tracks.length, {unit: true})
  3742. });
  3743. }
  3744. },
  3745. {
  3746. directory: 'albums/by-tracks',
  3747. title: ({strings}) => strings('listingPage.listAlbums.byTracks.title'),
  3748. data() {
  3749. return albumData.slice()
  3750. .sort((a, b) => b.tracks.length - a.tracks.length);
  3751. },
  3752. row(album, {strings, to}) {
  3753. return strings('listingPage.listAlbums.byTracks.item', {
  3754. album: strings.link.album(album, {to}),
  3755. tracks: strings.count.tracks(album.tracks.length, {unit: true})
  3756. });
  3757. }
  3758. },
  3759. {
  3760. directory: 'albums/by-duration',
  3761. title: ({strings}) => strings('listingPage.listAlbums.byDuration.title'),
  3762. data() {
  3763. return albumData
  3764. .map(album => ({album, duration: getTotalDuration(album.tracks)}))
  3765. .sort((a, b) => b.duration - a.duration);
  3766. },
  3767. row({album, duration}, {strings, to}) {
  3768. return strings('listingPage.listAlbums.byDuration.item', {
  3769. album: strings.link.album(album, {to}),
  3770. duration: strings.count.duration(duration)
  3771. });
  3772. }
  3773. },
  3774. {
  3775. directory: 'albums/by-date',
  3776. title: ({strings}) => strings('listingPage.listAlbums.byDate.title'),
  3777. data() {
  3778. return C.sortByDate(albumData
  3779. .filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY));
  3780. },
  3781. row(album, {strings, to}) {
  3782. return strings('listingPage.listAlbums.byDate.item', {
  3783. album: strings.link.album(album, {to}),
  3784. date: strings.count.date(album.date)
  3785. });
  3786. }
  3787. },
  3788. {
  3789. directory: 'albusm/by-date-added',
  3790. title: ({strings}) => strings('listingPage.listAlbums.byDateAdded.title'),
  3791. data() {
  3792. return chunkByProperties(albumData.slice().sort((a, b) => {
  3793. if (a.dateAdded < b.dateAdded) return -1;
  3794. if (a.dateAdded > b.dateAdded) return 1;
  3795. }), ['dateAdded']);
  3796. },
  3797. html(chunks, {strings, to}) {
  3798. return fixWS`
  3799. <dl>
  3800. ${chunks.map(({dateAdded, chunk: albums}) => fixWS`
  3801. <dt>${strings('listingPage.listAlbums.byDateAdded.date', {
  3802. date: strings.count.date(dateAdded)
  3803. })}</dt>
  3804. <dd><ul>
  3805. ${(albums
  3806. .map(album => strings('listingPage.listAlbums.byDateAdded.album', {
  3807. album: strings.link.album(album, {to})
  3808. }))
  3809. .map(row => `<li>${row}</li>`)
  3810. .join('\n'))}
  3811. </ul></dd>
  3812. `).join('\n')}
  3813. </dl>
  3814. `;
  3815. }
  3816. },
  3817. {
  3818. directory: 'artists/by-name',
  3819. title: ({strings}) => strings('listingPage.listArtists.byName.title'),
  3820. data() {
  3821. return artistData.slice()
  3822. .sort(sortByName)
  3823. .map(artist => ({artist, contributions: C.getArtistNumContributions(artist)}));
  3824. },
  3825. row({artist, contributions}, {strings, to}) {
  3826. return strings('listingPage.listArtists.byName.item', {
  3827. artist: strings.link.artist(artist, {to}),
  3828. contributions: strings.count.contributions(contributions, {to, unit: true})
  3829. });
  3830. }
  3831. },
  3832. {
  3833. directory: 'artists/by-contribs',
  3834. title: ({strings}) => strings('listingPage.listArtists.byContribs.title'),
  3835. data() {
  3836. return {
  3837. toTracks: (artistData
  3838. .map(artist => ({
  3839. artist,
  3840. contributions: (
  3841. artist.tracks.asContributor.length +
  3842. artist.tracks.asArtist.length
  3843. )
  3844. }))
  3845. .sort((a, b) => b.contributions - a.contributions)
  3846. .filter(({ contributions }) => contributions)),
  3847. toArtAndFlashes: (artistData
  3848. .map(artist => ({
  3849. artist,
  3850. contributions: (
  3851. artist.tracks.asCoverArtist.length +
  3852. artist.albums.asCoverArtist.length +
  3853. artist.albums.asWallpaperArtist.length +
  3854. artist.albums.asBannerArtist.length +
  3855. (wikiInfo.features.flashesAndGames
  3856. ? artist.flashes.asContributor.length
  3857. : 0)
  3858. )
  3859. }))
  3860. .sort((a, b) => b.contributions - a.contributions)
  3861. .filter(({ contributions }) => contributions))
  3862. };
  3863. },
  3864. html({toTracks, toArtAndFlashes}, {strings, to}) {
  3865. return fixWS`
  3866. <div class="content-columns">
  3867. <div class="column">
  3868. <h2>${strings('listingPage.misc.trackContributors')}</h2>
  3869. <ul>
  3870. ${(toTracks
  3871. .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', {
  3872. artist: strings.link.artist(artist, {to}),
  3873. contributions: strings.count.contributions(contributions, {unit: true})
  3874. }))
  3875. .map(row => `<li>${row}</li>`)
  3876. .join('\n'))}
  3877. </ul>
  3878. </div>
  3879. <div class="column">
  3880. <h2>${strings('listingPage.misc' +
  3881. (wikiInfo.features.flashesAndGames
  3882. ? '.artAndFlashContributors'
  3883. : '.artContributors'))}</h2>
  3884. <ul>
  3885. ${(toArtAndFlashes
  3886. .map(({ artist, contributions }) => strings('listingPage.listArtists.byContribs.item', {
  3887. artist: strings.link.artist(artist, {to}),
  3888. contributions: strings.count.contributions(contributions, {unit: true})
  3889. }))
  3890. .map(row => `<li>${row}</li>`)
  3891. .join('\n'))}
  3892. </ul>
  3893. </div>
  3894. </div>
  3895. `;
  3896. }
  3897. },
  3898. {
  3899. directory: 'artists/by-commentary',
  3900. title: ({strings}) => strings('listingPage.listArtists.byCommentary.title'),
  3901. data() {
  3902. return artistData
  3903. .map(artist => ({artist, entries: artist.tracks.asCommentator.length + artist.albums.asCommentator.length}))
  3904. .filter(({ entries }) => entries)
  3905. .sort((a, b) => b.entries - a.entries);
  3906. },
  3907. row({artist, entries}, {strings, to}) {
  3908. return strings('listingPage.listArtists.byCommentary.item', {
  3909. artist: strings.link.artist(artist, {to}),
  3910. entries: strings.count.commentaryEntries(entries, {unit: true})
  3911. });
  3912. }
  3913. },
  3914. {
  3915. directory: 'artists/by-duration',
  3916. title: ({strings}) => strings('listingPage.listArtists.byDuration.title'),
  3917. data() {
  3918. return artistData
  3919. .map(artist => ({artist, duration: getTotalDuration(
  3920. [...artist.tracks.asArtist, ...artist.tracks.asContributor].filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY))
  3921. }))
  3922. .filter(({ duration }) => duration > 0)
  3923. .sort((a, b) => b.duration - a.duration);
  3924. },
  3925. row({artist, duration}, {strings, to}) {
  3926. return strings('listingPage.listArtists.byDuration.item', {
  3927. artist: strings.link.artist(artist, {to}),
  3928. duration: strings.count.duration(duration)
  3929. });
  3930. }
  3931. },
  3932. {
  3933. directory: 'artists/by-latest',
  3934. title: ({strings}) => strings('listingPage.listArtists.byLatest.title'),
  3935. data() {
  3936. const reversedTracks = trackData.slice().reverse();
  3937. const reversedArtThings = justEverythingSortedByArtDateMan.slice().reverse();
  3938. return {
  3939. toTracks: C.sortByDate(artistData
  3940. .filter(artist => !artist.alias)
  3941. .map(artist => ({
  3942. artist,
  3943. date: reversedTracks.find(({ album, artists, contributors }) => (
  3944. album.directory !== C.UNRELEASED_TRACKS_DIRECTORY &&
  3945. [...artists, ...contributors].some(({ who }) => who === artist)
  3946. ))?.date
  3947. }))
  3948. .filter(({ date }) => date)
  3949. .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)).reverse(),
  3950. toArtAndFlashes: C.sortByDate(artistData
  3951. .filter(artist => !artist.alias)
  3952. .map(artist => {
  3953. const thing = reversedArtThings.find(({ album, coverArtists, contributors }) => (
  3954. album?.directory !== C.UNRELEASED_TRACKS_DIRECTORY &&
  3955. [...coverArtists || [], ...!album && contributors || []].some(({ who }) => who === artist)
  3956. ));
  3957. return thing && {
  3958. artist,
  3959. date: (thing.coverArtists?.some(({ who }) => who === artist)
  3960. ? thing.coverArtDate
  3961. : thing.date)
  3962. };
  3963. })
  3964. .filter(Boolean)
  3965. .sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0)
  3966. ).reverse()
  3967. };
  3968. },
  3969. html({toTracks, toArtAndFlashes}, {strings, to}) {
  3970. return fixWS`
  3971. <div class="content-columns">
  3972. <div class="column">
  3973. <h2>${strings('listingPage.misc.trackContributors')}</h2>
  3974. <ul>
  3975. ${(toTracks
  3976. .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', {
  3977. artist: strings.link.artist(artist, {to}),
  3978. date: strings.count.date(date)
  3979. }))
  3980. .map(row => `<li>${row}</li>`)
  3981. .join('\n'))}
  3982. </ul>
  3983. </div>
  3984. <div class="column">
  3985. <h2>${strings('listingPage.misc' +
  3986. (wikiInfo.features.flashesAndGames
  3987. ? '.artAndFlashContributors'
  3988. : '.artContributors'))}</h2>
  3989. <ul>
  3990. ${(toArtAndFlashes
  3991. .map(({ artist, date }) => strings('listingPage.listArtists.byLatest.item', {
  3992. artist: strings.link.artist(artist, {to}),
  3993. date: strings.count.date(date)
  3994. }))
  3995. .map(row => `<li>${row}</li>`)
  3996. .join('\n'))}
  3997. </ul>
  3998. </div>
  3999. </div>
  4000. `;
  4001. }
  4002. },
  4003. {
  4004. directory: 'groups/by-name',
  4005. title: ({strings}) => strings('listingPage.listGroups.byName.title'),
  4006. condition: () => wikiInfo.features.groupUI,
  4007. data() {
  4008. return groupData.slice().sort(sortByName);
  4009. },
  4010. row(group, {strings, to}) {
  4011. return strings('listingPage.listGroups.byCategory.group', {
  4012. group: strings.link.groupInfo(group, {to}),
  4013. gallery: strings.link.groupGallery(group, {
  4014. to,
  4015. text: strings('listingPage.listGroups.byCategory.group.gallery')
  4016. })
  4017. });
  4018. }
  4019. },
  4020. {
  4021. directory: 'groups/by-category',
  4022. title: ({strings}) => strings('listingPage.listGroups.byCategory.title'),
  4023. condition: () => wikiInfo.features.groupUI,
  4024. html({strings, to}) {
  4025. return fixWS`
  4026. <dl>
  4027. ${groupCategoryData.map(category => fixWS`
  4028. <dt>${strings('listingPage.listGroups.byCategory.category', {
  4029. category: strings.link.groupInfo(category.groups[0], {to, text: category.name})
  4030. })}</dt>
  4031. <dd><ul>
  4032. ${(category.groups
  4033. .map(group => strings('listingPage.listGroups.byCategory.group', {
  4034. group: strings.link.groupInfo(group, {to}),
  4035. gallery: strings.link.groupGallery(group, {
  4036. to,
  4037. text: strings('listingPage.listGroups.byCategory.group.gallery')
  4038. })
  4039. }))
  4040. .map(row => `<li>${row}</li>`)
  4041. .join('\n'))}
  4042. </ul></dd>
  4043. `).join('\n')}
  4044. </dl>
  4045. `;
  4046. }
  4047. },
  4048. {
  4049. directory: 'groups/by-albums',
  4050. title: ({strings}) => strings('listingPage.listGroups.byAlbums.title'),
  4051. condition: () => wikiInfo.features.groupUI,
  4052. data() {
  4053. return groupData
  4054. .map(group => ({group, albums: group.albums.length}))
  4055. .sort((a, b) => b.albums - a.albums);
  4056. },
  4057. row({group, albums}, {strings, to}) {
  4058. return strings('listingPage.listGroups.byAlbums.item', {
  4059. group: strings.link.groupInfo(group, {to}),
  4060. albums: strings.count.albums(albums, {unit: true})
  4061. });
  4062. }
  4063. },
  4064. {
  4065. directory: 'groups/by-tracks',
  4066. title: ({strings}) => strings('listingPage.listGroups.byTracks.title'),
  4067. condition: () => wikiInfo.features.groupUI,
  4068. data() {
  4069. return groupData
  4070. .map(group => ({group, tracks: group.albums.reduce((acc, album) => acc + album.tracks.length, 0)}))
  4071. .sort((a, b) => b.tracks - a.tracks);
  4072. },
  4073. row({group, tracks}, {strings, to}) {
  4074. return strings('listingPage.listGroups.byTracks.item', {
  4075. group: strings.link.groupInfo(group, {to}),
  4076. tracks: strings.count.tracks(tracks, {unit: true})
  4077. });
  4078. }
  4079. },
  4080. {
  4081. directory: 'groups/by-duration',
  4082. title: ({strings}) => strings('listingPage.listGroups.byDuration.title'),
  4083. condition: () => wikiInfo.features.groupUI,
  4084. data() {
  4085. return groupData
  4086. .map(group => ({group, duration: getTotalDuration(group.albums.flatMap(album => album.tracks))}))
  4087. .sort((a, b) => b.duration - a.duration);
  4088. },
  4089. row({group, duration}, {strings, to}) {
  4090. return strings('listingPage.listGroups.byDuration.item', {
  4091. group: strings.link.groupInfo(group, {to}),
  4092. duration: strings.count.duration(duration)
  4093. });
  4094. }
  4095. },
  4096. {
  4097. directory: 'groups/by-latest-album',
  4098. title: ({strings}) => strings('listingPage.listGroups.byLatest.title'),
  4099. condition: () => wikiInfo.features.groupUI,
  4100. data() {
  4101. return C.sortByDate(groupData
  4102. .map(group => ({group, date: group.albums[group.albums.length - 1].date}))
  4103. // So this is kinda tough to explain, 8ut 8asically, when we reverse the list after sorting it 8y d8te
  4104. // (so that the latest d8tes come first), it also flips the order of groups which share the same d8te.
  4105. // This happens mostly when a single al8um is the l8test in two groups. So, say one such al8um is in
  4106. // the groups "Fandom" and "UMSPAF". Per category order, Fandom is meant to show up 8efore UMSPAF, 8ut
  4107. // when we do the reverse l8ter, that flips them, and UMSPAF ends up displaying 8efore Fandom. So we do
  4108. // an extra reverse here, which will fix that and only affect groups that share the same d8te (8ecause
  4109. // groups that don't will 8e moved 8y the sortByDate call surrounding this).
  4110. .reverse()).reverse()
  4111. },
  4112. row({group, date}, {strings, to}) {
  4113. return strings('listingPage.listGroups.byLatest.item', {
  4114. group: strings.link.groupInfo(group, {to}),
  4115. date: strings.count.date(date)
  4116. });
  4117. }
  4118. },
  4119. {
  4120. directory: 'tracks/by-name',
  4121. title: ({strings}) => strings('listingPage.listTracks.byName.title'),
  4122. data() {
  4123. return trackData.slice().sort(sortByName);
  4124. },
  4125. row(track, {strings, to}) {
  4126. return strings('listingPage.listTracks.byName.item', {
  4127. track: strings.link.track(track, {to})
  4128. });
  4129. }
  4130. },
  4131. {
  4132. directory: 'tracks/by-album',
  4133. title: ({strings}) => strings('listingPage.listTracks.byAlbum.title'),
  4134. html({strings, to}) {
  4135. return fixWS`
  4136. <dl>
  4137. ${albumData.map(album => fixWS`
  4138. <dt>${strings('listingPage.listTracks.byAlbum.album', {
  4139. album: strings.link.album(album, {to})
  4140. })}</dt>
  4141. <dd><ol>
  4142. ${(album.tracks
  4143. .map(track => strings('listingPage.listTracks.byAlbum.track', {
  4144. track: strings.link.track(track, {to})
  4145. }))
  4146. .map(row => `<li>${row}</li>`)
  4147. .join('\n'))}
  4148. </ol></dd>
  4149. `).join('\n')}
  4150. </dl>
  4151. `;
  4152. }
  4153. },
  4154. {
  4155. directory: 'tracks/by-date',
  4156. title: ({strings}) => strings('listingPage.listTracks.byDate.title'),
  4157. data() {
  4158. return chunkByProperties(
  4159. C.sortByDate(trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)),
  4160. ['album', 'date']
  4161. );
  4162. },
  4163. html(chunks, {strings, to}) {
  4164. return fixWS`
  4165. <dl>
  4166. ${chunks.map(({album, date, chunk: tracks}) => fixWS`
  4167. <dt>${strings('listingPage.listTracks.byDate.album', {
  4168. album: strings.link.album(album, {to}),
  4169. date: strings.count.date(date)
  4170. })}</dt>
  4171. <dd><ul>
  4172. ${(tracks
  4173. .map(track => track.aka
  4174. ? `<li class="rerelease">${strings('listingPage.listTracks.byDate.track.rerelease', {
  4175. track: strings.link.track(track, {to})
  4176. })}</li>`
  4177. : `<li>${strings('listingPage.listTracks.byDate.track', {
  4178. track: strings.link.track(track, {to})
  4179. })}</li>`)
  4180. .join('\n'))}
  4181. </ul></dd>
  4182. `).join('\n')}
  4183. </dl>
  4184. `;
  4185. }
  4186. },
  4187. {
  4188. directory: 'tracks/by-duration',
  4189. title: ({strings}) => strings('listingPage.listTracks.byDuration.title'),
  4190. data() {
  4191. return trackData
  4192. .filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY)
  4193. .map(track => ({track, duration: track.duration}))
  4194. .filter(({ duration }) => duration > 0)
  4195. .sort((a, b) => b.duration - a.duration);
  4196. },
  4197. row({track, duration}, {strings, to}) {
  4198. return strings('listingPage.listTracks.byDuration.item', {
  4199. track: strings.link.track(track, {to}),
  4200. duration: strings.count.duration(duration)
  4201. });
  4202. }
  4203. },
  4204. {
  4205. directory: 'tracks/by-duration-in-album',
  4206. title: ({strings}) => strings('listingPage.listTracks.byDurationInAlbum.title'),
  4207. data() {
  4208. return albumData.map(album => ({
  4209. album,
  4210. tracks: album.tracks.slice().sort((a, b) => b.duration - a.duration)
  4211. }));
  4212. },
  4213. html(albums, {strings, to}) {
  4214. return fixWS`
  4215. <dl>
  4216. ${albums.map(({album, tracks}) => fixWS`
  4217. <dt>${strings('listingPage.listTracks.byDurationInAlbum.album', {
  4218. album: strings.link.album(album, {to})
  4219. })}</dt>
  4220. <dd><ul>
  4221. ${(tracks
  4222. .map(track => strings('listingPage.listTracks.byDurationInAlbum.track', {
  4223. track: strings.link.track(track, {to}),
  4224. duration: strings.count.duration(track.duration)
  4225. }))
  4226. .map(row => `<li>${row}</li>`)
  4227. .join('\n'))}
  4228. </dd></ul>
  4229. `).join('\n')}
  4230. </dl>
  4231. `;
  4232. }
  4233. },
  4234. {
  4235. directory: 'tracks/by-times-referenced',
  4236. title: ({strings}) => strings('listingPage.listTracks.byTimesReferenced.title'),
  4237. data() {
  4238. return trackData
  4239. .map(track => ({track, timesReferenced: track.referencedBy.length}))
  4240. .filter(({ timesReferenced }) => timesReferenced > 0)
  4241. .sort((a, b) => b.timesReferenced - a.timesReferenced);
  4242. },
  4243. row({track, timesReferenced}, {strings, to}) {
  4244. return strings('listingPage.listTracks.byTimesReferenced.item', {
  4245. track: strings.link.track(track, {to}),
  4246. timesReferenced: strings.count.timesReferenced(timesReferenced, {unit: true})
  4247. });
  4248. }
  4249. },
  4250. {
  4251. directory: 'tracks/in-flashes/by-album',
  4252. title: ({strings}) => strings('listingPage.listTracks.inFlashes.byAlbum.title'),
  4253. condition: () => wikiInfo.features.flashesAndGames,
  4254. data() {
  4255. return chunkByProperties(trackData.filter(t => t.flashes.length > 0), ['album'])
  4256. .filter(({ album }) => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
  4257. },
  4258. html(chunks, {strings, to}) {
  4259. return fixWS`
  4260. <dl>
  4261. ${chunks.map(({album, chunk: tracks}) => fixWS`
  4262. <dt>${strings('listingPage.listTracks.inFlashes.byAlbum.album', {
  4263. album: strings.link.album(album, {to}),
  4264. date: strings.count.date(album.date)
  4265. })}</dt>
  4266. <dd><ul>
  4267. ${(tracks
  4268. .map(track => strings('listingPage.listTracks.inFlashes.byAlbum.track', {
  4269. track: strings.link.track(track, {to}),
  4270. flashes: strings.list.and(track.flashes.map(flash => strings.link.flash(flash, {to})))
  4271. }))
  4272. .map(row => `<li>${row}</li>`)
  4273. .join('\n'))}
  4274. </dd></ul>
  4275. `).join('\n')}
  4276. </dl>
  4277. `;
  4278. }
  4279. },
  4280. {
  4281. directory: 'tracks/in-flashes/by-flash',
  4282. title: ({strings}) => strings('listingPage.listTracks.inFlashes.byFlash.title'),
  4283. condition: () => wikiInfo.features.flashesAndGames,
  4284. html({strings, to}) {
  4285. return fixWS`
  4286. <dl>
  4287. ${C.sortByDate(flashData.slice()).map(flash => fixWS`
  4288. <dt>${strings('listingPage.listTracks.inFlashes.byFlash.flash', {
  4289. flash: strings.link.flash(flash, {to}),
  4290. date: strings.count.date(flash.date)
  4291. })}</dt>
  4292. <dd><ul>
  4293. ${(flash.tracks
  4294. .map(track => strings('listingPage.listTracks.inFlashes.byFlash.track', {
  4295. track: strings.link.track(track, {to}),
  4296. album: strings.link.album(track.album, {to})
  4297. }))
  4298. .map(row => `<li>${row}</li>`)
  4299. .join('\n'))}
  4300. </ul></dd>
  4301. `).join('\n')}
  4302. </dl>
  4303. `;
  4304. }
  4305. },
  4306. {
  4307. directory: 'tracks/with-lyrics',
  4308. title: ({strings}) => strings('listingPage.listTracks.withLyrics.title'),
  4309. data() {
  4310. return chunkByProperties(trackData.filter(t => t.lyrics), ['album']);
  4311. },
  4312. html(chunks, {strings, to}) {
  4313. return fixWS`
  4314. <dl>
  4315. ${chunks.map(({album, chunk: tracks}) => fixWS`
  4316. <dt>${strings('listingPage.listTracks.withLyrics.album', {
  4317. album: strings.link.album(album, {to}),
  4318. date: strings.count.date(album.date)
  4319. })}</dt>
  4320. <dd><ul>
  4321. ${(tracks
  4322. .map(track => strings('listingPage.listTracks.withLyrics.track', {
  4323. track: strings.link.track(track, {to}),
  4324. }))
  4325. .map(row => `<li>${row}</li>`)
  4326. .join('\n'))}
  4327. </dd></ul>
  4328. `).join('\n')}
  4329. </dl>
  4330. `;
  4331. }
  4332. },
  4333. {
  4334. directory: 'tags/by-name',
  4335. title: ({strings}) => strings('listingPage.listTags.byName.title'),
  4336. condition: () => wikiInfo.features.artTagUI,
  4337. data() {
  4338. return tagData
  4339. .filter(tag => !tag.isCW)
  4340. .sort(sortByName)
  4341. .map(tag => ({tag, timesUsed: tag.things.length}));
  4342. },
  4343. row({tag, timesUsed}, {strings, to}) {
  4344. return strings('listingPage.listTags.byName.item', {
  4345. tag: strings.link.tag(tag, {to}),
  4346. timesUsed: strings.count.timesUsed(timesUsed, {unit: true})
  4347. });
  4348. }
  4349. },
  4350. {
  4351. directory: 'tags/by-uses',
  4352. title: ({strings}) => strings('listingPage.listTags.byUses.title'),
  4353. condition: () => wikiInfo.features.artTagUI,
  4354. data() {
  4355. return tagData
  4356. .filter(tag => !tag.isCW)
  4357. .map(tag => ({tag, timesUsed: tag.things.length}))
  4358. .sort((a, b) => b.timesUsed - a.timesUsed);
  4359. },
  4360. row({tag, timesUsed}, {strings, to}) {
  4361. return strings('listingPage.listTags.byUses.item', {
  4362. tag: strings.link.tag(tag, {to}),
  4363. timesUsed: strings.count.timesUsed(timesUsed, {unit: true})
  4364. });
  4365. }
  4366. },
  4367. {
  4368. directory: 'random',
  4369. title: ({strings}) => `Random Pages`,
  4370. html: ({strings, to}) => fixWS`
  4371. <p>Choose a link to go to a random page in that category or album! If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.</p>
  4372. <p class="js-hide-once-data">(Data files are downloading in the background! Please wait for data to load.)</p>
  4373. <p class="js-show-once-data">(Data files have finished being downloaded. The links should work!)</p>
  4374. <dl>
  4375. <dt>Miscellaneous:</dt>
  4376. <dd><ul>
  4377. <li>
  4378. <a href="#" data-random="artist">Random Artist</a>
  4379. (<a href="#" data-random="artist-more-than-one-contrib">&gt;1 contribution</a>)
  4380. </li>
  4381. <li><a href="#" data-random="album">Random Album (whole site)</a></li>
  4382. <li><a href="#" data-random="track">Random Track (whole site)</a></li>
  4383. </ul></dd>
  4384. ${[
  4385. {name: 'Official', albumData: officialAlbumData, code: 'official'},
  4386. {name: 'Fandom', albumData: fandomAlbumData, code: 'fandom'}
  4387. ].map(category => fixWS`
  4388. <dt>${category.name}: (<a href="#" data-random="album-in-${category.code}">Random Album</a>, <a href="#" data-random="track-in-${category.code}">Random Track</a>)</dt>
  4389. <dd><ul>${category.albumData.map(album => fixWS`
  4390. <li><a style="${getLinkThemeString(album)}; --album-directory: ${album.directory}" href="#" data-random="track-in-album">${album.name}</a></li>
  4391. `).join('\n')}</ul></dd>
  4392. `).join('\n')}
  4393. </dl>
  4394. `
  4395. }
  4396. ];
  4397. function writeListingPages() {
  4398. if (!wikiInfo.features.listings) {
  4399. return;
  4400. }
  4401. return [
  4402. writeListingIndex(),
  4403. ...listingSpec.map(writeListingPage).filter(Boolean)
  4404. ];
  4405. }
  4406. function writeListingIndex() {
  4407. const releasedTracks = trackData.filter(track => track.album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
  4408. const releasedAlbums = albumData.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
  4409. const duration = getTotalDuration(releasedTracks);
  4410. return ({strings, writePage}) => writePage('listingIndex', '', ({to}) => ({
  4411. title: strings('listingIndex.title'),
  4412. main: {
  4413. content: fixWS`
  4414. <h1>${strings('listingIndex.title')}</h1>
  4415. <p>${strings('listingIndex.infoLine', {
  4416. wiki: wikiInfo.name,
  4417. tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
  4418. albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
  4419. duration: `<b>${strings.count.duration(duration, {approximate: true, unit: true})}</b>`
  4420. })}</p>
  4421. <hr>
  4422. <p>${strings('listingIndex.exploreList')}</p>
  4423. ${generateLinkIndexForListings(null, {strings, to})}
  4424. `
  4425. },
  4426. sidebarLeft: {
  4427. content: generateSidebarForListings(null, {strings, to})
  4428. },
  4429. nav: {simple: true}
  4430. }))
  4431. }
  4432. function writeListingPage(listing) {
  4433. if (listing.condition && !listing.condition()) {
  4434. return null;
  4435. }
  4436. const data = (listing.data
  4437. ? listing.data()
  4438. : null);
  4439. return ({strings, writePage}) => writePage('listing', listing.directory, ({to}) => ({
  4440. title: listing.title({strings}),
  4441. main: {
  4442. content: fixWS`
  4443. <h1>${listing.title({strings})}</h1>
  4444. ${listing.html && (listing.data
  4445. ? listing.html(data, {strings, to})
  4446. : listing.html({strings, to}))}
  4447. ${listing.row && fixWS`
  4448. <ul>
  4449. ${(data
  4450. .map(item => listing.row(item, {strings, to}))
  4451. .map(row => `<li>${row}</li>`)
  4452. .join('\n'))}
  4453. </ul>
  4454. `}
  4455. `
  4456. },
  4457. sidebarLeft: {
  4458. content: generateSidebarForListings(listing, {strings, to})
  4459. },
  4460. nav: {
  4461. links: [
  4462. {
  4463. href: to('localized.home'),
  4464. title: wikiInfo.shortName
  4465. },
  4466. {
  4467. href: to('localized.listingIndex'),
  4468. title: strings('listingIndex.title')
  4469. },
  4470. {
  4471. href: '',
  4472. title: listing.title({strings})
  4473. }
  4474. ]
  4475. }
  4476. }));
  4477. }
  4478. function generateSidebarForListings(currentListing, {strings, to}) {
  4479. return fixWS`
  4480. <h1>${strings.link.listingIndex('', {text: strings('listingIndex.title'), to})}</h1>
  4481. ${generateLinkIndexForListings(currentListing, {strings, to})}
  4482. `;
  4483. }
  4484. function generateLinkIndexForListings(currentListing, {strings, to}) {
  4485. return fixWS`
  4486. <ul>
  4487. ${(listingSpec
  4488. .filter(({ condition }) => !condition || condition())
  4489. .map(listing => fixWS`
  4490. <li ${classes(listing === currentListing && 'current')}>
  4491. <a href="${to('localized.listing', listing.directory)}">${listing.title({strings})}</a>
  4492. </li>
  4493. `)
  4494. .join('\n'))}
  4495. </ul>
  4496. `;
  4497. }
  4498. function filterAlbumsByCommentary() {
  4499. return albumData.filter(album => [album, ...album.tracks].some(x => x.commentary));
  4500. }
  4501. function writeCommentaryPages() {
  4502. if (!filterAlbumsByCommentary().length) {
  4503. return;
  4504. }
  4505. return [
  4506. writeCommentaryIndex(),
  4507. ...filterAlbumsByCommentary().map(writeAlbumCommentaryPage)
  4508. ];
  4509. }
  4510. function writeCommentaryIndex() {
  4511. const data = filterAlbumsByCommentary()
  4512. .map(album => ({
  4513. album,
  4514. entries: [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary)
  4515. }))
  4516. .map(({ album, entries }) => ({
  4517. album, entries,
  4518. words: entries.join(' ').split(' ').length
  4519. }));
  4520. const totalEntries = data.reduce((acc, {entries}) => acc + entries.length, 0);
  4521. const totalWords = data.reduce((acc, {words}) => acc + words, 0);
  4522. return ({strings, writePage}) => writePage('commentaryIndex', '', ({to}) => ({
  4523. title: strings('commentaryIndex.title'),
  4524. main: {
  4525. content: fixWS`
  4526. <div class="long-content">
  4527. <h1>${strings('commentaryIndex.title')}</h1>
  4528. <p>${strings('commentaryIndex.infoLine', {
  4529. words: `<b>${strings.count.words(totalWords, {unit: true})}</b>`,
  4530. entries: `<b>${strings.count.commentaryEntries(totalEntries, {unit: true})}</b>`
  4531. })}</p>
  4532. <p>${strings('commentaryIndex.albumList.title')}</p>
  4533. <ul>
  4534. ${data
  4535. .map(({ album, entries, words }) => fixWS`
  4536. <li>${strings('commentaryIndex.albumList.item', {
  4537. album: strings.link.albumCommentary(album, {to}),
  4538. words: strings.count.words(words, {unit: true}),
  4539. entries: strings.count.commentaryEntries(entries.length, {unit: true})
  4540. })}</li>
  4541. `)
  4542. .join('\n')}
  4543. </ul>
  4544. </div>
  4545. `
  4546. },
  4547. nav: {simple: true}
  4548. }));
  4549. }
  4550. function writeAlbumCommentaryPage(album) {
  4551. const entries = [album, ...album.tracks].filter(x => x.commentary).map(x => x.commentary);
  4552. const words = entries.join(' ').split(' ').length;
  4553. return ({strings, writePage}) => writePage('albumCommentary', album.directory, ({to}) => ({
  4554. title: strings('albumCommentaryPage.title', {album: album.name}),
  4555. stylesheet: getAlbumStylesheet(album, {to}),
  4556. theme: getThemeString(album),
  4557. main: {
  4558. content: fixWS`
  4559. <div class="long-content">
  4560. <h1>${strings('albumCommentaryPage.title', {
  4561. album: strings.link.album(album, {to})
  4562. })}</h1>
  4563. <p>${strings('albumCommentaryPage.infoLine', {
  4564. words: `<b>${strings.count.words(words, {unit: true})}</b>`,
  4565. entries: `<b>${strings.count.commentaryEntries(entries.length, {unit: true})}</b>`
  4566. })}</p>
  4567. ${album.commentary && fixWS`
  4568. <h3>${strings('albumCommentaryPage.entry.title.albumCommentary')}</h3>
  4569. <blockquote>
  4570. ${transformMultiline(album.commentary, {strings, to})}
  4571. </blockquote>
  4572. `}
  4573. ${album.tracks.filter(t => t.commentary).map(track => fixWS`
  4574. <h3 id="${track.directory}">${strings('albumCommentaryPage.entry.title.trackCommentary', {
  4575. track: strings.link.track(track, {to})
  4576. })}</h3>
  4577. <blockquote style="${getLinkThemeString(track)}">
  4578. ${transformMultiline(track.commentary, {strings, to})}
  4579. </blockquote>
  4580. `).join('\n')}
  4581. </div>
  4582. `
  4583. },
  4584. nav: {
  4585. links: [
  4586. {
  4587. href: to('localized.home'),
  4588. title: wikiInfo.shortName
  4589. },
  4590. {
  4591. href: to('localized.commentaryIndex'),
  4592. title: strings('commentaryIndex.title')
  4593. },
  4594. {
  4595. html: strings('albumCommentaryPage.nav.album', {
  4596. album: strings.link.albumCommentary(album, {class: 'current', to})
  4597. })
  4598. }
  4599. ]
  4600. }
  4601. }));
  4602. }
  4603. function writeTagPages() {
  4604. if (!wikiInfo.features.artTagUI) {
  4605. return;
  4606. }
  4607. return tagData.filter(tag => !tag.isCW).map(writeTagPage);
  4608. }
  4609. function writeTagPage(tag) {
  4610. const { things } = tag;
  4611. return ({strings, writePage}) => writePage('tag', tag.directory, ({to}) => ({
  4612. title: strings('tagPage.title', {tag: tag.name}),
  4613. theme: getThemeString(tag),
  4614. main: {
  4615. classes: ['top-index'],
  4616. content: fixWS`
  4617. <h1>${strings('tagPage.title', {tag: tag.name})}</h1>
  4618. <p class="quick-info">${strings('tagPage.infoLine', {
  4619. coverArts: strings.count.coverArts(things.length, {unit: true})
  4620. })}</p>
  4621. <div class="grid-listing">
  4622. ${getGridHTML({
  4623. strings, to,
  4624. entries: things.map(item => ({item})),
  4625. srcFn: thing => (thing.album
  4626. ? getTrackCover(thing, {to})
  4627. : getAlbumCover(thing, {to})),
  4628. hrefFn: thing => (thing.album
  4629. ? to('localized.track', thing.directory)
  4630. : to('localized.album', thing.directory))
  4631. })}
  4632. </div>
  4633. `
  4634. },
  4635. nav: {
  4636. links: [
  4637. {
  4638. href: to('localized.home'),
  4639. title: wikiInfo.shortName
  4640. },
  4641. wikiInfo.features.listings &&
  4642. {
  4643. href: to('localized.listingIndex'),
  4644. title: strings('listingIndex.title')
  4645. },
  4646. {
  4647. html: strings('tagPage.nav.tag', {
  4648. tag: strings.link.tag(tag, {class: 'current', to})
  4649. })
  4650. }
  4651. ]
  4652. }
  4653. }));
  4654. }
  4655. function getArtistString(artists, {strings, to, showIcons = false, showContrib = false}) {
  4656. return strings.list.and(artists.map(({ who, what }) => {
  4657. const { urls, directory, name } = who;
  4658. return [
  4659. strings.link.artist(who, {to}),
  4660. showContrib && what && `(${what})`,
  4661. showIcons && urls.length && `<span class="icons">(${
  4662. strings.list.unit(urls.map(url => iconifyURL(url, {strings, to})))
  4663. })</span>`
  4664. ].filter(Boolean).join(' ');
  4665. }));
  4666. }
  4667. function getLinkThemeString(thing) {
  4668. const { primary, dim } = C.getColors(thing.color || wikiInfo.color);
  4669. return `--primary-color: ${primary}; --dim-color: ${dim}`;
  4670. }
  4671. function getThemeString(thing, additionalVariables = []) {
  4672. const { primary, dim } = C.getColors(thing.color || wikiInfo.color);
  4673. const variables = [
  4674. `--primary-color: ${primary}`,
  4675. `--dim-color: ${dim}`,
  4676. ...additionalVariables
  4677. ].filter(Boolean);
  4678. return fixWS`
  4679. ${variables.length && fixWS`
  4680. :root {
  4681. ${variables.map(line => line + ';').join('\n')}
  4682. }
  4683. `}
  4684. `;
  4685. }
  4686. function getFlashDirectory(flash) {
  4687. // const kebab = getKebabCase(flash.name.replace('[S] ', ''));
  4688. // return flash.page + (kebab ? '-' + kebab : '');
  4689. // return '' + flash.page;
  4690. return '' + flash.directory;
  4691. }
  4692. function getTagDirectory({name}) {
  4693. return C.getKebabCase(name);
  4694. }
  4695. function getAlbumListTag(album) {
  4696. if (album.directory === C.UNRELEASED_TRACKS_DIRECTORY) {
  4697. return 'ul';
  4698. } else {
  4699. return 'ol';
  4700. }
  4701. }
  4702. function fancifyURL(url, {strings, album = false} = {}) {
  4703. const domain = new URL(url).hostname;
  4704. return fixWS`<a href="${url}" class="nowrap">${
  4705. domain.includes('bandcamp.com') ? strings('misc.external.bandcamp') :
  4706. [
  4707. 'music.solatrux.com'
  4708. ].includes(domain) ? strings('misc.external.bandcamp.domain', {domain}) :
  4709. [
  4710. 'types.pl'
  4711. ].includes(domain) ? strings('misc.external.mastodon.domain', {domain}) :
  4712. domain.includes('youtu') ? (album
  4713. ? (url.includes('list=')
  4714. ? strings('misc.external.youtube.playlist')
  4715. : strings('misc.external.youtube.fullAlbum'))
  4716. : strings('misc.external.youtube')) :
  4717. domain.includes('soundcloud') ? strings('misc.external.soundcloud') :
  4718. domain.includes('tumblr.com') ? strings('misc.external.tumblr') :
  4719. domain.includes('twitter.com') ? strings('misc.external.twitter') :
  4720. domain.includes('deviantart.com') ? strings('misc.external.deviantart') :
  4721. domain.includes('wikipedia.org') ? strings('misc.external.wikipedia') :
  4722. domain.includes('poetryfoundation.org') ? strings('misc.external.poetryFoundation') :
  4723. domain.includes('instagram.com') ? strings('misc.external.instagram') :
  4724. domain.includes('patreon.com') ? strings('misc.external.patreon') :
  4725. domain
  4726. }</a>`;
  4727. }
  4728. function fancifyFlashURL(url, flash, {strings}) {
  4729. const link = fancifyURL(url, {strings});
  4730. return `<span class="nowrap">${
  4731. url.includes('homestuck.com') ? (isNaN(Number(flash.page))
  4732. ? strings('misc.external.flash.homestuck.secret', {link})
  4733. : strings('misc.external.flash.homestuck.page', {link, page: flash.page})) :
  4734. url.includes('bgreco.net') ? strings('misc.external.flash.bgreco', {link}) :
  4735. url.includes('youtu') ? strings('misc.external.flash.youtube', {link}) :
  4736. link
  4737. }</span>`;
  4738. }
  4739. function iconifyURL(url, {strings, to}) {
  4740. const domain = new URL(url).hostname;
  4741. const [ id, msg ] = (
  4742. domain.includes('bandcamp.com') ? ['bandcamp', strings('misc.external.bandcamp')] :
  4743. (
  4744. domain.includes('music.solatrus.com')
  4745. ) ? ['bandcamp', strings('misc.external.bandcamp.domain', {domain})] :
  4746. (
  4747. domain.includes('types.pl')
  4748. ) ? ['mastodon', strings('misc.external.mastodon.domain', {domain})] :
  4749. domain.includes('youtu') ? ['youtube', strings('misc.external.youtube')] :
  4750. domain.includes('soundcloud') ? ['soundcloud', strings('misc.external.soundcloud')] :
  4751. domain.includes('tumblr.com') ? ['tumblr', strings('misc.external.tumblr')] :
  4752. domain.includes('twitter.com') ? ['twitter', strings('misc.external.twitter')] :
  4753. domain.includes('deviantart.com') ? ['deviantart', strings('misc.external.deviantart')] :
  4754. domain.includes('instagram.com') ? ['instagram', strings('misc.external.bandcamp')] :
  4755. ['globe', strings('misc.external.domain', {domain})]
  4756. );
  4757. return fixWS`<a href="${url}" class="icon"><svg><title>${msg}</title><use href="${to('shared.staticFile', `icons.svg#icon-${id}`)}"></use></svg></a>`;
  4758. }
  4759. function chronologyLinks(currentThing, {
  4760. strings, to,
  4761. headingString,
  4762. contribKey,
  4763. getThings
  4764. }) {
  4765. const contributions = currentThing[contribKey];
  4766. if (!contributions) {
  4767. return '';
  4768. }
  4769. if (contributions.length > 8) {
  4770. return `<div class="chronology">${strings('misc.chronology.seeArtistPages')}</div>`;
  4771. }
  4772. return contributions.map(({ who: artist }) => {
  4773. const things = C.sortByDate(unique(getThings(artist)));
  4774. const releasedThings = things.filter(thing => {
  4775. const album = albumData.includes(thing) ? thing : thing.album;
  4776. return !(album && album.directory === C.UNRELEASED_TRACKS_DIRECTORY);
  4777. });
  4778. const index = releasedThings.indexOf(currentThing);
  4779. if (index === -1) return '';
  4780. // TODO: This can pro8a8ly 8e made to use generatePreviousNextLinks?
  4781. // We'd need to make generatePreviousNextLinks use toAnythingMan tho.
  4782. const previous = releasedThings[index - 1];
  4783. const next = releasedThings[index + 1];
  4784. const parts = [
  4785. previous && `<a href="${toAnythingMan(previous, to)}" title="${previous.name}">Previous</a>`,
  4786. next && `<a href="${toAnythingMan(next, to)}" title="${next.name}">Next</a>`
  4787. ].filter(Boolean);
  4788. const stringOpts = {
  4789. index: strings.count.index(index + 1, {strings}),
  4790. artist: strings.link.artist(artist, {to})
  4791. };
  4792. return fixWS`
  4793. <div class="chronology">
  4794. <span class="heading">${strings(headingString, stringOpts)}</span>
  4795. ${parts.length && `<span class="buttons">(${parts.join(', ')})</span>`}
  4796. </div>
  4797. `;
  4798. }).filter(Boolean).join('\n');
  4799. }
  4800. function generateAlbumNavLinks(album, currentTrack, {strings, to}) {
  4801. if (album.tracks.length <= 1) {
  4802. return '';
  4803. }
  4804. const previousNextLinks = currentTrack && generatePreviousNextLinks('localized.track', currentTrack, album.tracks, {strings, to})
  4805. const randomLink = `<a href="#" data-random="track-in-album" id="random-button">${
  4806. (currentTrack
  4807. ? strings('trackPage.nav.random')
  4808. : strings('albumPage.nav.randomTrack'))
  4809. }</a>`;
  4810. return (previousNextLinks
  4811. ? `(${previousNextLinks}<span class="js-hide-until-data">, ${randomLink}</span>)`
  4812. : `<span class="js-hide-until-data">(${randomLink})</span>`);
  4813. }
  4814. function generateAlbumChronologyLinks(album, currentTrack, {strings, to}) {
  4815. return [
  4816. currentTrack && chronologyLinks(currentTrack, {
  4817. strings, to,
  4818. headingString: 'misc.chronology.heading.track',
  4819. contribKey: 'artists',
  4820. getThings: artist => [...artist.tracks.asArtist, ...artist.tracks.asContributor]
  4821. }),
  4822. chronologyLinks(currentTrack || album, {
  4823. strings, to,
  4824. headingString: 'misc.chronology.heading.coverArt',
  4825. contribKey: 'coverArtists',
  4826. getThings: artist => [...artist.albums.asCoverArtist, ...artist.tracks.asCoverArtist]
  4827. })
  4828. ].filter(Boolean).join('\n');
  4829. }
  4830. function generateSidebarForAlbum(album, currentTrack, {strings, to}) {
  4831. const listTag = getAlbumListTag(album);
  4832. const trackToListItem = track => `<li ${classes(track === currentTrack && 'current')}>${
  4833. strings('albumSidebar.trackList.item', {
  4834. track: strings.link.track(track, {to})
  4835. })
  4836. }</li>`;
  4837. const trackListPart = fixWS`
  4838. <h1><a href="${to('localized.album', album.directory)}">${album.name}</a></h1>
  4839. ${album.trackGroups ? fixWS`
  4840. <dl>
  4841. ${album.trackGroups.map(({ name, color, startIndex, tracks }) => fixWS`
  4842. <dt ${classes(tracks.includes(currentTrack) && 'current')}>${
  4843. (listTag === 'ol'
  4844. ? strings('albumSidebar.trackList.group.withRange', {
  4845. group: strings.link.track(tracks[0], {to, text: name}),
  4846. range: `${startIndex + 1}&ndash;${startIndex + tracks.length}`
  4847. })
  4848. : strings('albumSidebar.trackList.group', {
  4849. group: strings.link.track(tracks[0], {to, text: name})
  4850. }))
  4851. }</dt>
  4852. ${(!currentTrack || tracks.includes(currentTrack)) && fixWS`
  4853. <dd><${listTag === 'ol' ? `ol start="${startIndex + 1}"` : listTag}>
  4854. ${tracks.map(trackToListItem).join('\n')}
  4855. </${listTag}></dd>
  4856. `}
  4857. `).join('\n')}
  4858. </dl>
  4859. ` : fixWS`
  4860. <${listTag}>
  4861. ${album.tracks.map(trackToListItem).join('\n')}
  4862. </${listTag}>
  4863. `}
  4864. `;
  4865. const { groups } = album;
  4866. const groupParts = groups.map(group => {
  4867. const index = group.albums.indexOf(album);
  4868. const next = group.albums[index + 1];
  4869. const previous = group.albums[index - 1];
  4870. return {group, next, previous};
  4871. }).map(({group, next, previous}) => fixWS`
  4872. <h1>${
  4873. strings('albumSidebar.groupBox.title', {
  4874. group: `<a href="${to('localized.groupInfo', group.directory)}">${group.name}</a>`
  4875. })
  4876. }</h1>
  4877. ${!currentTrack && transformMultiline(group.descriptionShort, {strings, to})}
  4878. ${group.urls.length && `<p>${
  4879. strings('releaseInfo.visitOn', {
  4880. links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings})))
  4881. })
  4882. }</p>`}
  4883. ${!currentTrack && fixWS`
  4884. ${next && `<p class="group-chronology-link">${
  4885. strings('albumSidebar.groupBox.next', {
  4886. album: `<a href="${to('localized.album', next.directory)}" style="${getLinkThemeString(next)}">${next.name}</a>`
  4887. })
  4888. }</p>`}
  4889. ${previous && `<p class="group-chronology-link">${
  4890. strings('albumSidebar.groupBox.previous', {
  4891. album: `<a href="${to('localized.album', previous.directory)}" style="${getLinkThemeString(previous)}">${previous.name}</a>`
  4892. })
  4893. }</p>`}
  4894. `}
  4895. `);
  4896. if (groupParts.length) {
  4897. if (currentTrack) {
  4898. const combinedGroupPart = groupParts.join('\n<hr>\n');
  4899. return {
  4900. multiple: [
  4901. trackListPart,
  4902. combinedGroupPart
  4903. ]
  4904. };
  4905. } else {
  4906. return {
  4907. multiple: [
  4908. ...groupParts,
  4909. trackListPart
  4910. ]
  4911. };
  4912. }
  4913. } else {
  4914. return {
  4915. content: trackListPart
  4916. };
  4917. }
  4918. }
  4919. function generateSidebarForGroup(currentGroup, {strings, to, isGallery}) {
  4920. if (!wikiInfo.features.groupUI) {
  4921. return null;
  4922. }
  4923. const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
  4924. return {
  4925. content: fixWS`
  4926. <h1>${strings('groupSidebar.title')}</h1>
  4927. <dl>
  4928. ${groupCategoryData.map(category => [
  4929. fixWS`
  4930. <dt ${classes(category === currentGroup.category && 'current')}>${
  4931. strings('groupSidebar.groupList.category', {
  4932. category: `<a href="${to(urlKey, category.groups[0].directory)}" style="${getLinkThemeString(category)}">${category.name}</a>`
  4933. })
  4934. }</dt>
  4935. <dd><ul>
  4936. ${category.groups.map(group => fixWS`
  4937. <li ${classes(group === currentGroup && 'current')} style="${getLinkThemeString(group)}">${
  4938. strings('groupSidebar.groupList.item', {
  4939. group: `<a href="${to(urlKey, group.directory)}">${group.name}</a>`
  4940. })
  4941. }</li>
  4942. `).join('\n')}
  4943. </ul></dd>
  4944. `
  4945. ]).join('\n')}
  4946. </dl>
  4947. `
  4948. };
  4949. }
  4950. function generateInfoGalleryLinks(urlKeyInfo, urlKeyGallery, currentThing, isGallery, {strings, to}) {
  4951. return [
  4952. strings.link[urlKeyInfo](currentThing, {
  4953. to,
  4954. class: isGallery ? '' : 'current',
  4955. text: strings('misc.nav.info')
  4956. }),
  4957. strings.link[urlKeyGallery](currentThing, {
  4958. to,
  4959. class: isGallery ? 'current' : '',
  4960. text: strings('misc.nav.gallery')
  4961. })
  4962. ].join(', ');
  4963. }
  4964. function generatePreviousNextLinks(urlKey, currentThing, thingData, {strings, to}) {
  4965. const index = thingData.indexOf(currentThing);
  4966. const previous = thingData[index - 1];
  4967. const next = thingData[index + 1];
  4968. return [
  4969. previous && `<a href="${to(urlKey, previous.directory)}" id="previous-button" title="${previous.name}">${strings('misc.nav.previous')}</a>`,
  4970. next && `<a href="${to(urlKey, next.directory)}" id="next-button" title="${next.name}">${strings('misc.nav.next')}</a>`
  4971. ].filter(Boolean).join(', ');
  4972. }
  4973. function generateNavForGroup(currentGroup, {strings, to, isGallery}) {
  4974. if (!wikiInfo.features.groupUI) {
  4975. return {simple: true};
  4976. }
  4977. const urlKey = isGallery ? 'localized.groupGallery' : 'localized.groupInfo';
  4978. const linkKey = isGallery ? 'groupGallery' : 'groupInfo';
  4979. const infoGalleryLinks = generateInfoGalleryLinks('groupInfo', 'groupGallery', currentGroup, isGallery, {strings, to});
  4980. const previousNextLinks = generatePreviousNextLinks(urlKey, currentGroup, groupData, {strings, to})
  4981. return {
  4982. links: [
  4983. {
  4984. href: to('localized.home'),
  4985. title: wikiInfo.shortName
  4986. },
  4987. wikiInfo.features.listings &&
  4988. {
  4989. href: to('localized.listingIndex'),
  4990. title: strings('listingIndex.title')
  4991. },
  4992. {
  4993. html: strings('groupPage.nav.group', {
  4994. group: strings.link[linkKey](currentGroup, {class: 'current', to})
  4995. })
  4996. },
  4997. {
  4998. divider: false,
  4999. html: (previousNextLinks
  5000. ? `(${infoGalleryLinks}; ${previousNextLinks})`
  5001. : `(${previousNextLinks})`)
  5002. }
  5003. ]
  5004. };
  5005. }
  5006. function writeGroupPages() {
  5007. return groupData.map(writeGroupPage);
  5008. }
  5009. function writeGroupPage(group) {
  5010. const releasedAlbums = group.albums.filter(album => album.directory !== C.UNRELEASED_TRACKS_DIRECTORY);
  5011. const releasedTracks = releasedAlbums.flatMap(album => album.tracks);
  5012. const totalDuration = getTotalDuration(releasedTracks);
  5013. return async ({strings, writePage}) => {
  5014. await writePage('groupInfo', group.directory, ({to}) => ({
  5015. title: strings('groupInfoPage.title', {group: group.name}),
  5016. theme: getThemeString(group),
  5017. main: {
  5018. content: fixWS`
  5019. <h1>${strings('groupInfoPage.title', {group: group.name})}</h1>
  5020. ${group.urls.length && `<p>${
  5021. strings('releaseInfo.visitOn', {
  5022. links: strings.list.or(group.urls.map(url => fancifyURL(url, {strings})))
  5023. })
  5024. }</p>`}
  5025. <blockquote>
  5026. ${transformMultiline(group.description, {strings, to})}
  5027. </blockquote>
  5028. <h2>${strings('groupInfoPage.albumList.title')}</h2>
  5029. <p>${
  5030. strings('groupInfoPage.viewAlbumGallery', {
  5031. link: `<a href="${to('localized.groupGallery', group.directory)}">${
  5032. strings('groupInfoPage.viewAlbumGallery.link')
  5033. }</a>`
  5034. })
  5035. }</p>
  5036. <ul>
  5037. ${group.albums.map(album => fixWS`
  5038. <li>${
  5039. strings('groupInfoPage.albumList.item', {
  5040. year: album.date.getFullYear(),
  5041. album: `<a href="${to('localized.album', album.directory)}" style="${getLinkThemeString(album)}">${album.name}</a>`
  5042. })
  5043. }</li>
  5044. `).join('\n')}
  5045. </ul>
  5046. `
  5047. },
  5048. sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: false}),
  5049. nav: generateNavForGroup(group, {strings, to, isGallery: false})
  5050. }));
  5051. await writePage('groupGallery', group.directory, ({to}) => ({
  5052. title: strings('groupGalleryPage.title', {group: group.name}),
  5053. theme: getThemeString(group),
  5054. main: {
  5055. classes: ['top-index'],
  5056. content: fixWS`
  5057. <h1>${strings('groupGalleryPage.title', {group: group.name})}</h1>
  5058. <p class="quick-info">${
  5059. strings('groupGalleryPage.infoLine', {
  5060. tracks: `<b>${strings.count.tracks(releasedTracks.length, {unit: true})}</b>`,
  5061. albums: `<b>${strings.count.albums(releasedAlbums.length, {unit: true})}</b>`,
  5062. time: `<b>${strings.count.duration(totalDuration, {unit: true})}</b>`
  5063. })
  5064. }</p>
  5065. ${wikiInfo.features.groupUI && wikiInfo.features.listings && `<p class="quick-info">(<a href="${to('localized.listing', 'groups/by-category')}">Choose another group to filter by!</a>)</p>`}
  5066. <div class="grid-listing">
  5067. ${getAlbumGridHTML({
  5068. strings, to,
  5069. entries: C.sortByDate(group.albums.map(item => ({item}))).reverse(),
  5070. details: true
  5071. })}
  5072. </div>
  5073. `
  5074. },
  5075. sidebarLeft: generateSidebarForGroup(group, {strings, to, isGallery: true}),
  5076. nav: generateNavForGroup(group, {strings, to, isGallery: true})
  5077. }));
  5078. };
  5079. }
  5080. function toAnythingMan(anythingMan, to) {
  5081. return (
  5082. albumData.includes(anythingMan) ? to('localized.album', anythingMan.directory) :
  5083. trackData.includes(anythingMan) ? to('localized.track', anythingMan.directory) :
  5084. flashData?.includes(anythingMan) ? to('localized.flash', anythingMan.directory) :
  5085. 'idk-bud'
  5086. )
  5087. }
  5088. function getAlbumCover(album, {to}) {
  5089. return to('media.albumCover', album.directory);
  5090. }
  5091. function getTrackCover(track, {to}) {
  5092. // Some al8ums don't have any track art at all, and in those, every track
  5093. // just inherits the al8um's own cover art.
  5094. if (track.coverArtists === null) {
  5095. return getAlbumCover(track.album, {to});
  5096. } else {
  5097. return to('media.trackCover', track.album.directory, track.directory);
  5098. }
  5099. }
  5100. function getFlashLink(flash) {
  5101. return `https://homestuck.com/story/${flash.page}`;
  5102. }
  5103. function classes(...args) {
  5104. const values = args.filter(Boolean);
  5105. return `class="${values.join(' ')}"`;
  5106. }
  5107. async function processLanguageFile(file, defaultStrings = null) {
  5108. let contents;
  5109. try {
  5110. contents = await readFile(file, 'utf-8');
  5111. } catch (error) {
  5112. return {error: `Could not read ${file} (${error.code}).`};
  5113. }
  5114. let json;
  5115. try {
  5116. json = JSON.parse(contents);
  5117. } catch (error) {
  5118. return {error: `Could not parse JSON from ${file} (${error}).`};
  5119. }
  5120. return genStrings(json, defaultStrings);
  5121. }
  5122. // Wrapper function for running a function once for all languages. It provides:
  5123. // * the language strings
  5124. // * a shadowing writePages function for outputing to the appropriate subdir
  5125. // * a shadowing urls object for linking to the appropriate relative paths
  5126. async function wrapLanguages(fn, writeOneLanguage = null) {
  5127. const k = writeOneLanguage
  5128. const languagesToRun = (k
  5129. ? {[k]: languages[k]}
  5130. : languages)
  5131. const entries = Object.entries(languagesToRun)
  5132. .filter(([ key ]) => key !== 'default');
  5133. for (let i = 0; i < entries.length; i++) {
  5134. const [ key, strings ] = entries[i];
  5135. const baseDirectory = (strings === languages.default ? '' : strings.code);
  5136. const shadow_writePage = (urlKey, directory, pageFn) => writePage(strings, baseDirectory, urlKey, directory, pageFn);
  5137. // 8ring the utility functions over too!
  5138. Object.assign(shadow_writePage, writePage);
  5139. await fn({
  5140. baseDirectory,
  5141. strings,
  5142. writePage: shadow_writePage
  5143. }, i, entries);
  5144. }
  5145. }
  5146. async function main() {
  5147. const miscOptions = await parseOptions(process.argv.slice(2), {
  5148. // Data files for the site, including flash, artist, and al8um data,
  5149. // and like a jillion other things too. Pretty much everything which
  5150. // makes an individual wiki what it is goes here!
  5151. 'data-path': {
  5152. type: 'value'
  5153. },
  5154. // Static media will 8e referenced in the site here! The contents are
  5155. // categorized; check out MEDIA_DIRECTORY and rel8ted constants in
  5156. // common/common.js. (This gets symlinked into the --data directory.)
  5157. 'media-path': {
  5158. type: 'value'
  5159. },
  5160. // String files! For the most part, this is used for translating the
  5161. // site to different languages, though you can also customize strings
  5162. // for your own 8uild of the site if you'd like. Files here should all
  5163. // match the format in strings-default.json in this repository. (If a
  5164. // language file is missing any strings, the site code will fall 8ack
  5165. // to what's specified in strings-default.json.)
  5166. //
  5167. // Unlike the other options here, this one's optional - the site will
  5168. // 8uild with the default (English) strings if this path is left
  5169. // unspecified.
  5170. 'lang-path': {
  5171. type: 'value'
  5172. },
  5173. // This is the output directory. It's the one you'll upload online with
  5174. // rsync or whatever when you're pushing an upd8, and also the one
  5175. // you'd archive if you wanted to make a 8ackup of the whole dang
  5176. // site. Just keep in mind that the gener8ted result will contain a
  5177. // couple symlinked directories, so if you're uploading, you're pro8a8ly
  5178. // gonna want to resolve those yourself.
  5179. 'out-path': {
  5180. type: 'value'
  5181. },
  5182. // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e
  5183. // kinda a pain to run every time, since it does necessit8te reading
  5184. // every media file at run time. Pass this to skip it.
  5185. 'skip-thumbs': {
  5186. type: 'flag'
  5187. },
  5188. // Only want 8uild one language during testing? This can chop down
  5189. // 8uild times a pretty 8ig chunk! Just pass a single language code.
  5190. 'lang': {
  5191. type: 'value'
  5192. },
  5193. 'queue-size': {
  5194. type: 'value',
  5195. validate(size) {
  5196. if (parseInt(size) !== parseFloat(size)) return 'an integer';
  5197. if (parseInt(size) < 0) return 'a counting number or zero';
  5198. return true;
  5199. }
  5200. },
  5201. queue: {alias: 'queue-size'},
  5202. [parseOptions.handleUnknown]: () => {}
  5203. });
  5204. dataPath = miscOptions['data-path'] || process.env.HSMUSIC_DATA;
  5205. mediaPath = miscOptions['media-path'] || process.env.HSMUSIC_MEDIA;
  5206. langPath = miscOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset!
  5207. outputPath = miscOptions['out-path'] || process.env.HSMUSIC_OUT;
  5208. const writeOneLanguage = miscOptions['lang'];
  5209. {
  5210. let errored = false;
  5211. const error = (cond, msg) => {
  5212. if (cond) {
  5213. console.error(`\x1b[31;1m${msg}\x1b[0m`);
  5214. errored = true;
  5215. }
  5216. };
  5217. error(!dataPath, `Expected --data option or HSMUSIC_DATA to be set`);
  5218. error(!mediaPath, `Expected --media option or HSMUSIC_MEDIA to be set`);
  5219. error(!outputPath, `Expected --out option or HSMUSIC_OUT to be set`);
  5220. if (errored) {
  5221. return;
  5222. }
  5223. }
  5224. const skipThumbs = miscOptions['skip-thumbs'] ?? false;
  5225. if (skipThumbs) {
  5226. logInfo`Skipping thumbnail generation.`;
  5227. } else {
  5228. logInfo`Begin thumbnail generation... -----+`;
  5229. const result = await genThumbs(mediaPath, {queueSize, quiet: true});
  5230. logInfo`Done thumbnail generation! --------+`;
  5231. if (!result) {
  5232. return;
  5233. }
  5234. }
  5235. const defaultStrings = await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE));
  5236. if (defaultStrings.error) {
  5237. logError`Error loading default strings: ${defaultStrings.error}`;
  5238. return;
  5239. }
  5240. if (langPath) {
  5241. const languageDataFiles = await findFiles(langPath, f => path.extname(f) === '.json');
  5242. const results = await progressPromiseAll(`Reading & processing language files.`, languageDataFiles
  5243. .map(file => processLanguageFile(file, defaultStrings.json)));
  5244. let error = false;
  5245. for (const strings of results) {
  5246. if (strings.error) {
  5247. logError`Error loading provided strings: ${strings.error}`;
  5248. error = true;
  5249. }
  5250. }
  5251. if (error) return;
  5252. languages = Object.fromEntries(results.map(strings => [strings.code, strings]));
  5253. } else {
  5254. languages = {};
  5255. }
  5256. if (!languages[defaultStrings.code]) {
  5257. languages[defaultStrings.code] = defaultStrings;
  5258. }
  5259. logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`;
  5260. if (writeOneLanguage && !(writeOneLanguage in languages)) {
  5261. logError`Specified to write only ${writeOneLanguage}, but there is no strings file with this language code!`;
  5262. return;
  5263. } else if (writeOneLanguage) {
  5264. logInfo`Writing only language ${writeOneLanguage} this run.`;
  5265. } else {
  5266. logInfo`Writing all languages.`;
  5267. }
  5268. wikiInfo = await processWikiInfoFile(path.join(dataPath, WIKI_INFO_FILE));
  5269. if (wikiInfo.error) {
  5270. console.log(`\x1b[31;1m${wikiInfo.error}\x1b[0m`);
  5271. return;
  5272. }
  5273. // Update languages o8ject with the wiki-specified default language!
  5274. // This will make page files for that language 8e gener8ted at the root
  5275. // directory, instead of the language-specific su8directory.
  5276. if (wikiInfo.defaultLanguage) {
  5277. if (Object.keys(languages).includes(wikiInfo.defaultLanguage)) {
  5278. languages.default = languages[wikiInfo.defaultLanguage];
  5279. } else {
  5280. logError`Wiki info file specified default language is ${wikiInfo.defaultLanguage}, but no such language file exists!`;
  5281. if (langPath) {
  5282. logError`Check if an appropriate file exists in ${langPath}?`;
  5283. } else {
  5284. logError`Be sure to specify ${'--lang'} or ${'HSMUSIC_LANG'} with the path to language files.`;
  5285. }
  5286. return;
  5287. }
  5288. } else {
  5289. languages.default = defaultStrings;
  5290. }
  5291. homepageInfo = await processHomepageInfoFile(path.join(dataPath, HOMEPAGE_INFO_FILE));
  5292. if (homepageInfo.error) {
  5293. console.log(`\x1b[31;1m${homepageInfo.error}\x1b[0m`);
  5294. return;
  5295. }
  5296. {
  5297. const errors = homepageInfo.rows.filter(obj => obj.error);
  5298. if (errors.length) {
  5299. for (const error of errors) {
  5300. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5301. }
  5302. return;
  5303. }
  5304. }
  5305. // 8ut wait, you might say, how do we know which al8um these data files
  5306. // correspond to???????? You wouldn't dare suggest we parse the actual
  5307. // paths returned 8y this function, which ought to 8e of effectively
  5308. // unknown format except for their purpose as reada8le data files!?
  5309. // To that, I would say, yeah, you're right. Thanks a 8unch, my projection
  5310. // of "you". We're going to read these files later, and contained within
  5311. // will 8e the actual directory names that the data correspond to. Yes,
  5312. // that's redundant in some ways - we COULD just return the directory name
  5313. // in addition to the data path, and duplicating that name within the file
  5314. // itself suggests we 8e careful to avoid mismatching it - 8ut doing it
  5315. // this way lets the data files themselves 8e more porta8le (meaning we
  5316. // could store them all in one folder, if we wanted, and this program would
  5317. // still output to the correct al8um directories), and also does make the
  5318. // function's signature simpler (an array of strings, rather than some kind
  5319. // of structure containing 8oth data file paths and output directories).
  5320. // This is o8jectively a good thing, 8ecause it means the function can stay
  5321. // truer to its name, and have a narrower purpose: it doesn't need to
  5322. // concern itself with where we *output* files, or whatever other reasons
  5323. // we might (hypothetically) have for knowing the containing directory.
  5324. // And, in the strange case where we DO really need to know that info, we
  5325. // callers CAN use path.dirname to find out that data. 8ut we'll 8e
  5326. // avoiding that in our code 8ecause, again, we want to avoid assuming the
  5327. // format of the returned paths here - they're only meant to 8e used for
  5328. // reading as-is.
  5329. const albumDataFiles = await findFiles(path.join(dataPath, C.DATA_ALBUM_DIRECTORY));
  5330. // Technically, we could do the data file reading and output writing at the
  5331. // same time, 8ut that kinda makes the code messy, so I'm not 8othering
  5332. // with it.
  5333. albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile));
  5334. {
  5335. const errors = albumData.filter(obj => obj.error);
  5336. if (errors.length) {
  5337. for (const error of errors) {
  5338. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5339. }
  5340. return;
  5341. }
  5342. }
  5343. C.sortByDate(albumData);
  5344. artistData = await processArtistDataFile(path.join(dataPath, ARTIST_DATA_FILE));
  5345. if (artistData.error) {
  5346. console.log(`\x1b[31;1m${artistData.error}\x1b[0m`);
  5347. return;
  5348. }
  5349. {
  5350. const errors = artistData.filter(obj => obj.error);
  5351. if (errors.length) {
  5352. for (const error of errors) {
  5353. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5354. }
  5355. return;
  5356. }
  5357. }
  5358. artistAliasData = artistData.filter(x => x.alias);
  5359. artistData = artistData.filter(x => !x.alias);
  5360. trackData = C.getAllTracks(albumData);
  5361. if (wikiInfo.features.flashesAndGames) {
  5362. flashData = await processFlashDataFile(path.join(dataPath, FLASH_DATA_FILE));
  5363. if (flashData.error) {
  5364. console.log(`\x1b[31;1m${flashData.error}\x1b[0m`);
  5365. return;
  5366. }
  5367. const errors = flashData.filter(obj => obj.error);
  5368. if (errors.length) {
  5369. for (const error of errors) {
  5370. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5371. }
  5372. return;
  5373. }
  5374. }
  5375. flashActData = flashData?.filter(x => x.act8r8k);
  5376. flashData = flashData?.filter(x => !x.act8r8k);
  5377. artistNames = Array.from(new Set([
  5378. ...artistData.filter(artist => !artist.alias).map(artist => artist.name),
  5379. ...[
  5380. ...albumData.flatMap(album => [
  5381. ...album.artists || [],
  5382. ...album.coverArtists || [],
  5383. ...album.wallpaperArtists || [],
  5384. ...album.tracks.flatMap(track => [
  5385. ...track.artists,
  5386. ...track.coverArtists || [],
  5387. ...track.contributors || []
  5388. ])
  5389. ]),
  5390. ...(flashData?.flatMap(flash => [
  5391. ...flash.contributors || []
  5392. ]) || [])
  5393. ].map(contribution => contribution.who)
  5394. ]));
  5395. tagData = await processTagDataFile(path.join(dataPath, TAG_DATA_FILE));
  5396. if (tagData.error) {
  5397. console.log(`\x1b[31;1m${tagData.error}\x1b[0m`);
  5398. return;
  5399. }
  5400. {
  5401. const errors = tagData.filter(obj => obj.error);
  5402. if (errors.length) {
  5403. for (const error of errors) {
  5404. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5405. }
  5406. return;
  5407. }
  5408. }
  5409. groupData = await processGroupDataFile(path.join(dataPath, GROUP_DATA_FILE));
  5410. if (groupData.error) {
  5411. console.log(`\x1b[31;1m${groupData.error}\x1b[0m`);
  5412. return;
  5413. }
  5414. {
  5415. const errors = groupData.filter(obj => obj.error);
  5416. if (errors.length) {
  5417. for (const error of errors) {
  5418. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5419. }
  5420. return;
  5421. }
  5422. }
  5423. groupCategoryData = groupData.filter(x => x.isCategory);
  5424. groupData = groupData.filter(x => x.isGroup);
  5425. staticPageData = await processStaticPageDataFile(path.join(dataPath, STATIC_PAGE_DATA_FILE));
  5426. if (staticPageData.error) {
  5427. console.log(`\x1b[31;1m${staticPageData.error}\x1b[0m`);
  5428. return;
  5429. }
  5430. {
  5431. const errors = staticPageData.filter(obj => obj.error);
  5432. if (errors.length) {
  5433. for (const error of errors) {
  5434. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5435. }
  5436. return;
  5437. }
  5438. }
  5439. if (wikiInfo.features.news) {
  5440. newsData = await processNewsDataFile(path.join(dataPath, NEWS_DATA_FILE));
  5441. if (newsData.error) {
  5442. console.log(`\x1b[31;1m${newsData.error}\x1b[0m`);
  5443. return;
  5444. }
  5445. const errors = newsData.filter(obj => obj.error);
  5446. if (errors.length) {
  5447. for (const error of errors) {
  5448. console.log(`\x1b[31;1m${error.error}\x1b[0m`);
  5449. }
  5450. return;
  5451. }
  5452. C.sortByDate(newsData);
  5453. newsData.reverse();
  5454. }
  5455. {
  5456. const tagNames = new Set([...trackData, ...albumData].flatMap(thing => thing.artTags));
  5457. for (let { name, isCW } of tagData) {
  5458. if (isCW) {
  5459. name = 'cw: ' + name;
  5460. }
  5461. tagNames.delete(name);
  5462. }
  5463. if (tagNames.size) {
  5464. for (const name of Array.from(tagNames).sort()) {
  5465. console.log(`\x1b[33;1m- Missing tag: "${name}"\x1b[0m`);
  5466. }
  5467. return;
  5468. }
  5469. }
  5470. artistNames.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : a.toLowerCase() > b.toLowerCase() ? 1 : 0);
  5471. justEverythingMan = C.sortByDate([...albumData, ...trackData, ...(flashData || [])]);
  5472. justEverythingSortedByArtDateMan = C.sortByArtDate(justEverythingMan.slice());
  5473. // console.log(JSON.stringify(justEverythingSortedByArtDateMan.map(toAnythingMan), null, 2));
  5474. {
  5475. let buffer = [];
  5476. const clearBuffer = function() {
  5477. if (buffer.length) {
  5478. for (const entry of buffer.slice(0, -1)) {
  5479. console.log(`\x1b[2m... ${entry.name} ...\x1b[0m`);
  5480. }
  5481. const lastEntry = buffer[buffer.length - 1];
  5482. console.log(`\x1b[2m... \x1b[0m${lastEntry.name}\x1b[0;2m ...\x1b[0m`);
  5483. buffer = [];
  5484. }
  5485. };
  5486. const showWhere = (name, color) => {
  5487. const where = justEverythingMan.filter(thing => [
  5488. ...thing.coverArtists || [],
  5489. ...thing.contributors || [],
  5490. ...thing.artists || []
  5491. ].some(({ who }) => who === name));
  5492. for (const thing of where) {
  5493. console.log(`\x1b[${color}m- ` + (thing.album ? `(\x1b[1m${thing.album.name}\x1b[0;${color}m)` : '') + ` \x1b[1m${thing.name}\x1b[0m`);
  5494. }
  5495. };
  5496. let CR4SH = false;
  5497. for (let name of artistNames) {
  5498. const entry = [...artistData, ...artistAliasData].find(entry => entry.name === name || entry.name.toLowerCase() === name.toLowerCase());
  5499. if (!entry) {
  5500. clearBuffer();
  5501. console.log(`\x1b[31mMissing entry for artist "\x1b[1m${name}\x1b[0;31m"\x1b[0m`);
  5502. showWhere(name, 31);
  5503. CR4SH = true;
  5504. } else if (entry.alias) {
  5505. console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.alias}\x1b[0;33m"\x1b[0m`);
  5506. showWhere(name, 33);
  5507. CR4SH = true;
  5508. } else if (entry.name !== name) {
  5509. console.log(`\x1b[33mArtist "\x1b[1m${name}\x1b[0;33m" should be named "\x1b[1m${entry.name}\x1b[0;33m"\x1b[0m`);
  5510. showWhere(name, 33);
  5511. CR4SH = true;
  5512. } else {
  5513. buffer.push(entry);
  5514. if (buffer.length > 3) {
  5515. buffer.shift();
  5516. }
  5517. }
  5518. }
  5519. if (CR4SH) {
  5520. return;
  5521. }
  5522. }
  5523. {
  5524. const directories = [];
  5525. for (const { directory, name } of albumData) {
  5526. if (directories.includes(directory)) {
  5527. console.log(`\x1b[31;1mDuplicate album directory "${directory}" (${name})\x1b[0m`);
  5528. return;
  5529. }
  5530. directories.push(directory);
  5531. }
  5532. }
  5533. {
  5534. const directories = [];
  5535. const where = {};
  5536. for (const { directory, album } of trackData) {
  5537. if (directories.includes(directory)) {
  5538. console.log(`\x1b[31;1mDuplicate track directory "${directory}"\x1b[0m`);
  5539. console.log(`Shows up in:`);
  5540. console.log(`- ${album.name}`);
  5541. console.log(`- ${where[directory].name}`);
  5542. return;
  5543. }
  5544. directories.push(directory);
  5545. where[directory] = album;
  5546. }
  5547. }
  5548. {
  5549. const artists = [];
  5550. const artistsLC = [];
  5551. for (const name of artistNames) {
  5552. if (!artists.includes(name) && artistsLC.includes(name.toLowerCase())) {
  5553. const other = artists.find(oth => oth.toLowerCase() === name.toLowerCase());
  5554. console.log(`\x1b[31;1mMiscapitalized artist name: ${name}, ${other}\x1b[0m`);
  5555. return;
  5556. }
  5557. artists.push(name);
  5558. artistsLC.push(name.toLowerCase());
  5559. }
  5560. }
  5561. {
  5562. for (const { references, name, album } of trackData) {
  5563. for (const ref of references) {
  5564. if (!search.track(ref)) {
  5565. logWarn`Track not found "${ref}" in ${name} (${album.name})`;
  5566. }
  5567. }
  5568. }
  5569. }
  5570. contributionData = Array.from(new Set([
  5571. ...trackData.flatMap(track => [...track.artists || [], ...track.contributors || [], ...track.coverArtists || []]),
  5572. ...albumData.flatMap(album => [...album.artists || [], ...album.coverArtists || [], ...album.wallpaperArtists || [], ...album.bannerArtists || []]),
  5573. ...(flashData?.flatMap(flash => [...flash.contributors || []]) || [])
  5574. ]));
  5575. // Now that we have all the data, resolve references all 8efore actually
  5576. // gener8ting any of the pages, 8ecause page gener8tion is going to involve
  5577. // accessing these references a lot, and there's no reason to resolve them
  5578. // more than once. (We 8uild a few additional links that can't 8e cre8ted
  5579. // at initial data processing time here too.)
  5580. const filterNullArray = (parent, key) => {
  5581. for (const obj of parent) {
  5582. const array = obj[key];
  5583. for (let i = 0; i < array.length; i++) {
  5584. if (!array[i]) {
  5585. const prev = array[i - 1] && array[i - 1].name;
  5586. const next = array[i + 1] && array[i + 1].name;
  5587. logWarn`Unexpected null in ${obj.name} (${obj.what}) (array key ${key} - prev: ${prev}, next: ${next})`;
  5588. }
  5589. }
  5590. array.splice(0, array.length, ...array.filter(Boolean));
  5591. }
  5592. };
  5593. const filterNullValue = (parent, key) => {
  5594. parent.splice(0, parent.length, ...parent.filter(obj => {
  5595. if (!obj[key]) {
  5596. logWarn`Unexpected null in ${obj.name} (value key ${key})`;
  5597. }
  5598. }));
  5599. };
  5600. trackData.forEach(track => mapInPlace(track.references, search.track));
  5601. trackData.forEach(track => track.aka = search.track(track.aka));
  5602. trackData.forEach(track => mapInPlace(track.artTags, search.tag));
  5603. albumData.forEach(album => mapInPlace(album.groups, search.group));
  5604. albumData.forEach(album => mapInPlace(album.artTags, search.tag));
  5605. artistAliasData.forEach(artist => artist.alias = search.artist(artist.alias));
  5606. contributionData.forEach(contrib => contrib.who = search.artist(contrib.who));
  5607. filterNullArray(trackData, 'references');
  5608. filterNullArray(trackData, 'artTags');
  5609. filterNullArray(albumData, 'groups');
  5610. filterNullArray(albumData, 'artTags');
  5611. filterNullValue(artistAliasData, 'alias');
  5612. filterNullValue(contributionData, 'who');
  5613. trackData.forEach(track1 => track1.referencedBy = trackData.filter(track2 => track2.references.includes(track1)));
  5614. groupData.forEach(group => group.albums = albumData.filter(album => album.groups.includes(group)));
  5615. tagData.forEach(tag => tag.things = C.sortByArtDate([...albumData, ...trackData]).filter(thing => thing.artTags.includes(tag)));
  5616. groupData.forEach(group => group.category = groupCategoryData.find(x => x.name === group.category));
  5617. groupCategoryData.forEach(category => category.groups = groupData.filter(x => x.category === category));
  5618. trackData.forEach(track => track.otherReleases = [
  5619. track.aka,
  5620. ...trackData.filter(({ aka }) => aka === track || (track.aka && aka === track.aka)),
  5621. ].filter(x => x && x !== track));
  5622. if (wikiInfo.features.flashesAndGames) {
  5623. flashData.forEach(flash => mapInPlace(flash.tracks, search.track));
  5624. flashData.forEach(flash => flash.act = flashActData.find(act => act.name === flash.act));
  5625. flashActData.forEach(act => act.flashes = flashData.filter(flash => flash.act === act));
  5626. filterNullArray(flashData, 'tracks');
  5627. trackData.forEach(track => track.flashes = flashData.filter(flash => flash.tracks.includes(track)));
  5628. }
  5629. artistData.forEach(artist => {
  5630. const filterProp = (array, prop) => array.filter(thing => thing[prop]?.some(({ who }) => who === artist));
  5631. const filterCommentary = array => array.filter(thing => thing.commentary && thing.commentary.replace(/<\/?b>/g, '').includes('<i>' + artist.name + ':</i>'));
  5632. artist.tracks = {
  5633. asArtist: filterProp(trackData, 'artists'),
  5634. asCommentator: filterCommentary(trackData),
  5635. asContributor: filterProp(trackData, 'contributors'),
  5636. asCoverArtist: filterProp(trackData, 'coverArtists'),
  5637. asAny: trackData.filter(track => (
  5638. [...track.artists, ...track.contributors, ...track.coverArtists || []].some(({ who }) => who === artist)
  5639. ))
  5640. };
  5641. artist.albums = {
  5642. asArtist: filterProp(albumData, 'artists'),
  5643. asCommentator: filterCommentary(albumData),
  5644. asCoverArtist: filterProp(albumData, 'coverArtists'),
  5645. asWallpaperArtist: filterProp(albumData, 'wallpaperArtists'),
  5646. asBannerArtist: filterProp(albumData, 'bannerArtists')
  5647. };
  5648. if (wikiInfo.features.flashesAndGames) {
  5649. artist.flashes = {
  5650. asContributor: filterProp(flashData, 'contributors')
  5651. };
  5652. }
  5653. });
  5654. officialAlbumData = albumData.filter(album => album.groups.some(group => group.directory === C.OFFICIAL_GROUP_DIRECTORY));
  5655. fandomAlbumData = albumData.filter(album => album.groups.every(group => group.directory !== C.OFFICIAL_GROUP_DIRECTORY));
  5656. // Makes writing a little nicer on CPU theoretically, 8ut also costs in
  5657. // performance right now 'cuz it'll w8 for file writes to 8e completed
  5658. // 8efore moving on to more data processing. So, defaults to zero, which
  5659. // disa8les the queue feature altogether.
  5660. queueSize = +(miscOptions['queue-size'] ?? 0);
  5661. // NOT for ena8ling or disa8ling specific features of the site!
  5662. // This is only in charge of what general groups of files to 8uild.
  5663. // They're here to make development quicker when you're only working
  5664. // on some particular area(s) of the site rather than making changes
  5665. // across all of them.
  5666. const writeFlags = await parseOptions(process.argv.slice(2), {
  5667. all: {type: 'flag'}, // Defaults to true if none 8elow specified.
  5668. album: {type: 'flag'},
  5669. artist: {type: 'flag'},
  5670. commentary: {type: 'flag'},
  5671. flash: {type: 'flag'},
  5672. group: {type: 'flag'},
  5673. list: {type: 'flag'},
  5674. misc: {type: 'flag'},
  5675. news: {type: 'flag'},
  5676. static: {type: 'flag'},
  5677. tag: {type: 'flag'},
  5678. track: {type: 'flag'},
  5679. [parseOptions.handleUnknown]: () => {}
  5680. });
  5681. const writeAll = !Object.keys(writeFlags).length || writeFlags.all;
  5682. logInfo`Writing site pages: ${writeAll ? 'all' : Object.keys(writeFlags).join(', ')}`;
  5683. await writeSymlinks();
  5684. await writeSharedFilesAndPages({strings: defaultStrings});
  5685. const buildDictionary = {
  5686. misc: writeMiscellaneousPages,
  5687. news: writeNewsPages,
  5688. list: writeListingPages,
  5689. tag: writeTagPages,
  5690. commentary: writeCommentaryPages,
  5691. static: writeStaticPages,
  5692. group: writeGroupPages,
  5693. album: writeAlbumPages,
  5694. track: writeTrackPages,
  5695. artist: writeArtistPages,
  5696. flash: writeFlashPages
  5697. };
  5698. const buildSteps = (writeAll
  5699. ? Object.values(buildDictionary)
  5700. : (Object.entries(buildDictionary)
  5701. .filter(([ flag ]) => writeFlags[flag])
  5702. .map(([ flag, fn ]) => fn)));
  5703. // *NB: While what's 8elow is 8asically still true in principle, the
  5704. // format is QUITE DIFFERENT than what's descri8ed here! There
  5705. // will 8e actual document8tion on like, what the return format
  5706. // looks like soon, once we implement a 8unch of other pages and
  5707. // are certain what they actually, uh, will look like, in the end.*
  5708. //
  5709. // The writeThingPages functions don't actually immediately do any file
  5710. // writing themselves; an initial call will only gather the relevant data
  5711. // which is *then* used for writing. So the return value is a function
  5712. // (or an array of functions) which expects {writePage, strings}, and
  5713. // *that's* what we call after -- multiple times, once for each language.
  5714. let writes;
  5715. {
  5716. let error = false;
  5717. writes = buildSteps.flatMap(fn => {
  5718. const fns = fn() || [];
  5719. // Do a quick valid8tion! If one of the writeThingPages functions go
  5720. // wrong, this will stall out early and tell us which did.
  5721. if (!Array.isArray(fns)) {
  5722. logError`${fn.name} didn't return an array!`;
  5723. error = true;
  5724. } else if (fns.every(entry => Array.isArray(entry))) {
  5725. if (!(
  5726. fns.every(entry => entry.every(obj => typeof obj === 'object')) &&
  5727. fns.every(entry => entry.every(obj => {
  5728. const result = validateWriteObject(obj);
  5729. if (result.error) {
  5730. logError`Validating write object failed: ${result.error}`;
  5731. return false;
  5732. } else {
  5733. return true;
  5734. }
  5735. }))
  5736. )) {
  5737. logError`${fn.name} uses updated format, but entries are invalid!`;
  5738. error = true;
  5739. }
  5740. return fns.flatMap(writes => writes);
  5741. } else if (fns.some(fn => typeof fn !== 'function')) {
  5742. logError`${fn.name} didn't return all functions or all arrays!`;
  5743. error = true;
  5744. }
  5745. return fns;
  5746. });
  5747. if (error) {
  5748. return;
  5749. }
  5750. // The modern(TM) return format for each writeThingPages function is an
  5751. // array of arrays, each of which's items are 8ig Complicated Objects
  5752. // that 8asically look like {type, path, content}. 8ut surprise, these
  5753. // aren't actually implemented in most places yet! So, we transform
  5754. // stuff in the old format here. 'Scept keep in mind, the OLD FORMAT
  5755. // doesn't really give us most of the info we want for Cool And Modern
  5756. // Reasons, so they're going into a fancy {type: 'legacy'} sort of
  5757. // o8ject, with a plain {write} property for, uh, the writing stuff,
  5758. // same as usual.
  5759. //
  5760. // I promise this document8tion will get 8etter when we make progress
  5761. // actually moving old pages over. Also it'll 8e hecks of less work
  5762. // than previous restructures, don't worry.
  5763. writes = writes.map(entry =>
  5764. typeof entry === 'object' ? entry :
  5765. typeof entry === 'function' ? {type: 'legacy', write: entry} :
  5766. {type: 'wut', entry});
  5767. const wut = writes.filter(({ type }) => type === 'wut');
  5768. if (wut.length) {
  5769. // Oh g*d oh h*ck.
  5770. logError`Uhhhhh writes contains something 8esides o8jects and functions?`;
  5771. logError`Definitely a 8ug!`;
  5772. console.log(wut);
  5773. return;
  5774. }
  5775. }
  5776. const localizedWrites = writes.filter(({ type }) => type === 'page' || type === 'legacy');
  5777. const dataWrites = writes.filter(({ type }) => type === 'data');
  5778. await progressPromiseAll(`Writing data files shared across languages.`, queue(
  5779. // TODO: This only supports one <>-style argument.
  5780. dataWrites.map(({path, file = 'data.json', data}) =>
  5781. () => writeData(path[0], path[1], file, data())),
  5782. queueSize
  5783. ));
  5784. await wrapLanguages(async ({strings, ...opts}, i, entries) => {
  5785. console.log(`\x1b[34;1m${
  5786. (`[${i + 1}/${entries.length}] ${strings.code} (-> /${opts.baseDirectory}) `
  5787. .padEnd(60, '-'))
  5788. }\x1b[0m`);
  5789. await progressPromiseAll(`Writing ${strings.code}`, queue(
  5790. localizedWrites.map(({type, ...props}) => () => {
  5791. switch (type) {
  5792. case 'legacy': {
  5793. const { write } = props;
  5794. return write({strings, ...opts});
  5795. }
  5796. case 'page': {
  5797. const { path, page } = props;
  5798. // TODO: This only supports one <>-style argument.
  5799. return opts.writePage(path[0], path[1], ({to}) => page({strings, to}));
  5800. }
  5801. }
  5802. }),
  5803. queueSize
  5804. ));
  5805. }, writeOneLanguage);
  5806. decorateTime.displayTime();
  5807. // The single most important step.
  5808. logInfo`Written!`;
  5809. }
  5810. main().catch(error => console.error(error));