mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-24 12:49:03 -05:00
feat: round 4 components
This commit is contained in:
commit
370bee1470
15 files changed
+1062
-163
No files matched your search
@@ -1,36 +1 @@
|
||||
<?xml version="1.0"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256">
|
||||
<desc iVinci="yes" version="4.6" gridStep="20" showGrid="no" snapToGrid="no" codePlatform="0"/>
|
||||
<g id="Layer1" name="Layer 1" opacity="1">
|
||||
<g id="Shape1">
|
||||
<desc shapeID="1" type="0" basicInfo-basicType="0" basicInfo-roundedRectRadius="12" basicInfo-polygonSides="6" basicInfo-starPoints="5" bounding="rect(-100,-68.4834,200,136.967)" text="" font-familyName="" font-pixelSize="20" font-bold="0" font-underline="0" font-alignment="1" strokeStyle="0" markerStart="0" markerEnd="0" shadowEnabled="0" shadowOffsetX="0" shadowOffsetY="2" shadowBlur="4" shadowOpacity="160" blurEnabled="0" blurRadius="4" transform="matrix(0.97865,0.000512411,-0.000512419,0.978665,127.968,189.029)" pers-center="0,0" pers-size="0,0" pers-start="0,0" pers-end="0,0" locked="0" mesh="" flag=""/>
|
||||
<path id="shapePath1" d="M42.4218,126.285 C31.8374,138.147 30.1561,145.569 30.1211,180.592 L30.0933,207.544 L57.0444,207.544 C92.0585,207.546 99.4913,205.871 111.351,195.312 L115.663,191.473 L115.875,219.601 C116.075,246.152 116.172,247.872 117.614,250.268 C122.501,258.388 134.847,257.846 138.714,249.344 C140.016,246.481 140.14,243.872 140.169,218.818 L140.2,191.423 L144.543,195.298 C156.43,205.904 163.834,207.583 198.872,207.618 L225.823,207.646 L225.824,180.695 C225.825,145.671 224.152,138.248 213.58,126.374 L209.729,122.05 L205.287,125.079 C197.219,130.58 186.766,134.179 178.77,134.208 L175.641,134.22 L173.613,139.379 C157.212,181.089 98.7321,181.059 82.3753,139.332 L80.3521,134.17 L77.2234,134.155 C69.2279,134.117 58.7787,130.507 50.7166,124.998 L46.277,121.964 L42.4218,126.285 Z" style="stroke:none;fill-rule:evenodd;fill:#2ecc71;fill-opacity:1;"/>
|
||||
</g>
|
||||
<g id="Shape2">
|
||||
<desc shapeID="2" type="0" basicInfo-basicType="1" basicInfo-roundedRectRadius="12" basicInfo-polygonSides="6" basicInfo-starPoints="5" bounding="rect(-83.0229,-25.7162,166.046,51.4324)" text="" font-familyName="Helvetica" font-pixelSize="20" font-bold="0" font-underline="0" font-alignment="1" strokeStyle="0" markerStart="0" markerEnd="0" shadowEnabled="0" shadowOffsetX="0" shadowOffsetY="2" shadowBlur="4" shadowOpacity="160" blurEnabled="0" blurRadius="4" transform="matrix(0.984273,0.000515356,-0.000585224,1.11771,127.988,150.75)" pers-center="0,0" pers-size="0,0" pers-start="0,0" pers-end="0,0" locked="0" mesh="" flag=""/>
|
||||
<path id="shapePath2" d="M46.2859,121.964 L209.72,122.049 L209.69,179.536 L46.2558,179.451 L46.2859,121.964 Z" style="stroke:none;fill-rule:evenodd;fill:#2ecc71;fill-opacity:1;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Layer2" name="Layer 4" opacity="1">
|
||||
<g id="Shape3">
|
||||
<desc shapeID="3" type="0" basicInfo-basicType="4" basicInfo-roundedRectRadius="12" basicInfo-polygonSides="6" basicInfo-starPoints="5" bounding="rect(-50.2318,-49.7682,100.464,99.5363)" text="" font-familyName="Helvetica" font-pixelSize="20" font-bold="0" font-underline="0" font-alignment="1" strokeStyle="0" markerStart="0" markerEnd="0" shadowEnabled="0" shadowOffsetX="0" shadowOffsetY="2" shadowBlur="4" shadowOpacity="160" blurEnabled="0" blurRadius="4" transform="matrix(0.974133,0.000510046,-0.000514806,0.983224,79.09,84.9615)" pers-center="0,0" pers-size="0,0" pers-start="0,0" pers-end="0,0" locked="0" mesh="" flag=""/>
|
||||
<path id="shapePath3" d="M30.1575,84.9359 C30.1717,57.9108 52.091,36.0141 79.1156,36.0283 C106.14,36.0424 128.037,57.962 128.023,84.9871 C128.008,112.012 106.089,133.909 79.0644,133.895 C52.0397,133.881 30.1434,111.961 30.1575,84.9359 Z" style="stroke:none;fill-rule:evenodd;fill:#d35400;fill-opacity:1;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Layer3" name="Layer 2" opacity="1">
|
||||
<g id="Shape4">
|
||||
<desc shapeID="4" type="0" basicInfo-basicType="4" basicInfo-roundedRectRadius="12" basicInfo-polygonSides="6" basicInfo-starPoints="5" bounding="rect(-48.9954,-49.7682,97.9908,99.5364)" text="" font-familyName="Helvetica" font-pixelSize="20" font-bold="0" font-underline="0" font-alignment="1" strokeStyle="0" markerStart="0" markerEnd="0" shadowEnabled="0" shadowOffsetX="0" shadowOffsetY="2" shadowBlur="4" shadowOpacity="160" blurEnabled="0" blurRadius="4" transform="matrix(0.998716,0.000522918,-0.000514806,0.983223,176.955,85.6138)" pers-center="0,0" pers-size="0,0" pers-start="0,0" pers-end="0,0" locked="0" mesh="" flag=""/>
|
||||
<path id="shapePath4" d="M128.022,85.5882 C128.036,58.5631 149.956,36.6664 176.98,36.6806 C204.005,36.6947 225.901,58.6144 225.887,85.6394 C225.873,112.665 203.954,134.561 176.929,134.547 C149.904,134.533 128.008,112.613 128.022,85.5882 Z" style="stroke:none;fill-rule:evenodd;fill:#e67e22;fill-opacity:1;"/>
|
||||
</g>
|
||||
<g id="Shape5">
|
||||
<desc shapeID="5" type="0" basicInfo-basicType="4" basicInfo-roundedRectRadius="12" basicInfo-polygonSides="6" basicInfo-starPoints="5" bounding="rect(-48.9954,-49.7682,97.9908,99.5364)" text="" font-familyName="Helvetica" font-pixelSize="20" font-bold="0" font-underline="0" font-alignment="1" strokeStyle="0" markerStart="0" markerEnd="0" shadowEnabled="0" shadowOffsetX="0" shadowOffsetY="2" shadowBlur="4" shadowOpacity="160" blurEnabled="0" blurRadius="4" transform="matrix(0.998716,0.000522918,-0.000514806,0.983223,128.003,121.592)" pers-center="0,0" pers-size="0,0" pers-start="0,0" pers-end="0,0" locked="0" mesh="" flag=""/>
|
||||
<path id="shapePath5" d="M79.0707,121.567 C79.0849,94.5417 101.004,72.645 128.029,72.6592 C155.054,72.6733 176.95,94.593 176.936,121.618 C176.922,148.643 155.002,170.54 127.978,170.526 C100.953,170.512 79.0566,148.592 79.0707,121.567 Z" style="stroke:none;fill-rule:evenodd;fill:#e67e22;fill-opacity:1;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Layer4" name="Layer 3" opacity="1">
|
||||
<g id="Shape6">
|
||||
<desc shapeID="6" type="0" basicInfo-basicType="4" basicInfo-roundedRectRadius="12" basicInfo-polygonSides="6" basicInfo-starPoints="5" bounding="rect(-50.2318,-49.7682,100.464,99.5363)" text="" font-familyName="Helvetica" font-pixelSize="20" font-bold="0" font-underline="0" font-alignment="1" strokeStyle="0" markerStart="0" markerEnd="0" shadowEnabled="0" shadowOffsetX="0" shadowOffsetY="2" shadowBlur="4" shadowOpacity="160" blurEnabled="0" blurRadius="4" transform="matrix(0.974133,0.000510046,-0.000514806,0.983224,128.041,48.9845)" pers-center="0,0" pers-size="0,0" pers-start="0,0" pers-end="0,0" locked="0" mesh="" flag=""/>
|
||||
<path id="shapePath6" d="M79.1089,48.9588 C79.123,21.9338 101.042,0.0370636 128.067,0.0512276 C155.092,0.0653763 176.988,21.985 176.974,49.0101 C176.96,76.0352 155.04,97.9319 128.016,97.9177 C100.991,97.9036 79.0947,75.9839 79.1089,48.9588 Z" style="stroke:none;fill-rule:evenodd;fill:#f1c40f;fill-opacity:1;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><path d="M42.422 126.285c-10.585 11.862-12.266 19.284-12.3 54.307l-.029 26.952h26.951c35.015.002 42.447-1.673 54.307-12.232l4.312-3.839.212 28.128c.2 26.551.297 28.271 1.739 30.667 4.887 8.12 17.233 7.578 21.1-.924 1.302-2.863 1.426-5.472 1.455-30.526l.031-27.395 4.343 3.875c11.887 10.606 19.291 12.285 54.329 12.32l26.951.028.001-26.951c.001-35.024-1.672-42.447-12.244-54.321l-3.851-4.324-4.442 3.029c-8.068 5.501-18.521 9.1-26.517 9.129l-3.129.012-2.028 5.159c-16.401 41.71-74.88 41.68-91.238-.047l-2.023-5.162-3.129-.015c-7.995-.038-18.444-3.648-26.506-9.157l-4.44-3.034z" style="stroke:none;fill-rule:evenodd;fill:#2ecc71;fill-opacity:1"/><path d="m46.286 121.964 163.434.085-.03 57.487-163.434-.085z" style="stroke:none;fill-rule:evenodd;fill:#2ecc71;fill-opacity:1"/><path d="M30.158 84.936c.014-27.025 21.933-48.922 48.958-48.908s48.921 21.934 48.907 48.96c-.015 27.024-21.934 48.921-48.959 48.907-27.024-.014-48.92-21.934-48.906-48.96" style="stroke:none;fill-rule:evenodd;fill:#d35400;fill-opacity:1"/><path d="M128.022 85.588c.014-27.025 21.934-48.922 48.958-48.907 27.025.014 48.921 21.933 48.907 48.958-.014 27.026-21.933 48.922-48.958 48.908s-48.921-21.934-48.907-48.959" style="stroke:none;fill-rule:evenodd;fill:#e67e22;fill-opacity:1"/><path d="M79.07 121.567c.015-27.025 21.934-48.922 48.959-48.908s48.921 21.934 48.907 48.959-21.934 48.922-48.958 48.908c-27.025-.014-48.921-21.934-48.907-48.959" style="stroke:none;fill-rule:evenodd;fill:#e67e22;fill-opacity:1"/><path d="M79.109 48.959C79.123 21.934 101.042.037 128.067.05s48.921 21.934 48.907 48.96c-.014 27.024-21.934 48.92-48.958 48.907-27.025-.014-48.921-21.934-48.907-48.96" style="stroke:none;fill-rule:evenodd;fill:#f1c40f;fill-opacity:1"/></svg>
|
||||
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -1,12 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 48 48">
|
||||
<!-- Generator: Adobe Illustrator 29.0.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 192) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #2f2f2f;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path id="Invader" class="st0" d="M15.2214499,30.6589508l1.8361292-2.0981903-2.6117897-.00597c-1.4364796-.0032806-2.6114798-.0085793-2.6111097-.0117493.0002804-.00317.1577702-.6325893.3497696-1.3986893l.3491001-1.3929195h-5.60711l.9638901-3.8554993.9638901-3.8554993h2.0806599l-.5253801,2.0981903c-.2889605,1.1540108-.5256901,2.1008396-.5260696,2.1040707-.0002804.0032196.7925396.0058899,1.7620497.0058899h1.7627401l.0164003-.0646496c.0090303-.0355606.2452698-.9797401.5249901-2.0981903l.5085897-2.0335407,5.0112696-.0235004-1.0804691-1.32827c-.5942593-.7305498-1.1016502-1.3573599-1.1275406-1.3929195l-.0470695-.0646496h2.0714703l1.22439,1.3987999,1.22439,1.3987999h4.5306797l1.22439-1.3987999,1.22439-1.3987999h2.0714703l-.0470695.0646496c-.0258808.0355597-.5332794.6623697-1.1275406,1.3929195l-1.0804691,1.32827,5.0112686.0235004.5085907,2.0335407c.2797203,1.1184502.5159683,2.0626392.5249901,2.0981903l.0163994.0646496h1.7627411c.9695091,0,1.7624283-.0025196,1.7620506-.0058899-.0002785-.0032196-.2371101-.9500599-.5260696-2.1040707l-.5253792-2.0981903h2.0806618l.9638901,3.8554993.9638901,3.8554993h-5.6071091l.3490982,1.3929195c.1920013.7660999.3493996,1.3955193.3497696,1.3986893.0002785.00317-1.1746292.0084705-2.6111107.0117493l-2.6117802.00597,1.8361301,2.0981903,1.8361397,2.0981903-3.185791-.0028-1.7044106-2.1022892-1.7044201-2.1022892h-8.0401287l-1.7044201,2.1022892-1.7044106,2.1022892-3.1857901.0028,1.8361588-2.0981903ZM19.9799404,21.8959103v-1.7514305h-3.5263691l-.0060806,1.7279205c-.0033398.9503593-.0011196,1.7412205.0051899,1.7574596.0089397.0234108.37533.0282898,1.7692699.0235004l1.7579899-.0060291v-1.751421ZM31.5464401,20.1444798l-3.5146198-.0120602v3.5269699l3.5146198-.0120602v-3.5028496Z"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><path d="m15.221 30.659 1.837-2.098-2.612-.006a297 297 0 0 1-2.611-.012c0-.003.157-.633.35-1.399l.349-1.393H6.926l.964-3.855.964-3.856h2.08l-.525 2.099-.526 2.104c0 .003.793.006 1.762.006h1.763l.017-.065.525-2.098.508-2.034 5.011-.023-1.08-1.329a204 204 0 0 1-1.128-1.392l-.047-.065h2.072l1.224 1.399 1.225 1.398h4.53l1.225-1.398 1.224-1.4h2.072l-.047.066c-.026.035-.534.662-1.128 1.392l-1.08 1.329 5.01.023.51 2.034.524 2.098.017.065h1.762c.97 0 1.763-.003 1.763-.006l-.527-2.104-.525-2.099h2.08l.965 3.856.964 3.855h-5.608l.35 1.393.35 1.399c0 .003-1.175.009-2.612.012l-2.612.006 1.837 2.098 1.836 2.098-3.186-.003-1.705-2.102-1.704-2.102h-8.04l-1.705 2.102-1.704 2.102-3.186.003zm4.759-8.763v-1.752h-3.526l-.007 1.728c-.003.95 0 1.742.006 1.758.009.023.375.028 1.769.023l1.758-.006zm11.566-1.752-3.514-.012v3.527l3.514-.012z" style="fill:#2f2f2f"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 917 B |
@@ -1,53 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 1067 1067"
|
||||
version="1.1"
|
||||
xml:space="preserve"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
|
||||
id="svg2"
|
||||
sodipodi:docname="rd-stable-icon.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.7469541"
|
||||
inkscape:cx="533.78621"
|
||||
inkscape:cy="533.5"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="2082"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" /><g
|
||||
id="g1"><path
|
||||
d="M1066.66,266.666c-0,-147.176 -119.49,-266.666 -266.666,-266.666l-533.333,-0c-147.176,-0 -266.666,119.49 -266.666,266.666l-0,533.333c-0,147.176 119.49,266.666 266.666,266.666l533.333,-0c147.176,-0 266.666,-119.49 266.666,-266.666l-0,-533.333Z"
|
||||
style="fill:#000000"
|
||||
id="path1" /></g><g
|
||||
id="g2"><g
|
||||
id="D"><path
|
||||
d="M847.457,876.335c-18.448,18.448 -38.862,32.56 -61.237,42.335c-22.369,9.775 -46.705,14.662 -73.001,14.662l-430.192,0l-0,-110.487l430.192,-0c9.775,-0 18.965,-1.825 27.569,-5.473c8.605,-3.649 16.107,-8.673 22.512,-15.078c6.399,-6.399 11.429,-13.907 15.077,-22.511c3.649,-8.605 5.473,-17.794 5.473,-27.57l0,-437.756c0,-9.088 -2,-17.863 -5.99,-26.331c-3.995,-8.468 -9.263,-15.97 -15.799,-22.511c-6.542,-6.541 -14.043,-11.81 -22.512,-15.799c-8.468,-3.996 -17.248,-5.991 -26.33,-5.991l-430.192,0l-0,-110.487l430.192,-0c13.077,-0 25.779,1.375 38.1,4.126c12.322,2.756 24.166,6.643 35.521,11.673c11.36,5.024 22.136,11.048 32.326,18.067c10.184,7.024 19.618,14.731 28.291,23.131c18.039,17.487 32.048,37.623 42.028,60.407c9.979,22.79 14.975,47.398 14.975,73.836l-0,417.514c-0,26.438 -4.888,50.808 -14.663,73.109c-9.781,22.306 -23.892,42.681 -42.34,61.134Z"
|
||||
style="fill:#fff;fill-rule:nonzero;"
|
||||
id="path2" /></g><use
|
||||
xlink:href="#_Image1"
|
||||
x="159.95"
|
||||
y="366.372"
|
||||
width="441px"
|
||||
height="334px"
|
||||
id="use2" /></g><defs
|
||||
id="defs2"><image
|
||||
id="_Image1"
|
||||
width="441px"
|
||||
height="334px"
|
||||
xlink:href="data:image/png;base64,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 truncated
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2" viewBox="0 0 1067 1067"><path d="M1066.66 266.666C1066.66 119.49 947.17 0 799.994 0H266.661C119.485 0-.005 119.49-.005 266.666v533.333c0 147.176 119.49 266.666 266.666 266.666h533.333c147.176 0 266.666-119.49 266.666-266.666z" style="fill:#000"/><path d="M847.457 876.335q-27.673 27.672-61.237 42.335-33.555 14.662-73.001 14.662H283.027V822.845h430.192q14.663 0 27.569-5.473t22.512-15.078c6.399-6.399 11.429-13.907 15.077-22.511q5.473-12.907 5.473-27.57V314.457c0-9.088-2-17.863-5.99-26.331q-5.994-12.7-15.799-22.511c-6.542-6.541-14.043-11.81-22.512-15.799-8.468-3.996-17.248-5.991-26.33-5.991H283.027V133.338h430.192q19.617 0 38.1 4.126c12.322 2.756 24.166 6.643 35.521 11.673a198 198 0 0 1 32.326 18.067c10.184 7.024 19.618 14.731 28.291 23.131q27.058 26.23 42.028 60.407c9.979 22.79 14.975 47.398 14.975 73.836v417.514q0 39.657-14.663 73.109c-9.781 22.306-23.892 42.681-42.34 61.134" style="fill:#fff;fill-rule:nonzero"/><use xlink:href="#a" width="441" height="334" x="159.95" y="366.372"/><defs><image xlink:href="data:image/png;base64,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 truncated
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 5.0 KiB |
@@ -10,7 +10,8 @@ them. The site build only reads those copies (``sitediagrams.localize_images``).
|
||||
|
||||
Raster logos are normalised to an 80 px square WebP, twice the 40 px they
|
||||
are shown at, and system icons to a 48 px PNG, so every tile has the same
|
||||
weight. SVG is kept as published.
|
||||
weight. SVG is minified by svgo, and a raster an SVG embeds is cut to the
|
||||
tile size.
|
||||
Normalising needs Pillow. This is a maintenance tool run by hand when a
|
||||
logo changes, not a build step: the build stays on the standard library
|
||||
and PyYAML.
|
||||
@@ -22,8 +23,11 @@ and PyYAML.
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -79,6 +83,44 @@ def safe_svg(data: bytes, url: str) -> bytes:
|
||||
return data
|
||||
|
||||
|
||||
# A raster inside an SVG logo: RetroDECK's icon carried a 441 x 334 PNG,
|
||||
# 9 KiB of a mark drawn 40 px wide. It never needs more pixels than the
|
||||
# whole tile.
|
||||
EMBEDDED_RASTER = re.compile(rb'data:image/(?:png|jpeg|webp);base64,([A-Za-z0-9+/=\s]+)')
|
||||
|
||||
|
||||
def shrink_embedded(data: bytes) -> bytes:
|
||||
"""Each raster an SVG embeds, at most LOGO_SIZE pixels on its longest side."""
|
||||
|
||||
def shrink(match: re.Match[bytes]) -> bytes:
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError as exc:
|
||||
raise SystemExit("Pillow is needed to normalise raster images: pip install Pillow") from exc
|
||||
image = Image.open(io.BytesIO(base64.b64decode(re.sub(rb"\s", b"", match.group(1))))).convert("RGBA")
|
||||
if max(image.size) <= LOGO_SIZE:
|
||||
return match.group(0)
|
||||
image.thumbnail((LOGO_SIZE, LOGO_SIZE), Image.LANCZOS)
|
||||
out = io.BytesIO()
|
||||
image.save(out, "PNG", optimize=True)
|
||||
return b"data:image/png;base64," + base64.b64encode(out.getvalue())
|
||||
|
||||
return EMBEDDED_RASTER.sub(shrink, data)
|
||||
|
||||
|
||||
# Editor markup (Inkscape's namedview, sodipodi attributes, metadata) was
|
||||
# most of the bytes of two logos. svgo comes with the site's packages.
|
||||
SVGO = ROOT / "website" / "node_modules" / ".bin" / "svgo"
|
||||
|
||||
|
||||
def minify_svg(data: bytes) -> bytes:
|
||||
if not SVGO.is_file():
|
||||
raise SystemExit("svgo is needed to minify SVG logos: npm ci --prefix website")
|
||||
result = subprocess.run([str(SVGO), "--multipass", "-i", "-", "-o", "-"],
|
||||
input=data, capture_output=True, check=True)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def write_manifest(directory: Path, note: str, entries: list[dict]) -> None:
|
||||
entries.sort(key=lambda entry: entry["file"])
|
||||
document = {"note": note, "images": entries}
|
||||
@@ -101,7 +143,7 @@ def fetch_logos() -> None:
|
||||
download = f"{url}?s={LOGO_SIZE * 2}" if AVATAR_HOST in url else url
|
||||
data = fetch(download)
|
||||
if is_svg(data):
|
||||
file_name, payload = f"{name}.svg", safe_svg(data, url)
|
||||
file_name, payload = f"{name}.svg", minify_svg(shrink_embedded(safe_svg(data, url)))
|
||||
else:
|
||||
file_name, payload = f"{name}.webp", square_image(data, LOGO_SIZE, "WEBP")
|
||||
for stale in LOGO_DIR.glob(f"{name}.*"):
|
||||
|
||||
@@ -52,7 +52,7 @@ class TablesStackWhenBuilt(unittest.TestCase):
|
||||
def test_the_plugin_is_registered_and_the_runtime_rule_is_gone(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("import stackTables from './src/plugins/rehype-stack-tables.mjs'", config)
|
||||
self.assertIn("hastPlugins: [stackTables()]", config)
|
||||
self.assertIn("hastPlugins: [stackTables(), ", config)
|
||||
self.assertNotIn("stackActionTable", _read(SITE_TS))
|
||||
|
||||
def test_setup_tables_carry_the_layout_in_the_html(self):
|
||||
|
||||
@@ -213,7 +213,7 @@ class HashLookupGivesOneVerdict(unittest.TestCase):
|
||||
source = _read(SITE_TS)
|
||||
self.assertNotIn("'match' : 'matches'", source)
|
||||
self.assertIn("count(platforms.length, 'declaration', 'declarations')} by ${count(setups.size, 'setup', 'setups')}", source)
|
||||
self.assertIn("count(setups.size, 'setup lists', 'setups list')", source)
|
||||
self.assertIn("count(listers.size, 'setup lists', 'setups list')", source)
|
||||
self.assertIn("'file in the collection', 'files in the collection'", source)
|
||||
|
||||
def test_emulators_by_name(self):
|
||||
@@ -291,14 +291,16 @@ class SearchFoldsPagesUnderAHash(unittest.TestCase):
|
||||
|
||||
def test_search_fields_are_weighted_and_never_shown(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);", _read(SITE_TS))
|
||||
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5, consoles: 10, terms: 10 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);", _read(SITE_TS))
|
||||
|
||||
|
||||
class OsTabsKeepTheChoiceInView(unittest.TestCase):
|
||||
def test_the_row_scrolls_not_the_page(self):
|
||||
class OsTabsNeverScrollThePage(unittest.TestCase):
|
||||
"""The tabs wrap since round 4 (retrobios.css): the script picks the
|
||||
visitor's tab and never scrolls the page."""
|
||||
|
||||
def test_no_page_scroll(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("list.scrollLeft", source)
|
||||
self.assertNotIn("scrollIntoView", source)
|
||||
self.assertIn("osTabs();", source)
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Round 4, package 2: components and runtime.
|
||||
|
||||
Source checks always run. Checks on the built site read website/dist and
|
||||
skip when no build is there. The rules written in TypeScript or in a build
|
||||
plugin run under node and skip without it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
WEBSITE = REPO_ROOT / "website"
|
||||
DIST = WEBSITE / "dist"
|
||||
COMPONENTS = WEBSITE / "src" / "components"
|
||||
SITE_TS = WEBSITE / "src" / "scripts" / "site.ts"
|
||||
CONFIG = WEBSITE / "astro.config.mjs"
|
||||
SHORT_CODE = WEBSITE / "src" / "plugins" / "rehype-short-code.mjs"
|
||||
LOGOS = REPO_ROOT / "docs_assets" / "logos"
|
||||
|
||||
# The four hashes of one dump, bios/Sony/PlayStation/scph5501.bin.
|
||||
SCPH5501 = {
|
||||
"md5": "490f666e1afb15b7362b406ed1cea246",
|
||||
"sha1": "0555c6fae8906f3f09baf5988f00e55f88e9f30b",
|
||||
"crc32": "8d8cb7e4",
|
||||
"sha256": "11052b6499e466bbf0a709b1f9cb6834a9418e66680387912451e971cf8a1fef",
|
||||
}
|
||||
ANDROID_UA = ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _built(page: str) -> str:
|
||||
path = DIST / page / "index.html"
|
||||
if not path.is_file():
|
||||
raise unittest.SkipTest(f"no built site at {path}")
|
||||
return _read(path)
|
||||
|
||||
|
||||
def _node(script: str) -> str:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
raise unittest.SkipTest("node is missing")
|
||||
result = subprocess.run(
|
||||
[node, "--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, check=True, cwd=WEBSITE,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _short_unmarked(html: str) -> list[str]:
|
||||
"""Inline code of 30 characters or fewer without rb-short, outside pre."""
|
||||
body = re.sub(r"<pre\b.*?</pre>", "", html, flags=re.S)
|
||||
found = []
|
||||
for attributes, text in re.findall(r"<code(\s[^>]*)?>([^<]*)</code>", body):
|
||||
plain = re.sub(r"&(?:#\d+|#x[0-9a-f]+|[a-z]+);", "_", text, flags=re.I)
|
||||
if 0 < len(plain) <= 30 and "rb-short" not in (attributes or ""):
|
||||
found.append(text)
|
||||
return found
|
||||
|
||||
|
||||
class OneVerdictWhateverHash(unittest.TestCase):
|
||||
"""2.1: the SHA1 of scph5501.bin found 4 setups, its MD5 12, its SHA256
|
||||
none (V1, J4). A setup's list writes one hash of a file."""
|
||||
|
||||
def test_the_other_hashes_are_read(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const heldHashes = (file: Held) =>", source)
|
||||
self.assertIn("await lookupValues(others)", source)
|
||||
self.assertIn("item.sha1 ?? files.find((file) => file && heldHashes(file).includes(item.value))?.sha1", source)
|
||||
self.assertIn("(fileOf(declared) ?? item.sha1) === item.sha1", source)
|
||||
|
||||
def test_the_verdict_names_the_other_name(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("'another name' : 'other names'}: `", source)
|
||||
self.assertNotIn("under the ${otherNames.length === 1 ? 'name' : 'names'}", source)
|
||||
|
||||
def test_entries_match_on_any_hash_of_the_file(self):
|
||||
source = _read(SITE_TS)
|
||||
self.assertIn("const HASH_FIELDS = ['sha1', 'md5', 'crc32', 'sha256'];", source)
|
||||
self.assertIn("declaredEntry(item, valuesOf(item))", source)
|
||||
self.assertIn("readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item))", source)
|
||||
|
||||
def test_what_the_renderer_reads_is_built(self):
|
||||
"""Each hash finds the collection record with its SHA1, and the
|
||||
manufacturer file gives the other three, so every paste reads the
|
||||
same four shards."""
|
||||
api = DIST / "api" / "v1"
|
||||
if not (api / "lookup").is_dir():
|
||||
self.skipTest("no built site")
|
||||
for value in SCPH5501.values():
|
||||
with self.subTest(value=value):
|
||||
shard = json.loads(_read(api / "lookup" / f"{value[:2]}.json"))["items"]
|
||||
held = [i for i in shard if i["value"] == value and i["layer"] == "collection"]
|
||||
self.assertEqual([i["sha1"] for i in held], [SCPH5501["sha1"]])
|
||||
self.assertIn("/systems/sony-playstation/", held[0]["url"])
|
||||
consoles = json.loads(_read(api / "systems" / "sony.json"))["items"][0]["consoles"]
|
||||
card = next(f for files in consoles.values() for f in files if f["sha1"] == SCPH5501["sha1"])
|
||||
self.assertEqual({k: card[k] for k in SCPH5501}, SCPH5501)
|
||||
|
||||
|
||||
class TheVisitorsTab(unittest.TestCase):
|
||||
"""2.2: on an Android phone the tabs opened on Windows (V4, T-F1)."""
|
||||
|
||||
def _rule(self) -> str:
|
||||
source = _read(SITE_TS)
|
||||
return source[source.index("const OS_TAB_RULES"):source.index("// End of the OS tab rule.")]
|
||||
|
||||
def test_labels_from_the_user_agent(self):
|
||||
cases = [
|
||||
[ANDROID_UA, ""],
|
||||
["Mozilla/5.0 (X11; Linux x86_64) Valve Steam GameOverlay/1729", "Linux"],
|
||||
["Mozilla/5.0 (X11; Linux x86_64; SteamOS) Firefox/130.0", ""],
|
||||
["Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) Safari/605.1.15", ""],
|
||||
["Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/130.0", "Windows"],
|
||||
["Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0", "Linux"],
|
||||
["Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) Safari/604.1", ""],
|
||||
["Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) Chrome/130.0", "Chrome OS"],
|
||||
]
|
||||
out = _node(self._rule() + f"console.log(JSON.stringify({json.dumps(cases)}.map(([ua, p]) => osTabLabel(ua, p))));")
|
||||
self.assertEqual(json.loads(out), [
|
||||
"Android (Termux)", "Steam Deck", "Steam Deck", "macOS", "Windows", "Linux", "", "",
|
||||
])
|
||||
|
||||
def test_labels_are_tab_labels(self):
|
||||
guides = _read(REPO_ROOT / "platforms" / "_guides.yml")
|
||||
for label in re.findall(r"\[/[^/]+/i, '([^']+)'\]", self._rule()):
|
||||
with self.subTest(label=label):
|
||||
self.assertIn(f"label: {label}\n", guides)
|
||||
|
||||
def test_a_saved_choice_wins_and_the_guess_is_not_saved(self):
|
||||
source = _read(SITE_TS)
|
||||
block = source[source.index("const osTabs = () =>"):source.index("// The sidebar lists")]
|
||||
self.assertIn("savedTab(OS_SYNC_KEY)", block)
|
||||
self.assertIn("set.switchTab(set.tabs[index], index, false)", block)
|
||||
self.assertNotIn("localStorage.setItem", source)
|
||||
self.assertNotIn("keepTabInView", source)
|
||||
|
||||
|
||||
class TheSidebarShowsTheCurrentPage(unittest.TestCase):
|
||||
"""2.3: the current link sat at y 1364 in a 900 px window (V5)."""
|
||||
|
||||
def test_the_pane_scrolls_never_the_page(self):
|
||||
source = _read(SITE_TS)
|
||||
block = source[source.index("const showCurrentPage = () =>"):source.index("// A glossary term")]
|
||||
self.assertIn("pane.scrollTop +=", block)
|
||||
self.assertIn("pane.clientHeight / 3", block)
|
||||
self.assertIn("addEventListener('toggle'", block)
|
||||
self.assertNotIn("scrollIntoView", source)
|
||||
self.assertIn("showCurrentPage();", source)
|
||||
|
||||
|
||||
class ChromeOutOfTheIndex(unittest.TestCase):
|
||||
"""2.4: excerpts opened on the title and closed on the licence line, and
|
||||
"bios" matched every page through the footer (N2, V6)."""
|
||||
|
||||
def test_components(self):
|
||||
title = _read(COMPONENTS / "PageTitle.astro")
|
||||
self.assertIn('<nav class="rb-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>', title)
|
||||
self.assertEqual(title.count('data-pagefind-ignore data-pagefind-meta="title"'), 2)
|
||||
footer = _read(COMPONENTS / "Footer.astro")
|
||||
self.assertIn('<div class="rb-footer-root" data-pagefind-ignore>\n<Default />', footer)
|
||||
|
||||
def test_built_page(self):
|
||||
html = _built("systems/sony-playstation")
|
||||
self.assertRegex(html, r'<nav class="rb-breadcrumbs[^"]*"[^>]*data-pagefind-ignore')
|
||||
self.assertRegex(html, r'<h1 id="_top"[^>]*data-pagefind-ignore[^>]*data-pagefind-meta="title"')
|
||||
root = html.index('class="rb-footer-root')
|
||||
self.assertIn("data-pagefind-ignore", html[html.rfind("<div", 0, root):root + 80])
|
||||
self.assertGreater(html.index("MIT for the tooling"), root)
|
||||
|
||||
|
||||
class ShortCodeMarked(unittest.TestCase):
|
||||
"""2.5: `--restore` split into `--` and `restore` on the home page at
|
||||
1440 px, and 51 flags split in the installer's options table (V3)."""
|
||||
|
||||
def test_the_rule(self):
|
||||
out = _node(
|
||||
f"import {{ isShort, markRawCode, SHORT_CODE }} from {json.dumps(SHORT_CODE.as_uri())};"
|
||||
"console.log(JSON.stringify([SHORT_CODE, isShort('--restore', false), isShort('--restore', true),"
|
||||
"isShort('x'.repeat(31), false), markRawCode('<td><code>bios/a.bin</code> <code class=\"rb-hash\">ab</code></td>'),"
|
||||
"markRawCode('<pre><code>a</code></pre>')]));"
|
||||
)
|
||||
self.assertEqual(json.loads(out), [
|
||||
30, True, False, False,
|
||||
'<td><code class="rb-short">bios/a.bin</code> <code class="rb-hash">ab</code></td>',
|
||||
"<pre><code>a</code></pre>",
|
||||
])
|
||||
|
||||
def test_registered(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("hastPlugins: [stackTables(), shortCode()],", config)
|
||||
|
||||
def test_every_short_code_is_marked(self):
|
||||
for page in ("", "wiki/installer", "which-pack", "emulators/duckstation", "systems/sony", "wiki/faq"):
|
||||
with self.subTest(page=page or "home"):
|
||||
self.assertEqual(_short_unmarked(_built(page)), [])
|
||||
self.assertIn('class="rb-short"', _built(""))
|
||||
|
||||
def test_the_options_table(self):
|
||||
self.assertRegex(_built("wiki/installer"), r'<table class="rb-options"')
|
||||
|
||||
|
||||
class SearchFields(unittest.TestCase):
|
||||
"""2.6: `consoles` and `terms` weigh as a title (N3, T-F2, D4)."""
|
||||
|
||||
def test_declared_and_never_shown(self):
|
||||
config = _read(CONFIG)
|
||||
self.assertIn("metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5, consoles: 10, terms: 10 },", config)
|
||||
self.assertIn("const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);", _read(SITE_TS))
|
||||
|
||||
|
||||
class SearchLoadsOnFirstUse(unittest.TestCase):
|
||||
"""2.7: ui-core.js (25.8 KiB) loaded on idle on every page (T-F8)."""
|
||||
|
||||
def test_the_override(self):
|
||||
self.assertIn("Search: './src/components/Search.astro',", _read(CONFIG))
|
||||
search = _read(COMPONENTS / "Search.astro")
|
||||
self.assertNotIn("requestIdleCallback", search)
|
||||
self.assertNotIn("DOMContentLoaded", search)
|
||||
self.assertIn("for (const type of ['pointerenter', 'pointerdown', 'focus'])", search)
|
||||
self.assertIn("loadSearch()?.then(", search)
|
||||
self.assertNotIn("TODO", search)
|
||||
|
||||
|
||||
class LogosAtTheirWeight(unittest.TestCase):
|
||||
"""2.7: two logos were Inkscape files, one with a 441 x 334 PNG inside."""
|
||||
|
||||
def test_no_editor_markup_and_small_rasters(self):
|
||||
import base64
|
||||
import struct
|
||||
|
||||
for path in sorted(LOGOS.glob("*.svg")):
|
||||
text = _read(path)
|
||||
with self.subTest(logo=path.name):
|
||||
self.assertNotRegex(text, r"inkscape:|sodipodi:|<metadata")
|
||||
self.assertNotRegex(text, r"data:image/(?!png)")
|
||||
for data in re.findall(r"data:image/png;base64,([A-Za-z0-9+/=]+)", text):
|
||||
# Width and height of a PNG: the IHDR chunk, bytes 16 to 24.
|
||||
width, height = struct.unpack(">II", base64.b64decode(data)[16:24])
|
||||
self.assertLessEqual(max(width, height), 80)
|
||||
|
||||
def test_the_fetcher_writes_them_so(self):
|
||||
script = _read(REPO_ROOT / "scripts" / "fetch_site_images.py")
|
||||
self.assertIn('minify_svg(shrink_embedded(safe_svg(data, url)))', script)
|
||||
|
||||
|
||||
class CopyButtonInTheHeader(unittest.TestCase):
|
||||
"""2.10: on touch the copy button covered the end of the first line."""
|
||||
|
||||
def test_rule(self):
|
||||
source = _read(COMPONENTS / "MarkdownContent.astro")
|
||||
self.assertIn("@media (pointer: coarse)", source)
|
||||
self.assertIn(".expressive-code .frame.has-title .copy { inset-block-start:", source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -13,6 +13,7 @@ import starlightLinksValidator from 'starlight-links-validator';
|
||||
import sidebar from './src/generated/sidebar.json' with { type: 'json' };
|
||||
import lastmod from './src/generated/lastmod.json' with { type: 'json' };
|
||||
import stackTables from './src/plugins/rehype-stack-tables.mjs';
|
||||
import shortCode from './src/plugins/rehype-short-code.mjs';
|
||||
import unbrokenTokens from './src/plugins/ec-unbroken-tokens.mjs';
|
||||
|
||||
const SITE = 'https://abdess.github.io';
|
||||
@@ -52,7 +53,7 @@ export default defineConfig({
|
||||
redirects: readRedirects(),
|
||||
markdown: {
|
||||
processor: satteri({
|
||||
hastPlugins: [stackTables()],
|
||||
hastPlugins: [stackTables(), shortCode()],
|
||||
features: {
|
||||
headingAttributes: true,
|
||||
definitionList: true,
|
||||
@@ -81,6 +82,7 @@ export default defineConfig({
|
||||
MarkdownContent: './src/components/MarkdownContent.astro',
|
||||
Footer: './src/components/Footer.astro',
|
||||
Header: './src/components/Header.astro',
|
||||
Search: './src/components/Search.astro',
|
||||
},
|
||||
routeMiddleware: ['./src/routeData.ts'],
|
||||
tableOfContents: { minHeadingLevel: 2, maxHeadingLevel: 3 },
|
||||
@@ -109,15 +111,20 @@ export default defineConfig({
|
||||
// every other query. Two fields name the page's subject in other words
|
||||
// and weigh half a title: `aliases`, the ids setups file a system under
|
||||
// (psx, dreamcast, gba), and `devices`, the handhelds and boxes a
|
||||
// setup's own site names (R36S, Miyoo Mini). All three are attributes
|
||||
// on an empty element, so no text of theirs reaches an excerpt.
|
||||
// setup's own site names (R36S, Miyoo Mini). Two more weigh as much as
|
||||
// a title, since each names what a reader types to reach the page:
|
||||
// `consoles`, the consoles a manufacturer page holds (Neo Geo,
|
||||
// WonderSwan Color), and `terms`, the words of a task or a message on
|
||||
// screen (bios not found, mame version, check dump). All of them are
|
||||
// attributes on an empty element, so no text of theirs reaches an
|
||||
// excerpt.
|
||||
pagefind: {
|
||||
ranking: {
|
||||
pageLength: 0.1,
|
||||
termFrequency: 0.5,
|
||||
termSaturation: 1.4,
|
||||
termSimilarity: 9,
|
||||
metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5 },
|
||||
metaWeights: { title: 10, files: 0.5, aliases: 5, devices: 5, consoles: 10, terms: 10 },
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// Starlight's footer (edit link, previous and next), who keeps the project
|
||||
// and where to report a problem, the release the packs come from, and the
|
||||
// licence line. The release is read from release.json, written when the
|
||||
// packs were published: the site may be newer than the packs.
|
||||
// packs were published: the site may be newer than the packs. None of it
|
||||
// is in the search index: "MIT for the tooling" was the excerpt of short
|
||||
// pages, and "bios", "release" and "problem" matched every page.
|
||||
import Default from '@astrojs/starlight/components/Footer.astro';
|
||||
import release from '../../../release.json';
|
||||
|
||||
@@ -19,6 +21,7 @@ const tag: string = release.tag;
|
||||
const date = releaseDate(tag);
|
||||
---
|
||||
|
||||
<div class="rb-footer-root" data-pagefind-ignore>
|
||||
<Default />
|
||||
<div class="rb-footer">
|
||||
<p>
|
||||
@@ -33,8 +36,11 @@ const date = releaseDate(tag);
|
||||
archival and interoperability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* The wrapper only keeps the footer out of the search index: it adds no box. */
|
||||
.rb-footer-root { display: contents; }
|
||||
.rb-footer {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
|
||||
@@ -51,4 +51,11 @@ const { searchWeight } = Astro.locals.starlightRoute.entry.data;
|
||||
.sl-markdown-content .rb-file-pick-note { color: var(--sl-color-gray-3); font-size: var(--sl-text-sm); }
|
||||
.sl-markdown-content .rb-file-pick-hashes code { overflow-wrap: anywhere; }
|
||||
.sl-markdown-content .rb-file-pick-hashes[hidden] { display: none; }
|
||||
/* On touch the copy button is always shown, and it covered the end of a
|
||||
command's first line. A titled frame (Terminal, PowerShell) has a
|
||||
header row with room on its right: the button sits there. */
|
||||
@media (pointer: coarse) {
|
||||
.expressive-code .frame.has-title .copy { inset-block-start: calc(var(--ec-brdWd) + 0.0625rem); }
|
||||
.expressive-code .frame.has-title .copy button { width: 2rem; height: 2rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,10 @@
|
||||
// a dark mark stays visible in the dark theme. A setup page with no logo file
|
||||
// gets its initials in the same square, as its tile does (sitemarkup.py
|
||||
// platform_tiles).
|
||||
//
|
||||
// The crumbs and the title stay out of the search index: every excerpt
|
||||
// began with the title and the crumbs matched "bios" on every page. The
|
||||
// title still names the result, as the page's title field.
|
||||
import setups from '../../public/data/picker/index.json';
|
||||
|
||||
const PAGE_TITLE_ID = '_top';
|
||||
@@ -46,7 +50,7 @@ const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platf
|
||||
|
||||
{
|
||||
crumbs.length > 0 && (
|
||||
<nav class="rb-breadcrumbs" aria-label="Breadcrumb">
|
||||
<nav class="rb-breadcrumbs" aria-label="Breadcrumb" data-pagefind-ignore>
|
||||
<ol>
|
||||
{crumbs.map((crumb) => (
|
||||
<li>{crumb.href ? <a href={crumb.href}>{crumb.label}</a> : crumb.label}</li>
|
||||
@@ -68,10 +72,10 @@ const setup = !logoSrc && setups.find((entry) => id === `platforms/${entry.platf
|
||||
</text>
|
||||
</svg>
|
||||
)}
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
<h1 id={PAGE_TITLE_ID} data-pagefind-ignore data-pagefind-meta="title">{title}</h1>
|
||||
</div>
|
||||
) : (
|
||||
<h1 id={PAGE_TITLE_ID}>{title}</h1>
|
||||
<h1 id={PAGE_TITLE_ID} data-pagefind-ignore data-pagefind-meta="title">{title}</h1>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
---
|
||||
// Starlight's search (@astrojs/starlight 0.42.6, components/Search.astro),
|
||||
// with one change: the Pagefind UI (ui-core.js, 26 KiB) loads when the
|
||||
// reader reaches for search, a pointer on the button, focus on it, a tap,
|
||||
// Ctrl+K, and not on idle on every page. It was a third of the home page's
|
||||
// weight. The markup and the styles are Starlight's own.
|
||||
import { Icon } from '@astrojs/starlight/components';
|
||||
import project from 'virtual:starlight/project-context';
|
||||
|
||||
const pagefindTranslations = {
|
||||
placeholder: Astro.locals.t('search.label'),
|
||||
...Object.fromEntries(
|
||||
Object.entries(Astro.locals.t.all())
|
||||
.filter(([key]) => key.startsWith('pagefind.'))
|
||||
.map(([key, value]) => [key.replace('pagefind.', ''), value])
|
||||
),
|
||||
};
|
||||
|
||||
const dataAttributes: DOMStringMap = { 'data-translations': JSON.stringify(pagefindTranslations) };
|
||||
if (project.trailingSlash === 'never') dataAttributes['data-strip-trailing-slash'] = '';
|
||||
---
|
||||
|
||||
<site-search class={Astro.props.class} {...dataAttributes}>
|
||||
<button
|
||||
data-open-modal
|
||||
disabled
|
||||
aria-label={Astro.locals.t('search.label')}
|
||||
aria-keyshortcuts="Control+K"
|
||||
>
|
||||
<Icon name="magnifier" />
|
||||
<span class="sl-hidden md:sl-block" aria-hidden="true">{Astro.locals.t('search.label')}</span>
|
||||
<kbd class="sl-hidden md:sl-flex" style="display: none;">
|
||||
<kbd>{Astro.locals.t('search.ctrlKey')}</kbd><kbd>K</kbd>
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<dialog style="padding:0" aria-label={Astro.locals.t('search.label')}>
|
||||
<div class="dialog-frame sl-flex">
|
||||
<button data-close-modal class="sl-flex md:sl-hidden">
|
||||
{Astro.locals.t('search.cancelLabel')}
|
||||
</button>
|
||||
{
|
||||
import.meta.env.DEV ? (
|
||||
<div style="margin: auto; text-align: center; white-space: pre-line;" dir="ltr">
|
||||
<p>{Astro.locals.t('search.devWarning')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="search-container">
|
||||
<div id="starlight__search" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</dialog>
|
||||
</site-search>
|
||||
|
||||
{
|
||||
/**
|
||||
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
|
||||
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
|
||||
* user agent is spoofed by some browsers when opening the devtools.
|
||||
*/
|
||||
}
|
||||
<script is:inline>
|
||||
(() => {
|
||||
const openBtn = document.querySelector('button[data-open-modal]');
|
||||
const shortcut = openBtn?.querySelector('kbd');
|
||||
if (!openBtn || !(shortcut instanceof HTMLElement)) return;
|
||||
const platformKey = shortcut.querySelector('kbd');
|
||||
if (platformKey && /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform)) {
|
||||
platformKey.textContent = '⌘';
|
||||
openBtn.setAttribute('aria-keyshortcuts', 'Meta+K');
|
||||
}
|
||||
shortcut.style.display = '';
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { pagefindUserConfig } from 'virtual:starlight/pagefind-config';
|
||||
|
||||
class SiteSearch extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const openBtn = this.querySelector<HTMLButtonElement>('button[data-open-modal]')!;
|
||||
const closeBtn = this.querySelector<HTMLButtonElement>('button[data-close-modal]')!;
|
||||
const dialog = this.querySelector('dialog')!;
|
||||
const dialogFrame = this.querySelector('.dialog-frame')!;
|
||||
|
||||
/** Close the modal if a user clicks on a link or outside of the modal. */
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const isLink = 'href' in (event.target || {});
|
||||
if (
|
||||
isLink ||
|
||||
(document.body.contains(event.target as Node) &&
|
||||
!dialogFrame.contains(event.target as Node))
|
||||
) {
|
||||
closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = (event?: MouseEvent) => {
|
||||
dialog.showModal();
|
||||
document.body.toggleAttribute('data-search-modal-open', true);
|
||||
this.querySelector('input')?.focus();
|
||||
event?.stopPropagation();
|
||||
window.addEventListener('click', onClick);
|
||||
// Opened before the UI was there: its input takes the focus once drawn.
|
||||
loadSearch()?.then(() => {
|
||||
if (dialog.open) this.querySelector('input')?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = () => dialog.close();
|
||||
|
||||
openBtn.addEventListener('click', openModal);
|
||||
openBtn.disabled = false;
|
||||
closeBtn.addEventListener('click', closeModal);
|
||||
|
||||
dialog.addEventListener('close', () => {
|
||||
document.body.toggleAttribute('data-search-modal-open', false);
|
||||
window.removeEventListener('click', onClick);
|
||||
});
|
||||
|
||||
// Listen for `ctrl + k` and `cmd + k` keyboard shortcuts.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey === true || e.ctrlKey === true) && e.key === 'k') {
|
||||
dialog.open ? closeModal() : openModal();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
let translations = {};
|
||||
try {
|
||||
translations = JSON.parse(this.dataset.translations || '{}');
|
||||
} catch {}
|
||||
|
||||
const shouldStrip = this.dataset.stripTrailingSlash !== undefined;
|
||||
const stripTrailingSlash = (path: string) => path.replace(/(.)\/(#.*)?$/, '$1$2');
|
||||
const formatURL = shouldStrip ? stripTrailingSlash : (path: string) => path;
|
||||
|
||||
let loading: Promise<void> | undefined;
|
||||
const loadSearch = () => {
|
||||
if (import.meta.env.DEV) return undefined;
|
||||
loading ??= (async () => {
|
||||
// @ts-expect-error: the @pagefind/default-ui package has no types.
|
||||
const { PagefindUI } = await import('@pagefind/default-ui');
|
||||
new PagefindUI({
|
||||
...pagefindUserConfig,
|
||||
element: '#starlight__search',
|
||||
baseUrl: import.meta.env.BASE_URL,
|
||||
bundlePath: import.meta.env.BASE_URL.replace(/\/$/, '') + '/pagefind/',
|
||||
showImages: false,
|
||||
translations,
|
||||
showSubResults: true,
|
||||
processResult: (result: { url: string; sub_results: Array<{ url: string }> }) => {
|
||||
result.url = formatURL(result.url);
|
||||
result.sub_results = result.sub_results.map((sub_result) => {
|
||||
sub_result.url = formatURL(sub_result.url);
|
||||
return sub_result;
|
||||
});
|
||||
},
|
||||
});
|
||||
})();
|
||||
return loading;
|
||||
};
|
||||
for (const type of ['pointerenter', 'pointerdown', 'focus']) {
|
||||
openBtn.addEventListener(type, () => loadSearch(), { once: true, passive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('site-search', SiteSearch);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@layer starlight.core {
|
||||
site-search {
|
||||
display: contents;
|
||||
}
|
||||
button[data-open-modal] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
color: var(--sl-color-gray-1);
|
||||
cursor: pointer;
|
||||
height: 2.5rem;
|
||||
font-size: var(--sl-text-xl);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
button[data-open-modal] {
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
border-radius: 0.5rem;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 0.5rem;
|
||||
background-color: var(--sl-color-black);
|
||||
color: var(--sl-color-gray-2);
|
||||
font-size: var(--sl-text-sm);
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
}
|
||||
button[data-open-modal]:hover {
|
||||
border-color: var(--sl-color-gray-2);
|
||||
color: var(--sl-color-white);
|
||||
}
|
||||
|
||||
button[data-open-modal] > :last-child {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
}
|
||||
|
||||
button > kbd {
|
||||
border-radius: 0.25rem;
|
||||
font-size: var(--sl-text-2xs);
|
||||
gap: 0.25em;
|
||||
padding-inline: 0.375rem;
|
||||
background-color: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--__sl-font);
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: 0;
|
||||
background-color: var(--sl-color-gray-6);
|
||||
border: 1px solid var(--sl-color-gray-5);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
box-shadow: var(--sl-shadow-lg);
|
||||
}
|
||||
dialog[open] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background-color: var(--sl-color-backdrop-overlay);
|
||||
-webkit-backdrop-filter: blur(0.25rem);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
}
|
||||
|
||||
.dialog-frame {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
button[data-close-modal] {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
align-items: center;
|
||||
align-self: flex-end;
|
||||
height: calc(64px * var(--pagefind-ui-scale));
|
||||
padding: 0.25rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--sl-color-text-accent);
|
||||
}
|
||||
|
||||
#starlight__search {
|
||||
--pagefind-ui-primary: var(--sl-color-text);
|
||||
--pagefind-ui-text: var(--sl-color-gray-2);
|
||||
--pagefind-ui-font: var(--__sl-font);
|
||||
--pagefind-ui-background: var(--sl-color-black);
|
||||
--pagefind-ui-border: var(--sl-color-gray-5);
|
||||
--pagefind-ui-border-width: 1px;
|
||||
--pagefind-ui-tag: var(--sl-color-gray-5);
|
||||
--sl-search-cancel-space: 5rem;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] #starlight__search {
|
||||
--pagefind-ui-tag: var(--sl-color-gray-6);
|
||||
}
|
||||
|
||||
@media (min-width: 50rem) {
|
||||
#starlight__search {
|
||||
--sl-search-cancel-space: 0px;
|
||||
}
|
||||
|
||||
dialog {
|
||||
margin: 4rem auto auto;
|
||||
border-radius: 0.5rem;
|
||||
width: 90%;
|
||||
max-width: 40rem;
|
||||
height: max-content;
|
||||
min-height: 15rem;
|
||||
max-height: calc(100% - 8rem);
|
||||
}
|
||||
|
||||
.dialog-frame {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style is:global>
|
||||
@import url('@pagefind/default-ui/css/ui.css') layer(starlight.core);
|
||||
|
||||
@layer starlight.core {
|
||||
[data-search-modal-open] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#starlight__search {
|
||||
--sl-search-result-spacing: calc(1.25rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-inline-start: calc(3.75rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-inline-end: calc(1.25rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-pad-block: calc(0.9375rem * var(--pagefind-ui-scale));
|
||||
--sl-search-result-nested-pad-block: calc(0.625rem * var(--pagefind-ui-scale));
|
||||
--sl-search-corners: calc(0.3125rem * var(--pagefind-ui-scale));
|
||||
--sl-search-page-icon-size: calc(1.875rem * var(--pagefind-ui-scale));
|
||||
--sl-search-page-icon-inline-start: calc(
|
||||
(var(--sl-search-result-pad-inline-start) - var(--sl-search-page-icon-size)) / 2
|
||||
);
|
||||
--sl-search-tree-diagram-size: calc(2.5rem * var(--pagefind-ui-scale));
|
||||
--sl-search-tree-diagram-inline-start: calc(
|
||||
(var(--sl-search-result-pad-inline-start) - var(--sl-search-tree-diagram-size)) / 2
|
||||
);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__form::before {
|
||||
--pagefind-ui-text: var(--sl-color-gray-1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__search-input {
|
||||
color: var(--sl-color-white);
|
||||
font-weight: 400;
|
||||
width: calc(100% - var(--sl-search-cancel-space));
|
||||
}
|
||||
|
||||
#starlight__search input:focus {
|
||||
--pagefind-ui-border: var(--sl-color-accent);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__search-clear {
|
||||
inset-inline-end: var(--sl-search-cancel-space);
|
||||
width: calc(60px * var(--pagefind-ui-scale));
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
#starlight__search .pagefind-ui__search-clear:focus {
|
||||
outline: 1px solid var(--sl-color-accent);
|
||||
}
|
||||
#starlight__search .pagefind-ui__search-clear::before {
|
||||
content: '';
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
|
||||
center / 50% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m13.41 12 6.3-6.29a1 1 0 1 0-1.42-1.42L12 10.59l-6.29-6.3a1 1 0 0 0-1.42 1.42l6.3 6.29-6.3 6.29a1 1 0 0 0 .33 1.64 1 1 0 0 0 1.09-.22l6.29-6.3 6.29 6.3a1 1 0 0 0 1.64-.33 1 1 0 0 0-.22-1.09L13.41 12Z'/%3E%3C/svg%3E")
|
||||
center / 50% no-repeat;
|
||||
background-color: var(--sl-color-text-accent);
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__results > * + * {
|
||||
margin-top: var(--sl-search-result-spacing);
|
||||
}
|
||||
#starlight__search .pagefind-ui__result {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested {
|
||||
position: relative;
|
||||
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
padding-inline-start: var(--sl-search-result-pad-inline-start);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)),
|
||||
#starlight__search .pagefind-ui__result-nested {
|
||||
position: relative;
|
||||
background-color: var(--sl-color-black);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover,
|
||||
#starlight__search
|
||||
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
|
||||
#starlight__search .pagefind-ui__result-nested:hover,
|
||||
#starlight__search .pagefind-ui__result-nested:focus-within {
|
||||
outline: 1px solid var(--sl-color-accent-high);
|
||||
}
|
||||
|
||||
#starlight__search
|
||||
.pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within,
|
||||
#starlight__search .pagefind-ui__result-nested:focus-within {
|
||||
background-color: var(--sl-color-accent-low);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-thumb,
|
||||
#starlight__search .pagefind-ui__result-inner {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner > :first-child {
|
||||
border-radius: var(--sl-search-corners) var(--sl-search-corners) 0 0;
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-inner > :last-child {
|
||||
border-radius: 0 0 var(--sl-search-corners) var(--sl-search-corners);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title {
|
||||
padding: var(--sl-search-result-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
padding-inline-start: var(--sl-search-result-pad-inline-start);
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-inner > .pagefind-ui__result-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--sl-search-page-icon-inline-start);
|
||||
width: var(--sl-search-page-icon-size);
|
||||
background: var(--sl-color-gray-3);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
|
||||
center no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='currentColor' viewBox='0 0 24 24'%3E%3Cpath d='M9 10h1a1 1 0 1 0 0-2H9a1 1 0 0 0 0 2Zm0 2a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2H9Zm11-3V8l-6-6a1 1 0 0 0-1 0H7a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V9Zm-6-4 3 3h-2a1 1 0 0 1-1-1V5Zm4 14a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h5v3a3 3 0 0 0 3 3h3v9Zm-3-3H9a1 1 0 0 0 0 2h6a1 1 0 0 0 0-2Z'/%3E%3C/svg%3E")
|
||||
center no-repeat;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-inner {
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link {
|
||||
position: unset;
|
||||
--pagefind-ui-text: var(--sl-color-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested .pagefind-ui__result-link::before {
|
||||
content: unset;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-nested::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline-start: var(--sl-search-tree-diagram-inline-start);
|
||||
width: var(--sl-search-tree-diagram-size);
|
||||
background: var(--sl-color-gray-4);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
|
||||
0% 0% / 100% no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' viewBox='0 0 16 1000' preserveAspectRatio='xMinYMin slice'%3E%3Cpath d='M8 0v1000m6-988H8'/%3E%3C/svg%3E")
|
||||
0% 0% / 100% no-repeat;
|
||||
}
|
||||
#starlight__search .pagefind-ui__result-nested:last-of-type::before {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' viewBox='0 0 16 16'%3E%3Cpath d='M8 0v12m6 0H8'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* Flip page and tree icons around the vertical axis when in an RTL layout. */
|
||||
[dir='rtl'] .pagefind-ui__result-title::before,
|
||||
[dir='rtl'] .pagefind-ui__result-nested::before {
|
||||
transform: matrix(-1, 0, 0, 1, 0, 0);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-excerpt {
|
||||
font-size: calc(1rem * var(--pagefind-ui-scale));
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#starlight__search mark {
|
||||
color: var(--sl-color-gray-2);
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__filter-value::before {
|
||||
border-color: var(--sl-color-text-invert);
|
||||
}
|
||||
|
||||
#starlight__search .pagefind-ui__result-tags {
|
||||
background-color: var(--sl-color-black);
|
||||
margin-top: 0;
|
||||
padding: var(--sl-search-result-nested-pad-block) var(--sl-search-result-pad-inline-end);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
// Code that must read in one piece, marked when the page is built (the CSS
|
||||
// is retrobios.css, code.rb-short and table.rb-options).
|
||||
//
|
||||
// - Inline code of SHORT_CODE characters or fewer gets `rb-short` and never
|
||||
// breaks: `--restore` split into `--` and `restore` on the home page at
|
||||
// 1440 px, and `--list-` / `systems` in the installer's options table.
|
||||
// Longer inline code (a path, a full command) still wraps.
|
||||
// - A table whose first column is "Option" gets `rb-options`, so its flags
|
||||
// keep their column.
|
||||
//
|
||||
// Satteri runs hast plugins (the rehype stage of its pipeline): this one is
|
||||
// registered in astro.config.mjs through satteri({ hastPlugins }).
|
||||
|
||||
export const SHORT_CODE = 30;
|
||||
const OPTION_HEADER = /^options?$/i;
|
||||
|
||||
const classes = (node) => {
|
||||
const value = node.properties?.className;
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
return value ? String(value).split(/\s+/) : [];
|
||||
};
|
||||
|
||||
const addClass = (ctx, node, name) => {
|
||||
const present = classes(node);
|
||||
if (!present.includes(name)) ctx.setProperty(node, 'className', [...present, name]);
|
||||
};
|
||||
|
||||
const firstHeader = (ctx, table) => {
|
||||
for (const part of table.children ?? []) {
|
||||
if (part.type !== 'element' || part.tagName !== 'thead') continue;
|
||||
for (const row of part.children ?? []) {
|
||||
if (row.type !== 'element' || row.tagName !== 'tr') continue;
|
||||
const cell = (row.children ?? []).find((child) => child.type === 'element' && child.tagName === 'th');
|
||||
return cell ? ctx.textContent(cell).trim() : '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// A code element is inline unless a `pre` holds it.
|
||||
export const isShort = (text, inPre) => !inPre && text.length > 0 && text.length <= SHORT_CODE;
|
||||
|
||||
// The cards and tables the generator writes as HTML reach this step as raw
|
||||
// text: their code elements are marked by rewriting the tag. A block that
|
||||
// holds a `pre` is left as written.
|
||||
const RAW_CODE = /<code(\s[^>]*)?>([^<]*)<\/code>/g;
|
||||
const ENTITY = /&(?:#\d+|#x[0-9a-f]+|[a-z]+);/gi;
|
||||
|
||||
export const markRawCode = (html) => {
|
||||
if (html.includes('<pre')) return html;
|
||||
return html.replace(RAW_CODE, (whole, attributes = '', text) => {
|
||||
if (/\bclass=/.test(attributes) || !isShort(text.replace(ENTITY, '_'), false)) return whole;
|
||||
return `<code class="rb-short"${attributes}>${text}</code>`;
|
||||
});
|
||||
};
|
||||
|
||||
export default function shortCode() {
|
||||
return {
|
||||
name: 'rb-short-code',
|
||||
raw(node, ctx) {
|
||||
// Inline HTML in Markdown (a hash in a table cell) arrives in three
|
||||
// nodes: the opening tag, the text, the closing tag.
|
||||
const open = /^<code(\s[^>]*)?>$/.exec(node.value ?? '');
|
||||
if (open) {
|
||||
const siblings = ctx.parent(node)?.children ?? [];
|
||||
const index = ctx.indexOf(node) ?? -1;
|
||||
const [text, close] = [siblings[index + 1], siblings[index + 2]];
|
||||
if (text?.type === 'text' && close?.type === 'raw' && close.value === '</code>'
|
||||
&& !/\bclass=/.test(open[1] ?? '') && isShort(text.value, false)) {
|
||||
ctx.replaceNode(node, { type: 'raw', value: `<code class="rb-short"${open[1] ?? ''}>` });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const value = markRawCode(node.value ?? '');
|
||||
if (value !== node.value) ctx.replaceNode(node, { type: 'raw', value });
|
||||
},
|
||||
element: [
|
||||
{
|
||||
filter: ['code'],
|
||||
visit(code, ctx) {
|
||||
const parent = ctx.parent(code);
|
||||
const inPre = parent?.type === 'element' && parent.tagName === 'pre';
|
||||
if (isShort(ctx.textContent(code), inPre)) addClass(ctx, code, 'rb-short');
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: ['table'],
|
||||
visit(table, ctx) {
|
||||
if (OPTION_HEADER.test(firstHeader(ctx, table))) addClass(ctx, table, 'rb-options');
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
+115
-44
@@ -1,7 +1,8 @@
|
||||
// Progressive enhancement of the generated pages: table captions, filters
|
||||
// and keyboard scrolling, the hash lookup on files/ (a pasted hash or a file
|
||||
// hashed on this device), moved cards, a hash typed in the search, glossary
|
||||
// terms on a tap, the OS tabs kept in view, and the 404 page's hints.
|
||||
// terms on a tap, the OS tab of the visitor's system, the current page in
|
||||
// the sidebar, and the 404 page's hints.
|
||||
// Which tables stack on a phone is decided when the page is built
|
||||
// (src/plugins/rehype-stack-tables.mjs), so the layout is right on first paint.
|
||||
|
||||
@@ -262,7 +263,11 @@ const lookupValues = async (values: string[]): Promise<Item[]> => {
|
||||
return shards.flat().filter((item) => wanted.has(item.value));
|
||||
};
|
||||
|
||||
type Held = { name: string; path: string; sha1: string; size: number };
|
||||
type Held = { name: string; path: string; sha1: string; size: number; md5?: string; crc32?: string; sha256?: string };
|
||||
|
||||
// Every hash of a collection file, lower case.
|
||||
const heldHashes = (file: Held) =>
|
||||
[file.sha1, file.md5, file.crc32, file.sha256].filter((value): value is string => !!value).map((value) => value.toLowerCase());
|
||||
type Declared = { name?: string; destination?: string; required?: boolean; [hash: string]: unknown };
|
||||
type ProfileFile = { name?: string; region?: string | string[]; category?: string; [field: string]: unknown };
|
||||
type Reader = { name: string; files: ProfileFile[] };
|
||||
@@ -334,15 +339,21 @@ const heldFile = async (item: Item): Promise<Held | undefined> => {
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Whether an entry of a list declares one of *values*, under any of its
|
||||
// hash fields. Recalbox writes several MD5 in one field, separated by commas.
|
||||
const HASH_FIELDS = ['sha1', 'md5', 'crc32', 'sha256'];
|
||||
const declares = (entry: Record<string, unknown>, values: Set<string>) =>
|
||||
HASH_FIELDS.some((field) =>
|
||||
String(entry[field] ?? '').toLowerCase().split(',').some((hash) => values.has(hash.trim())));
|
||||
|
||||
// A setup's own entry for the value, from its contract in api/v1/platforms/:
|
||||
// where the file goes and whether the setup requires it. Recalbox writes
|
||||
// several MD5 in one field, separated by commas.
|
||||
const declaredEntry = async (item: Item): Promise<Declared | undefined> => {
|
||||
// where the file goes and whether the setup requires it. *values* are the
|
||||
// hashes of the file the value names.
|
||||
const declaredEntry = async (item: Item, values: Set<string>): Promise<Declared | undefined> => {
|
||||
const system = decodeURIComponent(new URL(item.url, siteRoot()).hash.slice(1));
|
||||
const data = await json(`api/v1/platforms/${item.owner}.json`);
|
||||
const files: Declared[] = data.items?.[0]?.contract?.systems?.[system]?.files ?? [];
|
||||
const holds = (entry: Declared) =>
|
||||
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value);
|
||||
const holds = (entry: Declared) => declares(entry, values);
|
||||
return files.find((entry) => holds(entry) && entry.name === item.name) ?? files.find(holds);
|
||||
};
|
||||
|
||||
@@ -355,9 +366,8 @@ const readerProfile = async (owner: string): Promise<Reader | undefined> => {
|
||||
};
|
||||
|
||||
// The entry of a profile that reads the value: by name and hash, else by hash.
|
||||
const readerEntry = (reader: Reader | undefined, item: Item): ProfileFile | undefined => {
|
||||
const holds = (entry: ProfileFile) =>
|
||||
String(entry[item.algorithm] ?? '').toLowerCase().split(',').map((hash) => hash.trim()).includes(item.value);
|
||||
const readerEntry = (reader: Reader | undefined, item: Item, values: Set<string>): ProfileFile | undefined => {
|
||||
const holds = (entry: ProfileFile) => declares(entry, values);
|
||||
return reader?.files.find((entry) => holds(entry) && entry.name === item.name) ?? reader?.files.find(holds);
|
||||
};
|
||||
|
||||
@@ -414,21 +424,38 @@ const distinct = (items: Item[], key: (item: Item) => string) => {
|
||||
// how many setups list it under which names. Then where the collection
|
||||
// holds it, the setups that list it (where it goes, whether it is
|
||||
// required), and the emulators that read it, by name.
|
||||
const renderMatches = async (status: HTMLElement, result: HTMLElement, items: Item[]) => {
|
||||
//
|
||||
// A setup or a profile declares one hash of a file, the one its list
|
||||
// writes. When the value matches a file of the collection, the lists are
|
||||
// read under every hash of that file, so the MD5, the SHA1, the CRC32 and
|
||||
// the SHA256 of one dump give one answer. Setups and emulators are counted
|
||||
// per file, by its SHA1.
|
||||
const renderMatches = async (status: HTMLElement, result: HTMLElement, found: Item[]) => {
|
||||
const held = distinct(found.filter((item) => item.layer === 'collection'), (item) => item.sha1 ?? item.url);
|
||||
const files = await Promise.all(held.map((item) => heldFile(item).catch(() => undefined)));
|
||||
const looked = new Set(found.map((item) => item.value));
|
||||
const others = [...new Set(files.flatMap((file) => (file ? heldHashes(file) : [])))].filter((value) => !looked.has(value));
|
||||
const items = others.length ? [...found, ...(await lookupValues(others).catch(() => [] as Item[]))] : found;
|
||||
// The file a declaration names: the SHA1 the record carries, else the
|
||||
// collection file one of whose hashes it declares.
|
||||
const fileOf = (item: Item) =>
|
||||
item.sha1 ?? files.find((file) => file && heldHashes(file).includes(item.value))?.sha1;
|
||||
const valuesOf = (item: Item) => {
|
||||
const file = files.find((each) => each && each.sha1 === fileOf(item));
|
||||
return new Set([item.value, ...(file ? heldHashes(file) : [])]);
|
||||
};
|
||||
const byLayer = (layer: string) => items.filter((item) => item.layer === layer);
|
||||
const held = distinct(byLayer('collection'), (item) => item.sha1 ?? item.url);
|
||||
const platforms = distinct(byLayer('platform'), (item) => `${item.owner}|${item.name}|${item.url}`);
|
||||
const emulators = distinct(byLayer('emulator'), (item) => `${item.owner}|${item.name}|${item.url}`);
|
||||
const owners = [...new Set(emulators.map((item) => item.owner ?? ''))];
|
||||
const [files, entries, names, readers] = await Promise.all([
|
||||
Promise.all(held.map((item) => heldFile(item).catch(() => undefined))),
|
||||
Promise.all(platforms.map((item) => declaredEntry(item).catch(() => undefined))),
|
||||
const [entries, names, readers] = await Promise.all([
|
||||
Promise.all(platforms.map((item) => declaredEntry(item, valuesOf(item)).catch(() => undefined))),
|
||||
json('data/picker/index.json').catch(() => []) as Promise<{ platform: string; name: string }[]>,
|
||||
Promise.all(owners.map((owner) => readerProfile(owner))),
|
||||
]);
|
||||
const setupName = new Map(names.map((entry) => [entry.platform, entry.name]));
|
||||
const reader = new Map(owners.map((owner, index) => [owner, readers[index]]));
|
||||
const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item));
|
||||
const readEntries = emulators.map((item) => readerEntry(reader.get(item.owner ?? ''), item, valuesOf(item)));
|
||||
const setups = new Set(platforms.map((item) => item.owner));
|
||||
|
||||
const counted = [
|
||||
@@ -452,8 +479,11 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, items: It
|
||||
const what: (Node | string)[] = system
|
||||
? [', ', link(item.url, system), category ? ` ${category}` : ' file']
|
||||
: category ? [`, a ${category} file`] : [];
|
||||
// The setups that list this file, whatever hash their list writes.
|
||||
const listing = platforms.filter((declared) => (fileOf(declared) ?? item.sha1) === item.sha1);
|
||||
const listers = new Set(listing.map((declared) => declared.owner));
|
||||
const others = new Map<string, Set<string>>();
|
||||
for (const declared of platforms) {
|
||||
for (const declared of listing) {
|
||||
const other = declared.name ?? '';
|
||||
if (anyName(other) || other === name) continue;
|
||||
if (!others.has(other)) others.set(other, new Set());
|
||||
@@ -466,17 +496,17 @@ const renderMatches = async (status: HTMLElement, result: HTMLElement, items: It
|
||||
if (position) named.push(position === otherNames.length - 1 ? ' and ' : ', ');
|
||||
named.push(code(other));
|
||||
});
|
||||
const listing: (Node | string)[] = setups.size
|
||||
const verdict: (Node | string)[] = listers.size
|
||||
? [
|
||||
` ${count(setups.size, 'setup lists', 'setups list')} it`,
|
||||
` ${count(listers.size, 'setup lists', 'setups list')} it`,
|
||||
...(renamed.size
|
||||
? [`, ${renamed.size.toLocaleString('en')} of them under the ${otherNames.length === 1 ? 'name' : 'names'} `, ...named]
|
||||
? [`, ${renamed.size.toLocaleString('en')} of them under ${otherNames.length === 1 ? 'another name' : 'other names'}: `, ...named]
|
||||
: []),
|
||||
'.',
|
||||
]
|
||||
: [];
|
||||
blocks.push(
|
||||
paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...listing),
|
||||
paragraph('This is ', code(name), ...what, ...(regions ? [` for ${regions}`] : []), '.', ...verdict),
|
||||
paragraph(
|
||||
...(file ? ['Held in the collection at ', code(file.path), '. SHA1 ', code(file.sha1), `, ${formatSize(file.size)}. `] : []),
|
||||
link(item.url, 'Open its card'),
|
||||
@@ -697,11 +727,11 @@ const hashLookupRow = (drawer: HTMLElement, value: string) => {
|
||||
|
||||
// Pagefind lists a page's title twice: once for the page and once for the
|
||||
// anchor of its h1 (#_top). The second title is hidden, its excerpt kept.
|
||||
// The `files`, `aliases` and `devices` fields weigh in the ranking and are
|
||||
// never shown: a result is not a list of 120 names. Pagefind reuses
|
||||
// The `files`, `aliases`, `devices`, `consoles` and `terms` fields weigh in
|
||||
// the ranking and are never shown: a result is not a list of 120 names. Pagefind reuses
|
||||
// its nodes from one query to the next, so both are worked out again on
|
||||
// every change.
|
||||
const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices']);
|
||||
const UNSHOWN_FIELDS = new Set(['files', 'aliases', 'devices', 'consoles', 'terms']);
|
||||
|
||||
const tidyResults = (root: Element) => {
|
||||
for (const title of root.querySelectorAll<HTMLElement>('.pagefind-ui__result-nested > .pagefind-ui__result-title')) {
|
||||
@@ -751,29 +781,69 @@ const hashSearch = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// The OS tabs scroll sideways on a phone instead of wrapping to a second
|
||||
// row (retrobios.css): the chosen one is kept in view, on load and on a
|
||||
// change, by scrolling the row and never the page.
|
||||
const keepTabInView = (tab: Element) => {
|
||||
const list = tab.closest('[role="tablist"]');
|
||||
if (!list) return;
|
||||
const row = list.getBoundingClientRect();
|
||||
const box = tab.getBoundingClientRect();
|
||||
if (box.left < row.left) list.scrollLeft -= row.left - box.left;
|
||||
else if (box.right > row.right) list.scrollLeft += box.right - row.right;
|
||||
// The OS tabs open on the visitor's own system: the tab its label names, on
|
||||
// every tab set of the page that shares the "os" sync key. A tab the
|
||||
// visitor chose before (Starlight keeps it under the sync key and restores
|
||||
// it as the page draws) wins over the guess, and the guess is not saved as
|
||||
// a choice. No tab is for iOS or ChromeOS: those keep the first tab.
|
||||
const OS_SYNC_KEY = 'os';
|
||||
const OS_TAB_RULES: [RegExp, string][] = [
|
||||
[/SteamOS|Valve Steam/i, 'Steam Deck'],
|
||||
[/Android/i, 'Android (Termux)'],
|
||||
[/iPhone|iPad|iPod|CrOS|Chrome OS/i, ''],
|
||||
[/Mac/i, 'macOS'],
|
||||
[/Win/i, 'Windows'],
|
||||
[/Linux|X11/i, 'Linux'],
|
||||
];
|
||||
|
||||
const osTabLabel = (userAgent: string, platform: string): string =>
|
||||
OS_TAB_RULES.find(([pattern]) => pattern.test(userAgent) || pattern.test(platform))?.[1] ?? '';
|
||||
// End of the OS tab rule.
|
||||
|
||||
type SyncedTabs = HTMLElement & { tabs: HTMLElement[]; switchTab(tab: HTMLElement, index: number, sync?: boolean): void };
|
||||
|
||||
const savedTab = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(`starlight-synced-tabs__${key}`);
|
||||
} catch {
|
||||
// Storage refused (a private window, blocked site data): no saved tab.
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const osTabs = () => {
|
||||
const selected = () => document.querySelectorAll('starlight-tabs [role="tab"][aria-selected="true"]').forEach(keepTabInView);
|
||||
if (!document.querySelector('starlight-tabs')) return;
|
||||
const later = () => requestAnimationFrame(selected);
|
||||
for (const type of ['click', 'keyup']) {
|
||||
document.addEventListener(type, (event) => {
|
||||
if ((event.target as Element | null)?.closest?.('starlight-tabs [role="tab"]')) later();
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'complete') later();
|
||||
else addEventListener('load', later, { once: true });
|
||||
const sets = [...document.querySelectorAll<SyncedTabs>(`starlight-tabs[data-sync-key="${OS_SYNC_KEY}"]`)];
|
||||
if (!sets.length || savedTab(OS_SYNC_KEY)) return;
|
||||
const agent = navigator as Navigator & { userAgentData?: { platform?: string } };
|
||||
const label = osTabLabel(navigator.userAgent, agent.userAgentData?.platform ?? '');
|
||||
if (!label) return;
|
||||
customElements.whenDefined('starlight-tabs').then(() => {
|
||||
for (const set of sets) {
|
||||
const index = set.tabs.findIndex((tab) => tab.textContent?.trim() === label);
|
||||
if (index > 0 && set.tabs[index].getAttribute('aria-selected') !== 'true') set.switchTab(set.tabs[index], index, false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// The sidebar lists 91 manufacturers: the current page's link sat below the
|
||||
// pane, out of sight. On load, and when the phone menu opens, the pane
|
||||
// scrolls so the link sits a third of the way down. Only the pane scrolls,
|
||||
// never the page.
|
||||
const showCurrentPage = () => {
|
||||
const pane = document.getElementById('starlight__sidebar');
|
||||
if (!pane) return;
|
||||
const reveal = () => {
|
||||
const current = pane.querySelector<HTMLElement>('[aria-current="page"]');
|
||||
if (!current) return;
|
||||
const box = pane.getBoundingClientRect();
|
||||
const link = current.getBoundingClientRect();
|
||||
if (link.top >= box.top && link.bottom <= box.bottom) return;
|
||||
pane.scrollTop += link.top - box.top - pane.clientHeight / 3;
|
||||
};
|
||||
reveal();
|
||||
pane.addEventListener('toggle', (event) => {
|
||||
if ((event as ToggleEvent).newState === 'open') requestAnimationFrame(reveal);
|
||||
});
|
||||
};
|
||||
|
||||
// A glossary term shows its definition on hover; a tap or Enter shows it
|
||||
@@ -825,4 +895,5 @@ enhanceTables();
|
||||
hashSearch();
|
||||
glossaryTerms();
|
||||
osTabs();
|
||||
showCurrentPage();
|
||||
notFound();
|
||||
@@ -57,7 +57,7 @@ Every module in `tests/`, by the area it guards.
|
||||
| Installer and downloads | loopback server | `test_check_release`, `test_download`, `test_install`, `test_install_cores`, `test_install_experience`, `test_install_from`, `test_install_offline`, `test_install_selection`, `test_install_tui` |
|
||||
| Handheld launchers | a pty as the console, fake installer and firmware tools, stubbed PortMaster dialogs | `test_handheld_launchers`, `test_port` |
|
||||
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r4_p0`, `test_site_r4_css`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_availability`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_emulator_page`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_picker`, `test_site_platform_hashes`, `test_site_r2_components`, `test_site_r2_css`, `test_site_r2_emulators`, `test_site_r2_entry`, `test_site_r2_gate`, `test_site_r2_nav`, `test_site_r2_p0`, `test_site_r2_reference`, `test_site_r2_systems`, `test_site_r2_wiki`, `test_site_r3_collection`, `test_site_r3_components`, `test_site_r3_css`, `test_site_r3_emulators`, `test_site_r3_entry`, `test_site_r3_nav`, `test_site_r3_p0`, `test_site_r3_reference`, `test_site_r3_systems`, `test_site_r3_wiki`, `test_site_r4_components`, `test_site_r4_css`, `test_site_r4_p0`, `test_site_render`, `test_site_system_links`, `test_site_validation`, `test_site_weight`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_workflow_paths` |
|
||||
|
||||
## Test architecture
|
||||
|
||||
|
||||
Reference in new issue
Block a user