upd8.js 252 KB

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