diff --git a/src/BloomBrowserUI/Readium/reader.css b/src/BloomBrowserUI/Readium/reader.css index ef7d4e413110..665a92aeeec6 100644 --- a/src/BloomBrowserUI/Readium/reader.css +++ b/src/BloomBrowserUI/Readium/reader.css @@ -1,3 +1,1279 @@ -:root{-webkit-text-size-adjust:100%;zoom:reset}body{margin:0 auto}ul li,ol li{list-style:none}button{background:none;border:0;color:#5b5852;padding:.35rem .35rem .2rem;margin:0;-webkit-appearance:none}button:focus{background:none}a{color:#5b5852;text-decoration:none}.inactive{opacity:0;z-index:-3000}.active{opacity:1;z-index:3000}#viewer{padding:0 env(safe-area-inset-right) 0 env(safe-area-inset-left);box-sizing:border-box}#reader-info-bottom-flex{bottom:0px;position:fixed;width:100%;display:flex}#reader-info-bottom{bottom:0px;position:fixed;width:100%}.info{color:#5b5852;margin:0;padding:0 1.5rem;overflow:hidden;text-align:center;text-overflow:ellipsis;white-space:nowrap;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:default}.info.top{line-height:3;padding-top:env(safe-area-inset-top);min-height:3.6rem}.info.bottom{line-height:2;padding-bottom:env(safe-area-inset-bottom)}.info .chapter-position,.info .chapter-title,.info .remaining-positions{font-size:.85rem;font-variant-numeric:lining-nums tabular-nums}#iframe-wrapper iframe{border:none;overflow:auto;opacity:0}[data-viewer-theme=day]{background-color:#fff}[data-viewer-theme=sepia]{background-color:#faf4e8}[data-viewer-theme=sepia] .info{color:#5b5852}[data-viewer-theme=night]{background-color:#000;color:#fff}[data-viewer-theme=night] .info{color:#dadada}@-webkit-keyframes load{0%{transform:translate(-2.5rem, -2.5rem) rotate(0deg)}100%{transform:translate(-2.5rem, -2.5rem) rotate(360deg)}}@keyframes load{0%{transform:translate(-2.5rem, -2.5rem) rotate(0deg)}100%{transform:translate(-2.5rem, -2.5rem) rotate(360deg)}}.loading.is-loading .icon{animation:load 1s ease-in-out infinite}.loading{position:sticky;width:100%;height:100%;top:0;z-index:10;background-color:rgba(255,255,255,.9);color:#5b5852;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:default}.loading .icon{position:sticky;top:50%;left:50%;width:4rem;height:4rem;transform:translate(-50%, -50%);fill:#9e9e9e}[data-viewer-theme=sepia] .loading{background-color:rgba(250,244,232,.9)}[data-viewer-theme=night] .loading{background-color:#1a1a1a;color:#dadada}[data-viewer-theme=night] .loading .icon{fill:#dadada}.error{position:relative;z-index:20;background-color:rgba(255,255,255,.875);color:#d0343a;height:100%;top:0;padding-top:40vh;text-align:center;width:100%;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;cursor:default}.error .reader-icon{display:block;width:3rem;height:3rem;fill:#d0343a;margin:0 auto}.error span{display:block;margin-bottom:.75rem;font-size:1.2rem}.error button{color:#d0343a;border:1px solid #d0343a;border-radius:5px;font-size:1rem;font-weight:700;padding:.75rem 1rem}.error button+button{margin-left:1rem}[data-viewer-theme=sepia] .error{background-color:rgba(250,244,232,.875)}[data-viewer-theme=sepia] .error button{background-color:#fff;color:#d0343a;border:1px solid #d0343a}[data-viewer-theme=night] .error{background-color:rgba(0,0,0,.875)}[data-viewer-theme=night] .error button{background-color:#1a1a1a;color:#d0343a;border:1px solid #d0343a}.sidenav-toc{padding-inline-start:0px}.sidenav-toc .chapter-link{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidenav-toc .chapter-title{padding:0 16px;font-size:14px;color:#d3d3d3}.collapsible-header{border-bottom:1px solid rgba(0,0,0,.12)}.contents-view,.pageList-view,.landmarks-view{background-color:#fff;overflow:scroll;top:3.5rem;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.contents-view ol li,.contents-view ul li,.pageList-view ol li,.pageList-view ul li,.landmarks-view ol li,.landmarks-view ul li{margin-top:0}.contents-view ol li a,.contents-view ul li a,.pageList-view ol li a,.pageList-view ul li a,.landmarks-view ol li a,.landmarks-view ul li a{color:#5b5852;border-bottom:1px solid #ccc;display:block;padding:1rem;text-decoration:none;width:100%}@media screen and (min-width: 60rem){.contents-view ol li a:hover,.contents-view ul li a:hover,.pageList-view ol li a:hover,.pageList-view ul li a:hover,.landmarks-view ol li a:hover,.landmarks-view ul li a:hover{background:#e0e0e0;color:#111}}.contents-view ol li a.active,.contents-view ul li a.active,.pageList-view ol li a.active,.pageList-view ul li a.active,.landmarks-view ol li a.active,.landmarks-view ul li a.active{background:#dadada;color:#111}.contents-view ol li a.active:hover,.contents-view ul li a.active:hover,.pageList-view ol li a.active:hover,.pageList-view ul li a.active:hover,.landmarks-view ol li a.active:hover,.landmarks-view ul li a.active:hover{background:#5b5852;color:#dadada}.contents-view ol li span,.contents-view ul li span,.pageList-view ol li span,.pageList-view ul li span,.landmarks-view ol li span,.landmarks-view ul li span{color:#5b5852;border-bottom:1px solid #ccc;display:block;padding:1rem;text-decoration:none;width:100%}[data-viewer-theme=sepia] .contents-view,[data-viewer-theme=sepia] .pageList-view,[data-viewer-theme=sepia] .landmarks-view{background-color:#faf4e8}[data-viewer-theme=sepia] .contents-view ol li a,[data-viewer-theme=sepia] .contents-view ul li a,[data-viewer-theme=sepia] .pageList-view ol li a,[data-viewer-theme=sepia] .pageList-view ul li a,[data-viewer-theme=sepia] .landmarks-view ol li a,[data-viewer-theme=sepia] .landmarks-view ul li a{color:#5b5852;border-bottom:1px solid #e8cc94}@media screen and (min-width: 60rem){[data-viewer-theme=sepia] .contents-view ol li a:hover,[data-viewer-theme=sepia] .contents-view ul li a:hover,[data-viewer-theme=sepia] .pageList-view ol li a:hover,[data-viewer-theme=sepia] .pageList-view ul li a:hover,[data-viewer-theme=sepia] .landmarks-view ol li a:hover,[data-viewer-theme=sepia] .landmarks-view ul li a:hover{background:#e0e0e0;color:#faf4e8}}[data-viewer-theme=sepia] .contents-view ol li a.active,[data-viewer-theme=sepia] .contents-view ul li a.active,[data-viewer-theme=sepia] .pageList-view ol li a.active,[data-viewer-theme=sepia] .pageList-view ul li a.active,[data-viewer-theme=sepia] .landmarks-view ol li a.active,[data-viewer-theme=sepia] .landmarks-view ul li a.active{background:#dadada;color:#111}[data-viewer-theme=sepia] .contents-view ol li a.active:hover,[data-viewer-theme=sepia] .contents-view ul li a.active:hover,[data-viewer-theme=sepia] .pageList-view ol li a.active:hover,[data-viewer-theme=sepia] .pageList-view ul li a.active:hover,[data-viewer-theme=sepia] .landmarks-view ol li a.active:hover,[data-viewer-theme=sepia] .landmarks-view ul li a.active:hover{background:#5b5852;color:#dadada}[data-viewer-theme=sepia] .contents-view ol li span,[data-viewer-theme=sepia] .contents-view ul li span,[data-viewer-theme=sepia] .pageList-view ol li span,[data-viewer-theme=sepia] .pageList-view ul li span,[data-viewer-theme=sepia] .landmarks-view ol li span,[data-viewer-theme=sepia] .landmarks-view ul li span{color:#5b5852;border-bottom:1px solid #e8cc94}[data-viewer-theme=night] .contents-view,[data-viewer-theme=night] .pageList-view,[data-viewer-theme=night] .landmarks-view{background-color:#000}[data-viewer-theme=night] .contents-view ol li a,[data-viewer-theme=night] .contents-view ul li a,[data-viewer-theme=night] .pageList-view ol li a,[data-viewer-theme=night] .pageList-view ul li a,[data-viewer-theme=night] .landmarks-view ol li a,[data-viewer-theme=night] .landmarks-view ul li a{color:#5b5852;border-bottom:1px solid #ccc}@media screen and (min-width: 60rem){[data-viewer-theme=night] .contents-view ol li a:hover,[data-viewer-theme=night] .contents-view ul li a:hover,[data-viewer-theme=night] .pageList-view ol li a:hover,[data-viewer-theme=night] .pageList-view ul li a:hover,[data-viewer-theme=night] .landmarks-view ol li a:hover,[data-viewer-theme=night] .landmarks-view ul li a:hover{background:#e0e0e0;color:#fff}}[data-viewer-theme=night] .contents-view ol li a.active,[data-viewer-theme=night] .contents-view ul li a.active,[data-viewer-theme=night] .pageList-view ol li a.active,[data-viewer-theme=night] .pageList-view ul li a.active,[data-viewer-theme=night] .landmarks-view ol li a.active,[data-viewer-theme=night] .landmarks-view ul li a.active{background:#dadada;color:#000}[data-viewer-theme=night] .contents-view ol li a.active:hover,[data-viewer-theme=night] .contents-view ul li a.active:hover,[data-viewer-theme=night] .pageList-view ol li a.active:hover,[data-viewer-theme=night] .pageList-view ul li a.active:hover,[data-viewer-theme=night] .landmarks-view ol li a.active:hover,[data-viewer-theme=night] .landmarks-view ul li a.active:hover{background:#5b5852;color:#dadada}[data-viewer-theme=night] .contents-view ol li span,[data-viewer-theme=night] .contents-view ul li span,[data-viewer-theme=night] .pageList-view ol li span,[data-viewer-theme=night] .pageList-view ul li span,[data-viewer-theme=night] .landmarks-view ol li span,[data-viewer-theme=night] .landmarks-view ul li span{color:#dadada;border-bottom:1px solid #333}.settings-view>.settings-menu{outline:none}.settings-view>.settings-menu button{background-color:#fff;border:.0625rem solid #dadada;font-size:.75rem;letter-spacing:.07rem;padding:.5rem 1.5rem;text-transform:uppercase;margin:0;position:relative}.settings-view>.settings-menu button svg.checkedIcon{color:#fff;display:none;fill:#fff;height:.9rem;width:.9rem}.settings-view>.settings-menu button.active{color:#fff;background-color:#9e9e9e}.settings-view>.settings-menu button.active svg.checkedIcon{display:inline-block;margin:0;position:absolute;top:50%;transform:translateY(-55%);right:.35rem;vertical-align:text-bottom}.settings-view>.settings-menu>li{border:0;background-color:#fff;color:#5b5852;display:block;margin:.5rem 0;padding:0;text-align:center}.settings-view>.settings-menu .settings-options{padding-left:0;display:flex}.settings-view>.settings-menu .settings-options li{border:0;display:flex;margin:0;width:auto;flex:1 1 auto}.settings-view>.settings-menu .settings-options li button{width:100%;text-overflow:ellipsis}.settings-view>.settings-menu .settings-options button.publisher-font{border-top-right-radius:0;border-bottom-right-radius:0;border-left:0;border-right:0}.settings-view>.settings-menu .settings-options button.serif-font{border-radius:0}.settings-view>.settings-menu .settings-options button.sans-font{border-top-left-radius:0;border-bottom-left-radius:0;border-left:0;border-right:0}.settings-view>.settings-menu .settings-options button.decrease{border-top-right-radius:0;border-bottom-right-radius:0;border-left:0}.settings-view>.settings-menu .settings-options button.increase{border-top-left-radius:0;border-bottom-left-radius:0;border-left:0;border-right:0}.settings-view>.settings-menu .settings-options button.day-theme{background-color:#fff;border-top-right-radius:0;border-bottom-right-radius:0;border-left:0;border-right:0}.settings-view>.settings-menu .settings-options button.day-theme.active{color:#111}.settings-view>.settings-menu .settings-options button.day-theme.active svg.checkedIcon{fill:#111}.settings-view>.settings-menu .settings-options button.sepia-theme{background-color:#faf4e8;border-radius:0}.settings-view>.settings-menu .settings-options button.sepia-theme.active{color:#111}.settings-view>.settings-menu .settings-options button.sepia-theme.active svg.checkedIcon{fill:#111}.settings-view>.settings-menu .settings-options button.night-theme{background-color:#111;color:#fff;border-top-left-radius:0;border-bottom-left-radius:0;border-left:0;border-right:0}.settings-view>.settings-menu .settings-options button.night-theme.active{color:#fff}.settings-view>.settings-menu .settings-options button.night-theme.active svg.checkedIcon{fill:#fff}.settings-view>.settings-menu .settings-options button.columns-paginated-view{border-top-right-radius:0;border-bottom-right-radius:0;border-left:0}.settings-view>.settings-menu .settings-options button.scrolling-book-view{border-top-left-radius:0;border-bottom-left-radius:0;border-left:0;border-right:0}.settings-view>.settings-menu .settings-options .icon{color:#5b5852;fill:#5b5852;height:1.75rem;width:1.75rem}@media(min-width: 30rem){.settings-view>.settings-menu .settings-options .icon{height:1.5rem;width:1.5rem}}[data-viewer-theme=night] .settings-view>.settings-menu{outline:none}[data-viewer-theme=night] .settings-view>.settings-menu button{color:#5b5852}[data-viewer-theme=night] .settings-view>.settings-menu button svg.checkedIcon{color:#000;fill:#000}[data-viewer-theme=night] .settings-view>.settings-menu button.active{color:#fff;background-color:#9e9e9e}[data-viewer-theme=night] .settings-view>.settings-menu button.day-theme{background-color:#fff}[data-viewer-theme=night] .settings-view>.settings-menu button.sepia-theme{background-color:#faf4e8}[data-viewer-theme=night] .settings-view>.settings-menu button.night-theme{background-color:#111;color:#fff}[data-viewer-theme=night] .settings-view>.settings-menu>li{border:0;background-color:#1a1a1a;color:#111}[data-viewer-theme=sepia] .settings-view>.settings-menu{outline:none}[data-viewer-theme=sepia] .settings-view>.settings-menu button{color:#5b5852}[data-viewer-theme=sepia] .settings-view>.settings-menu button svg.checkedIcon{color:#faf4e8;fill:#faf4e8}[data-viewer-theme=sepia] .settings-view>.settings-menu button.active{color:#fff;background-color:#9e9e9e}[data-viewer-theme=sepia] .settings-view>.settings-menu button.day-theme{background-color:#fff}[data-viewer-theme=sepia] .settings-view>.settings-menu button.sepia-theme{background-color:#faf4e8}[data-viewer-theme=sepia] .settings-view>.settings-menu button.night-theme{background-color:#111;color:#fff}[data-viewer-theme=sepia] .settings-view>.settings-menu>li{border:0;background-color:#fff;color:#111}.bookmarks-view,.highlights-view{background-color:#fff;overflow:scroll;top:3.5rem;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.bookmarks-view ol li,.bookmarks-view ul li,.highlights-view ol li,.highlights-view ul li{margin-top:0}.bookmarks-view ol li a,.bookmarks-view ul li a,.highlights-view ol li a,.highlights-view ul li a{color:#5b5852;border-bottom:1px solid #ccc;display:block;padding:1rem;text-decoration:none;width:100%}@media screen and (min-width: 60rem){.bookmarks-view ol li a:hover,.bookmarks-view ul li a:hover,.highlights-view ol li a:hover,.highlights-view ul li a:hover{background:#e0e0e0;color:#111}}.bookmarks-view ol li a.active,.bookmarks-view ul li a.active,.highlights-view ol li a.active,.highlights-view ul li a.active{background:#dadada;color:#111}.bookmarks-view ol li a.active:hover,.bookmarks-view ul li a.active:hover,.highlights-view ol li a.active:hover,.highlights-view ul li a.active:hover{background:#5b5852;color:#dadada}[data-viewer-theme=sepia] .bookmarks-view,[data-viewer-theme=sepia] .highlights-view{background-color:#faf4e8}[data-viewer-theme=sepia] .bookmarks-view ol li a,[data-viewer-theme=sepia] .bookmarks-view ul li a,[data-viewer-theme=sepia] .highlights-view ol li a,[data-viewer-theme=sepia] .highlights-view ul li a{color:#5b5852;border-bottom:1px solid #e8cc94}@media screen and (min-width: 60rem){[data-viewer-theme=sepia] .bookmarks-view ol li a:hover,[data-viewer-theme=sepia] .bookmarks-view ul li a:hover,[data-viewer-theme=sepia] .highlights-view ol li a:hover,[data-viewer-theme=sepia] .highlights-view ul li a:hover{background:#e0e0e0;color:#faf4e8}}[data-viewer-theme=sepia] .bookmarks-view ol li a.active,[data-viewer-theme=sepia] .bookmarks-view ul li a.active,[data-viewer-theme=sepia] .highlights-view ol li a.active,[data-viewer-theme=sepia] .highlights-view ul li a.active{background:#dadada;color:#111}[data-viewer-theme=sepia] .bookmarks-view ol li a.active:hover,[data-viewer-theme=sepia] .bookmarks-view ul li a.active:hover,[data-viewer-theme=sepia] .highlights-view ol li a.active:hover,[data-viewer-theme=sepia] .highlights-view ul li a.active:hover{background:#111;color:#dadada}[data-viewer-theme=sepia] .bookmarks-view ol li span,[data-viewer-theme=sepia] .bookmarks-view ul li span,[data-viewer-theme=sepia] .highlights-view ol li span,[data-viewer-theme=sepia] .highlights-view ul li span{color:#5b5852}[data-viewer-theme=night] .bookmarks-view,[data-viewer-theme=night] .highlights-view{background-color:#000}[data-viewer-theme=night] .bookmarks-view ol li a,[data-viewer-theme=night] .bookmarks-view ul li a,[data-viewer-theme=night] .highlights-view ol li a,[data-viewer-theme=night] .highlights-view ul li a{color:#5b5852;border-bottom:1px solid #ccc}@media screen and (min-width: 60rem){[data-viewer-theme=night] .bookmarks-view ol li a:hover,[data-viewer-theme=night] .bookmarks-view ul li a:hover,[data-viewer-theme=night] .highlights-view ol li a:hover,[data-viewer-theme=night] .highlights-view ul li a:hover{background:#e0e0e0;color:#fff}}[data-viewer-theme=night] .bookmarks-view ol li a.active,[data-viewer-theme=night] .bookmarks-view ul li a.active,[data-viewer-theme=night] .highlights-view ol li a.active,[data-viewer-theme=night] .highlights-view ul li a.active{background:#dadada;color:#000}[data-viewer-theme=night] .bookmarks-view ol li a.active:hover,[data-viewer-theme=night] .bookmarks-view ul li a.active:hover,[data-viewer-theme=night] .highlights-view ol li a.active:hover,[data-viewer-theme=night] .highlights-view ul li a.active:hover{background:#5b5852;color:#dadada}[data-viewer-theme=night] .bookmarks-view ol li span,[data-viewer-theme=night] .bookmarks-view ul li span,[data-viewer-theme=night] .highlights-view ol li span,[data-viewer-theme=night] .highlights-view ul li span{color:#dadada;border-bottom:1px solid #333}:root{--RS__highlightColor: rgba(255, 255, 0, 0.5)}.sidenav-annotations{padding-inline-start:0px}.sidenav-annotations .chapter-link{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sidenav-annotations .chapter-title{padding:0 16px;font-size:14px;color:#d3d3d3}.sidenav-annotations ol{padding-left:24px}.sidenav-annotations li{position:relative}.sidenav-annotations li .delete{position:absolute;right:10px;top:50%;transform:translate(0, -50%)}.sidenav-annotations li .bookmark-link,.sidenav-annotations li .highlight-link{display:block;position:relative;padding-top:8px !important;padding-bottom:8px !important;line-height:1.25}.sidenav-annotations li .bookmark-link svg,.sidenav-annotations li .bookmark-link i,.sidenav-annotations li .highlight-link svg,.sidenav-annotations li .highlight-link i{left:4px;top:8px;position:absolute}.sidenav-annotations li .bookmark-link .title,.sidenav-annotations li .highlight-link .title{display:block}.sidenav-annotations li .bookmark-link .subtitle,.sidenav-annotations li .highlight-link .subtitle{display:block;line-height:20px;font-size:10px}.sidenav-annotations li .bookmark-link .timestamp,.sidenav-annotations li .highlight-link .timestamp{display:block;line-height:20px;font-size:8px}.highlight-toolbox{transform:translate(-50%, -100%);animation:toolsAnimateIn 100ms ease-out both;transform-origin:bottom;z-index:999;position:relative;background:#dcdcdc;display:none;width:fit-content}.highlight-toolbox:before{content:"";position:absolute;width:.4rem;height:.4rem;bottom:-0.2rem;left:calc(50% - 0.2rem);background-color:inherit;transform:rotate(45deg)}.highlight-toolbox>div>button{display:inline-block}.color-option span{display:inline-block;border-radius:50%;width:24px;height:24px;background:gray}@keyframes toolsAnimateIn{from{opacity:0;transform:translate(-50%, -50%) scale(0.5)}}.thumb{border-radius:50% 50% 50% 0 !important;height:30px !important;width:30px !important;margin-left:-15px !important;top:-20px !important;left:50%}input[type=range]+.thumb .value{margin-top:-20px;font-size:10px;right:22px;position:absolute}input[type=checkbox] label{display:flex;flex-direction:row;align-items:center}input[type=checkbox]{position:relative !important;appearance:none;box-sizing:content-box;overflow:hidden}input[type=checkbox]:before{content:"";display:block;box-sizing:content-box;width:16px;height:16px;border:2px solid #ccc;transition:.2s border-color ease}input[type=checkbox]:checked:before{border-color:#101010;transition:.5s border-color ease}input[type=checkbox]:disabled:before{border-color:#ccc;background-color:#ccc}input[type=checkbox]:after{content:"";display:block;position:absolute;box-sizing:content-box;top:50%;left:50%;transform-origin:50% 50%;background-color:#101010;width:16px;height:16px;border-radius:100vh;transform:translate(-50%, -50%) scale(0)}input[type=checkbox][type=radio]:before{border-radius:100vh}input[type=checkbox][type=radio]:after{width:16px;height:16px;border-radius:100vh;transform:translate(-50%, -50%) scale(0)}input[type=checkbox][type=radio]:checked:after{animation:toggleOnRadio .2s ease forwards}input[type=checkbox][type=checkbox]:before{border-radius:4px}input[type=checkbox][type=checkbox]:after{width:9.6px;height:16px;border-radius:0;transform:translate(-50%, -85%) scale(0) rotate(45deg);background-color:rgba(0,0,0,0);box-shadow:4px 4px 0px 0px #101010}input[type=checkbox][type=checkbox]:checked:after{animation:toggleOnCheckbox .2s ease forwards}input[type=checkbox][type=checkbox].filled:before{border-radius:4px;transition:.2s border-color ease,.2s background-color ease}input[type=checkbox][type=checkbox].filled:checked:not(:disabled):before{background-color:#101010}input[type=checkbox][type=checkbox].filled:not(:disabled):after{box-shadow:4px 4px 0px 0px #fff}@keyframes toggleOnCheckbox{0%{opacity:0;transform:translate(-50%, -85%) scale(0) rotate(45deg)}70%{opacity:1;transform:translate(-50%, -85%) scale(0.9) rotate(45deg)}100%{transform:translate(-50%, -85%) scale(0.8) rotate(45deg)}}@keyframes toggleOnRadio{0%{opacity:0;transform:translate(-50%, -50%) scale(0)}70%{opacity:1;transform:translate(-50%, -50%) scale(0.9)}100%{transform:translate(-50%, -50%) scale(0.8)}}.range-slider{width:100%}.range-slider__range{-webkit-appearance:none;width:calc(100% - (73px));height:10px;border-radius:5px;background:#d7dcdf;outline:none;padding:0;margin:0}.range-slider__range::-webkit-slider-thumb{appearance:none;width:20px;height:20px;border-radius:50%;background:#2c3e50;cursor:pointer;transition:.15s ease-in-out}.range-slider__range::-webkit-slider-thumb:hover{background:#1abc9c}.range-slider__range:active::-webkit-slider-thumb{background:#1abc9c}.range-slider__range::-moz-range-thumb{width:20px;height:20px;border:0;border-radius:50%;background:#2c3e50;cursor:pointer;transition:.15s ease-in-out}.range-slider__range::-moz-range-thumb:hover{background:#1abc9c}.range-slider__range:active::-moz-range-thumb{background:#1abc9c}.range-slider__range:focus::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 6px #1abc9c}.range-slider__range_settings{-webkit-appearance:none;width:100%;height:10px;border-radius:5px;background:#d7dcdf;outline:none;padding:0;margin:0}.range-slider__range_settings::-webkit-slider-thumb{appearance:none;width:20px;height:20px;border-radius:50%;background:#2c3e50;cursor:pointer;transition:.15s ease-in-out}.range-slider__range_settings::-webkit-slider-thumb:hover{background:#1abc9c}.range-slider__range_settings:active::-webkit-slider-thumb{background:#1abc9c}.range-slider__range_settings::-moz-range-thumb{width:20px;height:20px;border:0;border-radius:50%;background:#2c3e50;cursor:pointer;transition:.15s ease-in-out}.range-slider__range_settings::-moz-range-thumb:hover{background:#1abc9c}.range-slider__range_settings:active::-moz-range-thumb{background:#1abc9c}.range-slider__range_settings:focus::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 6px #1abc9c}.range-slider__value{display:inline-block;position:relative;width:60px;color:#fff;line-height:20px;text-align:center;border-radius:3px;background:#2c3e50;padding:5px 10px;margin-left:8px}.range-slider__value:after{position:absolute;top:8px;left:-7px;width:0;height:0;border-top:7px solid rgba(0,0,0,0);border-right:7px solid #2c3e50;border-bottom:7px solid rgba(0,0,0,0);content:""}::-moz-range-track{background:#d7dcdf;border:0}input::-moz-focus-inner,input::-moz-focus-outer{border:0}@media only screen and (max-width: 600px){.timeline,.scrubber>input{display:none}}.scrubber{flex-grow:1;padding-right:20px;padding-left:20px}.timeline{position:fixed;top:1.5rem;left:2.5rem;bottom:1.5rem;width:1.25rem;display:flex;flex-direction:column}.timeline .chapter{border-left:6px solid rgba(0,0,0,0);transition:border-color 300ms ease-out;background:#dadada;border:1px solid #fff;border-radius:5px;position:relative}.timeline .chapter:hover{background-color:#dadada}.timeline .chapter:hover .chapter-tooltip{display:block}.timeline .chapter.active{background-color:#9e9e9e}.timeline .chapter-tooltip{display:none;position:absolute;left:2rem;top:50%;transform:translate(0, -50%);background:#dadada;padding:.25rem .5rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:22rem}[data-viewer-theme=night] .timeline .chapter{border:1px solid #111}[data-viewer-theme=sepia] .timeline .chapter{border:1px solid #faf4e8}.collection{border-radius:2px;overflow:hidden;position:relative}.collection .collection-item{padding:10px 20px;margin:0}.collection .collection-item.avatar{min-height:84px;padding-left:72px;position:relative}.collection .collection-item.avatar:not(.circle-clipper)>.circle,.collection .collection-item.avatar :not(.circle-clipper)>.circle{position:absolute;width:42px;height:42px;overflow:hidden;left:15px;display:inline-block;vertical-align:middle}.collection .collection-item.avatar i.circle{font-size:18px;line-height:42px;color:#fff;background-color:#999;text-align:center}.collection .collection-item.avatar .title{font-size:16px}.collection .collection-item.avatar p{margin:0}.collection .collection-item.avatar .secondary-content{position:absolute;top:16px;right:16px}.collection .collection-item:last-child{border-bottom:none}.collection .collection-item.active .secondary-content{color:#fff}.collection a.collection-item{display:block;transition:.25s}.collection.with-header .collection-header{padding:10px 20px}.collection.with-header .collection-item{padding-left:30px}.collection.with-header .collection-item.avatar{padding-left:72px}.pagination{display:inline-block}.pagination li{display:inline-block;border-radius:2px;text-align:center;vertical-align:top;height:30px;clear:unset !important;width:unset !important}.pagination li a{color:#444;display:inline-block !important;font-size:1.2rem;padding:0 10px;line-height:30px}.pagination li.active a{color:#999}.pagination li.disabled a{cursor:default;color:#999}.pagination li i{font-size:2rem}.pagination li.pages ul li{display:inline-block;float:none}.search-wrapper{display:flex;align-items:center}.sidenav.expanded{width:992px !important}@media only screen and (max-width : 992px){.sidenav.expanded{width:100% !important}}.logo-container i.editAnnotations{transform:rotate(-90deg);margin-top:12px;font-size:32px;margin-right:6px;float:right}.grab-to-pan-grab{cursor:grab !important}.grab-to-pan-grab *:not(input):not(textarea):not(button):not(select):not(:link){cursor:inherit !important}.grab-to-pan-grab:active,.grab-to-pan-grabbing{cursor:grabbing !important} +:root { + -webkit-text-size-adjust: 100%; + zoom: reset; +} +body { + margin: 0 auto; +} +ul li, +ol li { + list-style: none; +} +button { + background: none; + border: 0; + color: #5b5852; + padding: 0.35rem 0.35rem 0.2rem; + margin: 0; + -webkit-appearance: none; +} +button:focus { + background: none; +} +a { + color: #5b5852; + text-decoration: none; +} +.inactive { + opacity: 0; + z-index: -3000; +} +.active { + opacity: 1; + z-index: 3000; +} +#viewer { + padding: 0 env(safe-area-inset-right) 0 env(safe-area-inset-left); + box-sizing: border-box; +} +#reader-info-bottom-flex { + bottom: 0px; + position: fixed; + width: 100%; + display: flex; +} +#reader-info-bottom { + bottom: 0px; + position: fixed; + width: 100%; +} +.info { + color: #5b5852; + margin: 0; + padding: 0 1.5rem; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default; +} +.info.top { + line-height: 3; + padding-top: env(safe-area-inset-top); + min-height: 3.6rem; +} +.info.bottom { + line-height: 2; + padding-bottom: env(safe-area-inset-bottom); +} +.info .chapter-position, +.info .chapter-title, +.info .remaining-positions { + font-size: 0.85rem; + font-variant-numeric: lining-nums tabular-nums; +} +#iframe-wrapper iframe { + border: none; + overflow: auto; + opacity: 0; +} +[data-viewer-theme="day"] { + background-color: #fff; +} +[data-viewer-theme="sepia"] { + background-color: #faf4e8; +} +[data-viewer-theme="sepia"] .info { + color: #5b5852; +} +[data-viewer-theme="night"] { + background-color: #000; + color: #fff; +} +[data-viewer-theme="night"] .info { + color: #dadada; +} +@-webkit-keyframes load { + 0% { + transform: translate(-2.5rem, -2.5rem) rotate(0deg); + } + 100% { + transform: translate(-2.5rem, -2.5rem) rotate(360deg); + } +} +@keyframes load { + 0% { + transform: translate(-2.5rem, -2.5rem) rotate(0deg); + } + 100% { + transform: translate(-2.5rem, -2.5rem) rotate(360deg); + } +} +.loading.is-loading .icon { + animation: load 1s ease-in-out infinite; +} +.loading { + position: sticky; + width: 100%; + height: 100%; + top: 0; + z-index: 10; + background-color: rgba(255, 255, 255, 0.9); + color: #5b5852; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default; +} +.loading .icon { + position: sticky; + top: 50%; + left: 50%; + width: 4rem; + height: 4rem; + transform: translate(-50%, -50%); + fill: #9e9e9e; +} +[data-viewer-theme="sepia"] .loading { + background-color: rgba(250, 244, 232, 0.9); +} +[data-viewer-theme="night"] .loading { + background-color: #1a1a1a; + color: #dadada; +} +[data-viewer-theme="night"] .loading .icon { + fill: #dadada; +} +.error { + position: relative; + z-index: 20; + background-color: rgba(255, 255, 255, 0.875); + color: #d0343a; + height: 100%; + top: 0; + padding-top: 40vh; + text-align: center; + width: 100%; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + cursor: default; +} +.error .reader-icon { + display: block; + width: 3rem; + height: 3rem; + fill: #d0343a; + margin: 0 auto; +} +.error span { + display: block; + margin-bottom: 0.75rem; + font-size: 1.2rem; +} +.error button { + color: #d0343a; + border: 1px solid #d0343a; + border-radius: 5px; + font-size: 1rem; + font-weight: 700; + padding: 0.75rem 1rem; +} +.error button + button { + margin-left: 1rem; +} +[data-viewer-theme="sepia"] .error { + background-color: rgba(250, 244, 232, 0.875); +} +[data-viewer-theme="sepia"] .error button { + background-color: #fff; + color: #d0343a; + border: 1px solid #d0343a; +} +[data-viewer-theme="night"] .error { + background-color: rgba(0, 0, 0, 0.875); +} +[data-viewer-theme="night"] .error button { + background-color: #1a1a1a; + color: #d0343a; + border: 1px solid #d0343a; +} +.sidenav-toc { + padding-inline-start: 0px; +} +.sidenav-toc .chapter-link { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidenav-toc .chapter-title { + padding: 0 16px; + font-size: 14px; + color: #d3d3d3; +} +.collapsible-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.12); +} +.contents-view, +.pageList-view, +.landmarks-view { + background-color: #fff; + overflow: scroll; + top: 3.5rem; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.contents-view ol li, +.contents-view ul li, +.pageList-view ol li, +.pageList-view ul li, +.landmarks-view ol li, +.landmarks-view ul li { + margin-top: 0; +} +.contents-view ol li a, +.contents-view ul li a, +.pageList-view ol li a, +.pageList-view ul li a, +.landmarks-view ol li a, +.landmarks-view ul li a { + color: #5b5852; + border-bottom: 1px solid #ccc; + display: block; + padding: 1rem; + text-decoration: none; + width: 100%; +} +@media screen and (min-width: 60rem) { + .contents-view ol li a:hover, + .contents-view ul li a:hover, + .pageList-view ol li a:hover, + .pageList-view ul li a:hover, + .landmarks-view ol li a:hover, + .landmarks-view ul li a:hover { + background: #e0e0e0; + color: #111; + } +} +.contents-view ol li a.active, +.contents-view ul li a.active, +.pageList-view ol li a.active, +.pageList-view ul li a.active, +.landmarks-view ol li a.active, +.landmarks-view ul li a.active { + background: #dadada; + color: #111; +} +.contents-view ol li a.active:hover, +.contents-view ul li a.active:hover, +.pageList-view ol li a.active:hover, +.pageList-view ul li a.active:hover, +.landmarks-view ol li a.active:hover, +.landmarks-view ul li a.active:hover { + background: #5b5852; + color: #dadada; +} +.contents-view ol li span, +.contents-view ul li span, +.pageList-view ol li span, +.pageList-view ul li span, +.landmarks-view ol li span, +.landmarks-view ul li span { + color: #5b5852; + border-bottom: 1px solid #ccc; + display: block; + padding: 1rem; + text-decoration: none; + width: 100%; +} +[data-viewer-theme="sepia"] .contents-view, +[data-viewer-theme="sepia"] .pageList-view, +[data-viewer-theme="sepia"] .landmarks-view { + background-color: #faf4e8; +} +[data-viewer-theme="sepia"] .contents-view ol li a, +[data-viewer-theme="sepia"] .contents-view ul li a, +[data-viewer-theme="sepia"] .pageList-view ol li a, +[data-viewer-theme="sepia"] .pageList-view ul li a, +[data-viewer-theme="sepia"] .landmarks-view ol li a, +[data-viewer-theme="sepia"] .landmarks-view ul li a { + color: #5b5852; + border-bottom: 1px solid #e8cc94; +} +@media screen and (min-width: 60rem) { + [data-viewer-theme="sepia"] .contents-view ol li a:hover, + [data-viewer-theme="sepia"] .contents-view ul li a:hover, + [data-viewer-theme="sepia"] .pageList-view ol li a:hover, + [data-viewer-theme="sepia"] .pageList-view ul li a:hover, + [data-viewer-theme="sepia"] .landmarks-view ol li a:hover, + [data-viewer-theme="sepia"] .landmarks-view ul li a:hover { + background: #e0e0e0; + color: #faf4e8; + } +} +[data-viewer-theme="sepia"] .contents-view ol li a.active, +[data-viewer-theme="sepia"] .contents-view ul li a.active, +[data-viewer-theme="sepia"] .pageList-view ol li a.active, +[data-viewer-theme="sepia"] .pageList-view ul li a.active, +[data-viewer-theme="sepia"] .landmarks-view ol li a.active, +[data-viewer-theme="sepia"] .landmarks-view ul li a.active { + background: #dadada; + color: #111; +} +[data-viewer-theme="sepia"] .contents-view ol li a.active:hover, +[data-viewer-theme="sepia"] .contents-view ul li a.active:hover, +[data-viewer-theme="sepia"] .pageList-view ol li a.active:hover, +[data-viewer-theme="sepia"] .pageList-view ul li a.active:hover, +[data-viewer-theme="sepia"] .landmarks-view ol li a.active:hover, +[data-viewer-theme="sepia"] .landmarks-view ul li a.active:hover { + background: #5b5852; + color: #dadada; +} +[data-viewer-theme="sepia"] .contents-view ol li span, +[data-viewer-theme="sepia"] .contents-view ul li span, +[data-viewer-theme="sepia"] .pageList-view ol li span, +[data-viewer-theme="sepia"] .pageList-view ul li span, +[data-viewer-theme="sepia"] .landmarks-view ol li span, +[data-viewer-theme="sepia"] .landmarks-view ul li span { + color: #5b5852; + border-bottom: 1px solid #e8cc94; +} +[data-viewer-theme="night"] .contents-view, +[data-viewer-theme="night"] .pageList-view, +[data-viewer-theme="night"] .landmarks-view { + background-color: #000; +} +[data-viewer-theme="night"] .contents-view ol li a, +[data-viewer-theme="night"] .contents-view ul li a, +[data-viewer-theme="night"] .pageList-view ol li a, +[data-viewer-theme="night"] .pageList-view ul li a, +[data-viewer-theme="night"] .landmarks-view ol li a, +[data-viewer-theme="night"] .landmarks-view ul li a { + color: #5b5852; + border-bottom: 1px solid #ccc; +} +@media screen and (min-width: 60rem) { + [data-viewer-theme="night"] .contents-view ol li a:hover, + [data-viewer-theme="night"] .contents-view ul li a:hover, + [data-viewer-theme="night"] .pageList-view ol li a:hover, + [data-viewer-theme="night"] .pageList-view ul li a:hover, + [data-viewer-theme="night"] .landmarks-view ol li a:hover, + [data-viewer-theme="night"] .landmarks-view ul li a:hover { + background: #e0e0e0; + color: #fff; + } +} +[data-viewer-theme="night"] .contents-view ol li a.active, +[data-viewer-theme="night"] .contents-view ul li a.active, +[data-viewer-theme="night"] .pageList-view ol li a.active, +[data-viewer-theme="night"] .pageList-view ul li a.active, +[data-viewer-theme="night"] .landmarks-view ol li a.active, +[data-viewer-theme="night"] .landmarks-view ul li a.active { + background: #dadada; + color: #000; +} +[data-viewer-theme="night"] .contents-view ol li a.active:hover, +[data-viewer-theme="night"] .contents-view ul li a.active:hover, +[data-viewer-theme="night"] .pageList-view ol li a.active:hover, +[data-viewer-theme="night"] .pageList-view ul li a.active:hover, +[data-viewer-theme="night"] .landmarks-view ol li a.active:hover, +[data-viewer-theme="night"] .landmarks-view ul li a.active:hover { + background: #5b5852; + color: #dadada; +} +[data-viewer-theme="night"] .contents-view ol li span, +[data-viewer-theme="night"] .contents-view ul li span, +[data-viewer-theme="night"] .pageList-view ol li span, +[data-viewer-theme="night"] .pageList-view ul li span, +[data-viewer-theme="night"] .landmarks-view ol li span, +[data-viewer-theme="night"] .landmarks-view ul li span { + color: #dadada; + border-bottom: 1px solid #333; +} +.settings-view > .settings-menu { + outline: none; +} +.settings-view > .settings-menu button { + background-color: #fff; + border: 0.0625rem solid #dadada; + font-size: 0.75rem; + letter-spacing: 0.07rem; + padding: 0.5rem 1.5rem; + text-transform: uppercase; + margin: 0; + position: relative; +} +.settings-view > .settings-menu button svg.checkedIcon { + color: #fff; + display: none; + fill: #fff; + height: 0.9rem; + width: 0.9rem; +} +.settings-view > .settings-menu button.active { + color: #fff; + background-color: #9e9e9e; +} +.settings-view > .settings-menu button.active svg.checkedIcon { + display: inline-block; + margin: 0; + position: absolute; + top: 50%; + transform: translateY(-55%); + right: 0.35rem; + vertical-align: text-bottom; +} +.settings-view > .settings-menu > li { + border: 0; + background-color: #fff; + color: #5b5852; + display: block; + margin: 0.5rem 0; + padding: 0; + text-align: center; +} +.settings-view > .settings-menu .settings-options { + padding-left: 0; + display: flex; +} +.settings-view > .settings-menu .settings-options li { + border: 0; + display: flex; + margin: 0; + width: auto; + flex: 1 1 auto; +} +.settings-view > .settings-menu .settings-options li button { + width: 100%; + text-overflow: ellipsis; +} +.settings-view > .settings-menu .settings-options button.publisher-font { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 0; + border-right: 0; +} +.settings-view > .settings-menu .settings-options button.serif-font { + border-radius: 0; +} +.settings-view > .settings-menu .settings-options button.sans-font { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + border-right: 0; +} +.settings-view > .settings-menu .settings-options button.decrease { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 0; +} +.settings-view > .settings-menu .settings-options button.increase { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + border-right: 0; +} +.settings-view > .settings-menu .settings-options button.day-theme { + background-color: #fff; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 0; + border-right: 0; +} +.settings-view > .settings-menu .settings-options button.day-theme.active { + color: #111; +} +.settings-view + > .settings-menu + .settings-options + button.day-theme.active + svg.checkedIcon { + fill: #111; +} +.settings-view > .settings-menu .settings-options button.sepia-theme { + background-color: #faf4e8; + border-radius: 0; +} +.settings-view > .settings-menu .settings-options button.sepia-theme.active { + color: #111; +} +.settings-view + > .settings-menu + .settings-options + button.sepia-theme.active + svg.checkedIcon { + fill: #111; +} +.settings-view > .settings-menu .settings-options button.night-theme { + background-color: #111; + color: #fff; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + border-right: 0; +} +.settings-view > .settings-menu .settings-options button.night-theme.active { + color: #fff; +} +.settings-view + > .settings-menu + .settings-options + button.night-theme.active + svg.checkedIcon { + fill: #fff; +} +.settings-view + > .settings-menu + .settings-options + button.columns-paginated-view { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-left: 0; +} +.settings-view > .settings-menu .settings-options button.scrolling-book-view { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: 0; + border-right: 0; +} +.settings-view > .settings-menu .settings-options .icon { + color: #5b5852; + fill: #5b5852; + height: 1.75rem; + width: 1.75rem; +} +@media (min-width: 30rem) { + .settings-view > .settings-menu .settings-options .icon { + height: 1.5rem; + width: 1.5rem; + } +} +[data-viewer-theme="night"] .settings-view > .settings-menu { + outline: none; +} +[data-viewer-theme="night"] .settings-view > .settings-menu button { + color: #5b5852; +} +[data-viewer-theme="night"] + .settings-view + > .settings-menu + button + svg.checkedIcon { + color: #000; + fill: #000; +} +[data-viewer-theme="night"] .settings-view > .settings-menu button.active { + color: #fff; + background-color: #9e9e9e; +} +[data-viewer-theme="night"] .settings-view > .settings-menu button.day-theme { + background-color: #fff; +} +[data-viewer-theme="night"] .settings-view > .settings-menu button.sepia-theme { + background-color: #faf4e8; +} +[data-viewer-theme="night"] .settings-view > .settings-menu button.night-theme { + background-color: #111; + color: #fff; +} +[data-viewer-theme="night"] .settings-view > .settings-menu > li { + border: 0; + background-color: #1a1a1a; + color: #111; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu { + outline: none; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu button { + color: #5b5852; +} +[data-viewer-theme="sepia"] + .settings-view + > .settings-menu + button + svg.checkedIcon { + color: #faf4e8; + fill: #faf4e8; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu button.active { + color: #fff; + background-color: #9e9e9e; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu button.day-theme { + background-color: #fff; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu button.sepia-theme { + background-color: #faf4e8; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu button.night-theme { + background-color: #111; + color: #fff; +} +[data-viewer-theme="sepia"] .settings-view > .settings-menu > li { + border: 0; + background-color: #fff; + color: #111; +} +.bookmarks-view, +.highlights-view { + background-color: #fff; + overflow: scroll; + top: 3.5rem; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.bookmarks-view ol li, +.bookmarks-view ul li, +.highlights-view ol li, +.highlights-view ul li { + margin-top: 0; +} +.bookmarks-view ol li a, +.bookmarks-view ul li a, +.highlights-view ol li a, +.highlights-view ul li a { + color: #5b5852; + border-bottom: 1px solid #ccc; + display: block; + padding: 1rem; + text-decoration: none; + width: 100%; +} +@media screen and (min-width: 60rem) { + .bookmarks-view ol li a:hover, + .bookmarks-view ul li a:hover, + .highlights-view ol li a:hover, + .highlights-view ul li a:hover { + background: #e0e0e0; + color: #111; + } +} +.bookmarks-view ol li a.active, +.bookmarks-view ul li a.active, +.highlights-view ol li a.active, +.highlights-view ul li a.active { + background: #dadada; + color: #111; +} +.bookmarks-view ol li a.active:hover, +.bookmarks-view ul li a.active:hover, +.highlights-view ol li a.active:hover, +.highlights-view ul li a.active:hover { + background: #5b5852; + color: #dadada; +} +[data-viewer-theme="sepia"] .bookmarks-view, +[data-viewer-theme="sepia"] .highlights-view { + background-color: #faf4e8; +} +[data-viewer-theme="sepia"] .bookmarks-view ol li a, +[data-viewer-theme="sepia"] .bookmarks-view ul li a, +[data-viewer-theme="sepia"] .highlights-view ol li a, +[data-viewer-theme="sepia"] .highlights-view ul li a { + color: #5b5852; + border-bottom: 1px solid #e8cc94; +} +@media screen and (min-width: 60rem) { + [data-viewer-theme="sepia"] .bookmarks-view ol li a:hover, + [data-viewer-theme="sepia"] .bookmarks-view ul li a:hover, + [data-viewer-theme="sepia"] .highlights-view ol li a:hover, + [data-viewer-theme="sepia"] .highlights-view ul li a:hover { + background: #e0e0e0; + color: #faf4e8; + } +} +[data-viewer-theme="sepia"] .bookmarks-view ol li a.active, +[data-viewer-theme="sepia"] .bookmarks-view ul li a.active, +[data-viewer-theme="sepia"] .highlights-view ol li a.active, +[data-viewer-theme="sepia"] .highlights-view ul li a.active { + background: #dadada; + color: #111; +} +[data-viewer-theme="sepia"] .bookmarks-view ol li a.active:hover, +[data-viewer-theme="sepia"] .bookmarks-view ul li a.active:hover, +[data-viewer-theme="sepia"] .highlights-view ol li a.active:hover, +[data-viewer-theme="sepia"] .highlights-view ul li a.active:hover { + background: #111; + color: #dadada; +} +[data-viewer-theme="sepia"] .bookmarks-view ol li span, +[data-viewer-theme="sepia"] .bookmarks-view ul li span, +[data-viewer-theme="sepia"] .highlights-view ol li span, +[data-viewer-theme="sepia"] .highlights-view ul li span { + color: #5b5852; +} +[data-viewer-theme="night"] .bookmarks-view, +[data-viewer-theme="night"] .highlights-view { + background-color: #000; +} +[data-viewer-theme="night"] .bookmarks-view ol li a, +[data-viewer-theme="night"] .bookmarks-view ul li a, +[data-viewer-theme="night"] .highlights-view ol li a, +[data-viewer-theme="night"] .highlights-view ul li a { + color: #5b5852; + border-bottom: 1px solid #ccc; +} +@media screen and (min-width: 60rem) { + [data-viewer-theme="night"] .bookmarks-view ol li a:hover, + [data-viewer-theme="night"] .bookmarks-view ul li a:hover, + [data-viewer-theme="night"] .highlights-view ol li a:hover, + [data-viewer-theme="night"] .highlights-view ul li a:hover { + background: #e0e0e0; + color: #fff; + } +} +[data-viewer-theme="night"] .bookmarks-view ol li a.active, +[data-viewer-theme="night"] .bookmarks-view ul li a.active, +[data-viewer-theme="night"] .highlights-view ol li a.active, +[data-viewer-theme="night"] .highlights-view ul li a.active { + background: #dadada; + color: #000; +} +[data-viewer-theme="night"] .bookmarks-view ol li a.active:hover, +[data-viewer-theme="night"] .bookmarks-view ul li a.active:hover, +[data-viewer-theme="night"] .highlights-view ol li a.active:hover, +[data-viewer-theme="night"] .highlights-view ul li a.active:hover { + background: #5b5852; + color: #dadada; +} +[data-viewer-theme="night"] .bookmarks-view ol li span, +[data-viewer-theme="night"] .bookmarks-view ul li span, +[data-viewer-theme="night"] .highlights-view ol li span, +[data-viewer-theme="night"] .highlights-view ul li span { + color: #dadada; + border-bottom: 1px solid #333; +} +:root { + --RS__highlightColor: rgba(255, 255, 0, 0.5); +} +.sidenav-annotations { + padding-inline-start: 0px; +} +.sidenav-annotations .chapter-link { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidenav-annotations .chapter-title { + padding: 0 16px; + font-size: 14px; + color: #d3d3d3; +} +.sidenav-annotations ol { + padding-left: 24px; +} +.sidenav-annotations li { + position: relative; +} +.sidenav-annotations li .delete { + position: absolute; + right: 10px; + top: 50%; + transform: translate(0, -50%); +} +.sidenav-annotations li .bookmark-link, +.sidenav-annotations li .highlight-link { + display: block; + position: relative; + padding-top: 8px !important; + padding-bottom: 8px !important; + line-height: 1.25; +} +.sidenav-annotations li .bookmark-link svg, +.sidenav-annotations li .bookmark-link i, +.sidenav-annotations li .highlight-link svg, +.sidenav-annotations li .highlight-link i { + left: 4px; + top: 8px; + position: absolute; +} +.sidenav-annotations li .bookmark-link .title, +.sidenav-annotations li .highlight-link .title { + display: block; +} +.sidenav-annotations li .bookmark-link .subtitle, +.sidenav-annotations li .highlight-link .subtitle { + display: block; + line-height: 20px; + font-size: 10px; +} +.sidenav-annotations li .bookmark-link .timestamp, +.sidenav-annotations li .highlight-link .timestamp { + display: block; + line-height: 20px; + font-size: 8px; +} +.highlight-toolbox { + transform: translate(-50%, -100%); + animation: toolsAnimateIn 100ms ease-out both; + transform-origin: bottom; + z-index: 999; + position: relative; + background: #dcdcdc; + display: none; + width: fit-content; +} +.highlight-toolbox:before { + content: ""; + position: absolute; + width: 0.4rem; + height: 0.4rem; + bottom: -0.2rem; + left: calc(50% - 0.2rem); + background-color: inherit; + transform: rotate(45deg); +} +.highlight-toolbox > div > button { + display: inline-block; +} +.color-option span { + display: inline-block; + border-radius: 50%; + width: 24px; + height: 24px; + background: gray; +} +@keyframes toolsAnimateIn { + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.5); + } +} +.thumb { + border-radius: 50% 50% 50% 0 !important; + height: 30px !important; + width: 30px !important; + margin-left: -15px !important; + top: -20px !important; + left: 50%; +} +input[type="range"] + .thumb .value { + margin-top: -20px; + font-size: 10px; + right: 22px; + position: absolute; +} +input[type="checkbox"] label { + display: flex; + flex-direction: row; + align-items: center; +} +input[type="checkbox"] { + position: relative !important; + appearance: none; + box-sizing: content-box; + overflow: hidden; +} +input[type="checkbox"]:before { + content: ""; + display: block; + box-sizing: content-box; + width: 16px; + height: 16px; + border: 2px solid #ccc; + transition: 0.2s border-color ease; +} +input[type="checkbox"]:checked:before { + border-color: #101010; + transition: 0.5s border-color ease; +} +input[type="checkbox"]:disabled:before { + border-color: #ccc; + background-color: #ccc; +} +input[type="checkbox"]:after { + content: ""; + display: block; + position: absolute; + box-sizing: content-box; + top: 50%; + left: 50%; + transform-origin: 50% 50%; + background-color: #101010; + width: 16px; + height: 16px; + border-radius: 100vh; + transform: translate(-50%, -50%) scale(0); +} +input[type="checkbox"][type="radio"]:before { + border-radius: 100vh; +} +input[type="checkbox"][type="radio"]:after { + width: 16px; + height: 16px; + border-radius: 100vh; + transform: translate(-50%, -50%) scale(0); +} +input[type="checkbox"][type="radio"]:checked:after { + animation: toggleOnRadio 0.2s ease forwards; +} +input[type="checkbox"][type="checkbox"]:before { + border-radius: 4px; +} +input[type="checkbox"][type="checkbox"]:after { + width: 9.6px; + height: 16px; + border-radius: 0; + transform: translate(-50%, -85%) scale(0) rotate(45deg); + background-color: rgba(0, 0, 0, 0); + box-shadow: 4px 4px 0px 0px #101010; +} +input[type="checkbox"][type="checkbox"]:checked:after { + animation: toggleOnCheckbox 0.2s ease forwards; +} +input[type="checkbox"][type="checkbox"].filled:before { + border-radius: 4px; + transition: + 0.2s border-color ease, + 0.2s background-color ease; +} +input[type="checkbox"][type="checkbox"].filled:checked:not(:disabled):before { + background-color: #101010; +} +input[type="checkbox"][type="checkbox"].filled:not(:disabled):after { + box-shadow: 4px 4px 0px 0px #fff; +} +@keyframes toggleOnCheckbox { + 0% { + opacity: 0; + transform: translate(-50%, -85%) scale(0) rotate(45deg); + } + 70% { + opacity: 1; + transform: translate(-50%, -85%) scale(0.9) rotate(45deg); + } + 100% { + transform: translate(-50%, -85%) scale(0.8) rotate(45deg); + } +} +@keyframes toggleOnRadio { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0); + } + 70% { + opacity: 1; + transform: translate(-50%, -50%) scale(0.9); + } + 100% { + transform: translate(-50%, -50%) scale(0.8); + } +} +.range-slider { + width: 100%; +} +.range-slider__range { + -webkit-appearance: none; + width: calc(100% - (73px)); + height: 10px; + border-radius: 5px; + background: #d7dcdf; + outline: none; + padding: 0; + margin: 0; +} +.range-slider__range::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #2c3e50; + cursor: pointer; + transition: 0.15s ease-in-out; +} +.range-slider__range::-webkit-slider-thumb:hover { + background: #1abc9c; +} +.range-slider__range:active::-webkit-slider-thumb { + background: #1abc9c; +} +.range-slider__range::-moz-range-thumb { + width: 20px; + height: 20px; + border: 0; + border-radius: 50%; + background: #2c3e50; + cursor: pointer; + transition: 0.15s ease-in-out; +} +.range-slider__range::-moz-range-thumb:hover { + background: #1abc9c; +} +.range-slider__range:active::-moz-range-thumb { + background: #1abc9c; +} +.range-slider__range:focus::-webkit-slider-thumb { + box-shadow: + 0 0 0 3px #fff, + 0 0 0 6px #1abc9c; +} +.range-slider__range_settings { + -webkit-appearance: none; + width: 100%; + height: 10px; + border-radius: 5px; + background: #d7dcdf; + outline: none; + padding: 0; + margin: 0; +} +.range-slider__range_settings::-webkit-slider-thumb { + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: #2c3e50; + cursor: pointer; + transition: 0.15s ease-in-out; +} +.range-slider__range_settings::-webkit-slider-thumb:hover { + background: #1abc9c; +} +.range-slider__range_settings:active::-webkit-slider-thumb { + background: #1abc9c; +} +.range-slider__range_settings::-moz-range-thumb { + width: 20px; + height: 20px; + border: 0; + border-radius: 50%; + background: #2c3e50; + cursor: pointer; + transition: 0.15s ease-in-out; +} +.range-slider__range_settings::-moz-range-thumb:hover { + background: #1abc9c; +} +.range-slider__range_settings:active::-moz-range-thumb { + background: #1abc9c; +} +.range-slider__range_settings:focus::-webkit-slider-thumb { + box-shadow: + 0 0 0 3px #fff, + 0 0 0 6px #1abc9c; +} +.range-slider__value { + display: inline-block; + position: relative; + width: 60px; + color: #fff; + line-height: 20px; + text-align: center; + border-radius: 3px; + background: #2c3e50; + padding: 5px 10px; + margin-left: 8px; +} +.range-slider__value:after { + position: absolute; + top: 8px; + left: -7px; + width: 0; + height: 0; + border-top: 7px solid rgba(0, 0, 0, 0); + border-right: 7px solid #2c3e50; + border-bottom: 7px solid rgba(0, 0, 0, 0); + content: ""; +} +::-moz-range-track { + background: #d7dcdf; + border: 0; +} +input::-moz-focus-inner, +input::-moz-focus-outer { + border: 0; +} +@media only screen and (max-width: 600px) { + .timeline, + .scrubber > input { + display: none; + } +} +.scrubber { + flex-grow: 1; + padding-right: 20px; + padding-left: 20px; +} +.timeline { + position: fixed; + top: 1.5rem; + left: 2.5rem; + bottom: 1.5rem; + width: 1.25rem; + display: flex; + flex-direction: column; +} +.timeline .chapter { + border-left: 6px solid rgba(0, 0, 0, 0); + transition: border-color 300ms ease-out; + background: #dadada; + border: 1px solid #fff; + border-radius: 5px; + position: relative; +} +.timeline .chapter:hover { + background-color: #dadada; +} +.timeline .chapter:hover .chapter-tooltip { + display: block; +} +.timeline .chapter.active { + background-color: #9e9e9e; +} +.timeline .chapter-tooltip { + display: none; + position: absolute; + left: 2rem; + top: 50%; + transform: translate(0, -50%); + background: #dadada; + padding: 0.25rem 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 22rem; +} +[data-viewer-theme="night"] .timeline .chapter { + border: 1px solid #111; +} +[data-viewer-theme="sepia"] .timeline .chapter { + border: 1px solid #faf4e8; +} +.collection { + border-radius: 2px; + overflow: hidden; + position: relative; +} +.collection .collection-item { + padding: 10px 20px; + margin: 0; +} +.collection .collection-item.avatar { + min-height: 84px; + padding-left: 72px; + position: relative; +} +.collection .collection-item.avatar:not(.circle-clipper) > .circle, +.collection .collection-item.avatar :not(.circle-clipper) > .circle { + position: absolute; + width: 42px; + height: 42px; + overflow: hidden; + left: 15px; + display: inline-block; + vertical-align: middle; +} +.collection .collection-item.avatar i.circle { + font-size: 18px; + line-height: 42px; + color: #fff; + background-color: #999; + text-align: center; +} +.collection .collection-item.avatar .title { + font-size: 16px; +} +.collection .collection-item.avatar p { + margin: 0; +} +.collection .collection-item.avatar .secondary-content { + position: absolute; + top: 16px; + right: 16px; +} +.collection .collection-item:last-child { + border-bottom: none; +} +.collection .collection-item.active .secondary-content { + color: #fff; +} +.collection a.collection-item { + display: block; + transition: 0.25s; +} +.collection.with-header .collection-header { + padding: 10px 20px; +} +.collection.with-header .collection-item { + padding-left: 30px; +} +.collection.with-header .collection-item.avatar { + padding-left: 72px; +} +.pagination { + display: inline-block; +} +.pagination li { + display: inline-block; + border-radius: 2px; + text-align: center; + vertical-align: top; + height: 30px; + clear: unset !important; + width: unset !important; +} +.pagination li a { + color: #444; + display: inline-block !important; + font-size: 1.2rem; + padding: 0 10px; + line-height: 30px; +} +.pagination li.active a { + color: #999; +} +.pagination li.disabled a { + cursor: default; + color: #999; +} +.pagination li i { + font-size: 2rem; +} +.pagination li.pages ul li { + display: inline-block; + float: none; +} +.search-wrapper { + display: flex; + align-items: center; +} +.sidenav.expanded { + width: 992px !important; +} +@media only screen and (max-width: 992px) { + .sidenav.expanded { + width: 100% !important; + } +} +.logo-container i.editAnnotations { + transform: rotate(-90deg); + margin-top: 12px; + font-size: 32px; + margin-right: 6px; + float: right; +} +.grab-to-pan-grab { + cursor: grab !important; +} +.grab-to-pan-grab + *:not(input):not(textarea):not(button):not(select):not(:link) { + cursor: inherit !important; +} +.grab-to-pan-grab:active, +.grab-to-pan-grabbing { + cursor: grabbing !important; +} -/*# sourceMappingURL=reader.css.map */ \ No newline at end of file +/*# sourceMappingURL=reader.css.map */ diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 3feee6a4fc73..bc0a7c4023e0 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -10,29 +10,27 @@ "node": ">=22.12.0" }, "scripts": { - "dev": "vite", + "dev": "node ./scripts/dev.mjs", + "// Watching: yarn dev starts vite + file watchers (LESS, pug, static assets, and key content folders)": " ", "// COMMENTS: make the action a space rather than empty string so `yarn run` can list the scripts": " ", "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run", "check-that-node-modules-exists-in-content-dir": "cd ../content && node checkForNodeModules.js && cd ../BloomBrowserUI", "// 'build:ui': 'builds all the stuff handled directly by vite'": " ", - "build:ui": "vite build", - "// 'build': 'builds all the core stuff devs need in both this folder and content. Does not clean.'": " ", - "build": "npm-run-all --parallel build:ui build:content", + "build:ui": "vite build --logLevel error", + "// 'build': 'builds all the core stuff devs need in both this folder and content. Cleans first.'": " ", + "build": "node scripts/build.js", "build:clean": "node scripts/clean.js", "// 'build-prod': 'production build: clean, then build+content in parallel, then l10n'": " ", - "build-prod": "npm run build:clean && npm-run-all --parallel build:ui build:content && npm-run-all --parallel build:l10n:translate build:l10n:create", - "build:pug": "node ./scripts/compilePug.mjs", + "build-prod": "yarn build:clean && npm-run-all --parallel build:ui build:content && npm-run-all --parallel build:l10n:translate build:l10n:create", "// 'build:l10n': creates/updates xliff files and translates html files.": " ", "// 'build:l10n': is needed when markdown/html content changes or when testing l10n.": " ", "// 'build:l10n': should be run after build. (build-prod includes this functionality.)": " ", "build:l10n": "node scripts/l10n-build.js", "build:l10n:translate": "node scripts/l10n-build.js translate", "build:l10n:create": "node scripts/l10n-build.js create", - "build:content": "npm run check-that-node-modules-exists-in-content-dir && cd ../content && npm run build", - "// We shouldn't need'watchBookEditLess' anymore once we finish getting vite dev working": " ", - "watchBookEditLess": "less-watch-compiler bookEdit ../../output/browser/bookEdit", + "build:content": "yarn run check-that-node-modules-exists-in-content-dir && yarn --cwd ../content build", "// 'watch': rebuilds bundles when source files change (for entrypoints not yet working with vite dev)": " ", "watch": "vite build --watch", "// You can use yarn link to symlink bloom-player. But also, bloom needs a copy in output/!": " ", @@ -233,11 +231,13 @@ "less": "^3.13.1", "less-watch-compiler": "^1.13.0", "lessc-glob": "^1.0.9", + "chokidar": "^3.6.0", "lint-staged": "^15.4.3", "lorem-ipsum": "^2.0.2", "markdown-it-attrs": "^4.3.1", "markdown-it-container": "^4.0.0", "npm-run-all": "^4.1.5", + "onchange": "^7.1.0", "patch-package": "^6.4.7", "path": "^0.12.7", "playwright": "^1.56.1", diff --git a/src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts b/src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts new file mode 100644 index 000000000000..21fbd4df2afb --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts @@ -0,0 +1,67 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { compilePugFiles } from "../compilePug.mjs"; + +let tempDir: string; +let browserUIRoot: string; +let contentRoot: string; +let outputBase: string; + +function makeDir(dirPath: string) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath: string, contents: string) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compile-pug-")); + browserUIRoot = path.join(tempDir, "browserUI"); + contentRoot = path.join(tempDir, "content"); + outputBase = path.join(tempDir, "out"); + makeDir(browserUIRoot); + makeDir(contentRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("compilePugFiles", () => { + it("recompiles dependents when an included pug file changes", async () => { + const partialPath = path.join(browserUIRoot, "partials", "partial.pug"); + const mainPath = path.join(browserUIRoot, "pages", "main.pug"); + + writeFile(partialPath, "p Partial A\n"); + + writeFile( + mainPath, + [ + "doctype html", + "html", + " body", + " include ../partials/partial.pug", + "", + ].join("\n"), + ); + + await compilePugFiles({ browserUIRoot, contentRoot, outputBase }); + + const outPath = path.join(outputBase, "pages", "main.html"); + expect(fs.existsSync(outPath)).toBe(true); + const firstHtml = fs.readFileSync(outPath, "utf8"); + expect(firstHtml).toContain("Partial A"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(partialPath, "p Partial B\n"); + + await compilePugFiles({ browserUIRoot, contentRoot, outputBase }); + const secondHtml = fs.readFileSync(outPath, "utf8"); + expect(secondHtml).toContain("Partial B"); + expect(secondHtml).not.toContain("Partial A"); + }); +}); diff --git a/src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs new file mode 100644 index 000000000000..e55a6aaa5575 --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs @@ -0,0 +1,188 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { LessWatchManager } from "../watchLess.mjs"; + +const silentLogger = { + log: () => {}, + warn: () => {}, + error: () => {}, +}; + +let tempDir; +let sourceRoot; +let outputRoot; +let metadataPath; + +function makeDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath, contents) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +function makeManager(overrides = {}) { + return new LessWatchManager({ + repoRoot: tempDir, + metadataPath, + logger: silentLogger, + targets: [ + { + name: "test", + root: sourceRoot, + outputBase: outputRoot, + }, + ], + ...overrides, + }); +} + +function getEntryId(manager, filePath) { + const key = path + .relative(manager.repoRoot, path.resolve(filePath)) + .replace(/\\/g, "/"); + return key; +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "watch-less-")); + sourceRoot = path.join(tempDir, "src"); + outputRoot = path.join(tempDir, "out"); + metadataPath = path.join(outputRoot, ".state.json"); + makeDir(sourceRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("LessWatchManager", () => { + it("compiles missing outputs and records metadata", async () => { + const entryPath = path.join(sourceRoot, "pages", "main.less"); + const partialPath = path.join(sourceRoot, "partials", "colors.less"); + writeFile(partialPath, "@primary: #ff0000;\n"); + writeFile( + entryPath, + '@import "../partials/colors.less";\nbody { color: @primary; }\n', + ); + + const manager = makeManager(); + await manager.initialize(); + + const cssPath = path.join(outputRoot, "pages", "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + const css = fs.readFileSync(cssPath, "utf8"); + expect(css).toContain("body"); + expect(fs.existsSync(`${cssPath}.map`)).toBe(true); + + const state = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + const entryId = getEntryId(manager, entryPath); + expect(state.entries[entryId]).toContain( + path.relative(tempDir, partialPath).replace(/\\/g, "/"), + ); + }); + + it("rebuilds when dependency is newer on startup", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const partialPath = path.join(sourceRoot, "dep.less"); + writeFile(partialPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const firstManager = makeManager(); + await firstManager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + const initialMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(partialPath, "@val: green;\n"); + + const secondManager = makeManager(); + await secondManager.initialize(); + const rebuiltMTime = fs.statSync(cssPath).mtimeMs; + expect(rebuiltMTime).toBeGreaterThan(initialMTime); + }); + + it("updates dependency graph when imports change", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depPath = path.join(sourceRoot, "dep.less"); + writeFile(depPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const entryId = getEntryId(manager, entryPath); + const cssPath = path.join(outputRoot, "main.css"); + const baselineMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(entryPath, "body { color: black; }\n"); + await manager.handleFileChanged(entryPath, "entry updated"); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.length).toBe(1); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depPath, "@val: red;\n"); + await manager.handleFileChanged(depPath, "dep changed"); + const afterMTime = fs.statSync(cssPath).mtimeMs; + expect(afterMTime).toBe(baselineMTime); + }); + + it("adds new dependencies and rebuilds when partial changes", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depA = path.join(sourceRoot, "depA.less"); + const depB = path.join(sourceRoot, "depB.less"); + writeFile(depA, "@val: blue;\n"); + writeFile(depB, "@alt: red;\n"); + writeFile(entryPath, '@import "depA.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile( + entryPath, + '@import "depA.less";\n@import "depB.less";\nbody { color: @alt; }\n', + ); + await manager.handleFileChanged(entryPath, "entry changed"); + const entryId = getEntryId(manager, entryPath); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.some((dep) => dep.endsWith("depB.less"))).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depB, "@alt: purple;\n"); + const before = fs.statSync(cssPath).mtimeMs; + await manager.handleFileChanged(depB, "depB updated"); + const after = fs.statSync(cssPath).mtimeMs; + expect(after).toBeGreaterThan(before); + }); + + it("removes outputs when an entry is deleted", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + writeFile(entryPath, "body { color: blue; }\n"); + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + + fs.unlinkSync(entryPath); + await manager.handleFileRemoved(entryPath); + expect(fs.existsSync(cssPath)).toBe(false); + expect(fs.existsSync(`${cssPath}.map`)).toBe(false); + }); + + it("builds new entries on the fly", async () => { + const manager = makeManager(); + await manager.initialize(); + + const entryPath = path.join(sourceRoot, "new.less"); + writeFile(entryPath, "body { color: orange; }\n"); + await manager.handleFileAdded(manager.targets[0], entryPath); + + const cssPath = path.join(outputRoot, "new.css"); + expect(fs.existsSync(cssPath)).toBe(true); + }); +}); diff --git a/src/BloomBrowserUI/scripts/__tests__/watchLess.test.ts b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.ts new file mode 100644 index 000000000000..cb33a7fa0750 --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.ts @@ -0,0 +1,242 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { LessWatchManager } from "../watchLess.mjs"; +import type { LessWatchTarget } from "../watchLess.mjs"; + +const silentLogger = { + log: () => {}, + warn: () => {}, + error: () => {}, +}; + +let tempDir: string; +let sourceRoot: string; +let outputRoot: string; +let metadataPath: string; + +function makeDir(dirPath: string) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath: string, contents: string) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +function makeManager(overrides: Partial<{ targets: LessWatchTarget[] }> = {}) { + const defaultTarget: LessWatchTarget = { + name: "test", + root: sourceRoot, + outputBase: outputRoot, + }; + + return new LessWatchManager({ + repoRoot: tempDir, + metadataPath, + logger: silentLogger, + targets: overrides.targets ?? [defaultTarget], + }); +} + +function getEntryId(manager: LessWatchManager, filePath: string) { + return path + .relative(manager.repoRoot, path.resolve(filePath)) + .replace(/\\/g, "/"); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "watch-less-")); + sourceRoot = path.join(tempDir, "src"); + outputRoot = path.join(tempDir, "out"); + metadataPath = path.join(outputRoot, ".state.json"); + makeDir(sourceRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("LessWatchManager", () => { + it("compiles missing outputs and records metadata", async () => { + const entryPath = path.join(sourceRoot, "pages", "main.less"); + const partialPath = path.join(sourceRoot, "partials", "colors.less"); + writeFile(partialPath, "@primary: #ff0000;\n"); + writeFile( + entryPath, + '@import "../partials/colors.less";\nbody { color: @primary; }\n', + ); + + const manager = makeManager(); + await manager.initialize(); + + const cssPath = path.join(outputRoot, "pages", "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + const css = fs.readFileSync(cssPath, "utf8"); + expect(css).toContain("body"); + expect(fs.existsSync(`${cssPath}.map`)).toBe(true); + + const state = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + const entryId = getEntryId(manager, entryPath); + expect(state.entries[entryId]).toContain( + path.relative(tempDir, partialPath).replace(/\\/g, "/"), + ); + }); + + it("rebuilds when dependency is newer on startup", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const partialPath = path.join(sourceRoot, "dep.less"); + writeFile(partialPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const firstManager = makeManager(); + await firstManager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + const initialMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(partialPath, "@val: green;\n"); + + const secondManager = makeManager(); + await secondManager.initialize(); + const rebuiltMTime = fs.statSync(cssPath).mtimeMs; + expect(rebuiltMTime).toBeGreaterThan(initialMTime); + }); + + it("updates dependency graph when imports change", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depPath = path.join(sourceRoot, "dep.less"); + writeFile(depPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const entryId = getEntryId(manager, entryPath); + const cssPath = path.join(outputRoot, "main.css"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(entryPath, "body { color: black; }\n"); + await manager.handleFileChanged(entryPath, "entry updated"); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.length).toBe(1); + const baselineMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depPath, "@val: red;\n"); + await manager.handleFileChanged(depPath, "dep changed"); + const afterMTime = fs.statSync(cssPath).mtimeMs; + expect(afterMTime).toBe(baselineMTime); + }); + + it("adds new dependencies and rebuilds when partial changes", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depA = path.join(sourceRoot, "depA.less"); + const depB = path.join(sourceRoot, "depB.less"); + writeFile(depA, "@val: blue;\n"); + writeFile(depB, "@alt: red;\n"); + writeFile(entryPath, '@import "depA.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile( + entryPath, + '@import "depA.less";\n@import "depB.less";\nbody { color: @alt; }\n', + ); + await manager.handleFileChanged(entryPath, "entry changed"); + const entryId = getEntryId(manager, entryPath); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.some((dep) => dep.endsWith("depB.less"))).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depB, "@alt: purple;\n"); + const before = fs.statSync(cssPath).mtimeMs; + await manager.handleFileChanged(depB, "depB updated"); + const after = fs.statSync(cssPath).mtimeMs; + expect(after).toBeGreaterThan(before); + }); + + it("removes outputs when an entry is deleted", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + writeFile(entryPath, "body { color: blue; }\n"); + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + + fs.unlinkSync(entryPath); + await manager.handleFileRemoved(entryPath); + expect(fs.existsSync(cssPath)).toBe(false); + expect(fs.existsSync(`${cssPath}.map`)).toBe(false); + }); + + it("builds new entries on the fly", async () => { + const manager = makeManager(); + await manager.initialize(); + + const entryPath = path.join(sourceRoot, "new.less"); + writeFile(entryPath, "body { color: orange; }\n"); + await manager.handleFileAdded(manager.targets[0], entryPath); + + const cssPath = path.join(outputRoot, "new.css"); + expect(fs.existsSync(cssPath)).toBe(true); + }); + + it("rebuilds direct + transitive dependents when a dependency changes, even without prior metadata", async () => { + const fontsPath = path.join(sourceRoot, "bloomWebFonts.less"); + const uiPath = path.join(sourceRoot, "bloomUI.less"); + const editModePath = path.join( + sourceRoot, + "bookEdit", + "css", + "editMode.less", + ); + + writeFile(fontsPath, "@UIFontStack: Arial;\n"); + writeFile( + uiPath, + '@import "./bloomWebFonts.less";\nbody { font-family: @UIFontStack; }\n', + ); + writeFile( + editModePath, + '@import "../../bloomUI.less";\n.editMode { color: black; }\n', + ); + + // Simulate pre-existing CSS outputs (e.g. built by some other pipeline) so the manager + // won't compile anything on startup unless it can still determine dependencies. + await new Promise((resolve) => setTimeout(resolve, 30)); + const fontsCssPath = path.join(outputRoot, "bloomWebFonts.css"); + const uiCssPath = path.join(outputRoot, "bloomUI.css"); + const editModeCssPath = path.join( + outputRoot, + "bookEdit", + "css", + "editMode.css", + ); + writeFile(fontsCssPath, "/* prebuilt */\n"); + writeFile(uiCssPath, "/* prebuilt */\n"); + writeFile(editModeCssPath, "/* prebuilt */\n"); + + const manager = makeManager(); + await manager.initialize(); + + const fontsBaseline = fs.statSync(fontsCssPath).mtimeMs; + const uiBaseline = fs.statSync(uiCssPath).mtimeMs; + const editModeBaseline = fs.statSync(editModeCssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(fontsPath, "@UIFontStack: Verdana;\n"); + await manager.handleFileChanged(fontsPath, "fonts changed"); + + const fontsAfter = fs.statSync(fontsCssPath).mtimeMs; + const uiAfter = fs.statSync(uiCssPath).mtimeMs; + const editModeAfter = fs.statSync(editModeCssPath).mtimeMs; + + expect(fontsAfter).toBeGreaterThan(fontsBaseline); + expect(uiAfter).toBeGreaterThan(uiBaseline); + expect(editModeAfter).toBeGreaterThan(editModeBaseline); + }); +}); diff --git a/src/BloomBrowserUI/scripts/build.js b/src/BloomBrowserUI/scripts/build.js new file mode 100644 index 000000000000..bdef2e0f3eea --- /dev/null +++ b/src/BloomBrowserUI/scripts/build.js @@ -0,0 +1,164 @@ +#!/usr/bin/env node +const { spawn } = require("child_process"); +const path = require("path"); + +const args = new Set(process.argv.slice(2)); +const verbose = args.has("--verbose"); + +const browserUIRoot = path.resolve(__dirname, ".."); +const contentRoot = path.resolve(browserUIRoot, "..", "content"); + +const env = { ...process.env }; + +const viteBin = path.join( + browserUIRoot, + "node_modules", + "vite", + "bin", + "vite.js", +); +const tsNodeBin = path.join( + contentRoot, + "node_modules", + "ts-node", + "dist", + "bin.js", +); +const cpxBin = path.join(contentRoot, "node_modules", "cpx", "bin", "index.js"); +const rimrafBin = path.join( + contentRoot, + "node_modules", + "rimraf", + "dist", + "esm", + "bin.mjs", +); + +const runCommand = (command, commandArgs, options = {}) => + new Promise((resolve, reject) => { + const showOutput = options.showOutput ?? verbose; + const child = spawn(command, commandArgs, { + cwd: options.cwd ?? browserUIRoot, + env, + shell: false, + stdio: showOutput ? "inherit" : ["ignore", "pipe", "pipe"], + }); + + if (showOutput) { + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `Command failed (${code}): ${command} ${commandArgs.join( + " ", + )}`, + ), + ); + } + }); + return; + } + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data; + }); + child.stderr.on("data", (data) => { + stderr += data; + }); + child.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + if (stdout) { + process.stdout.write(stdout); + } + if (stderr) { + process.stderr.write(stderr); + } + reject( + new Error( + `Command failed (${code}): ${command} ${commandArgs.join( + " ", + )}`, + ), + ); + }); + child.on("error", reject); + }); + +const run = async () => { + console.log("Cleaning output/browser..."); + await runCommand("node", ["scripts/clean.js", "--quiet"], { + cwd: browserUIRoot, + }); + + console.log("Vite build..."); + await runCommand("node", [viteBin, "build", "--logLevel", "warn"], { + cwd: browserUIRoot, + showOutput: true, + }); + + console.log("Building content assets..."); + await runCommand("node", ["checkForNodeModules.js"], { cwd: contentRoot }); + await runCommand("node", [tsNodeBin, "pageSizes.ts"], { + cwd: contentRoot, + }); + console.log("Copying branding assets..."); + await runCommand( + "node", + [ + cpxBin, + "branding/**/!(source)/*.{png,jpg,svg,css,json,htm}", + "../../output/browser/branding", + ], + { cwd: contentRoot }, + ); + console.log("Copying template assets..."); + await runCommand( + "node", + [ + cpxBin, + "templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}", + "../../output/browser/templates", + ], + { cwd: contentRoot }, + ); + console.log("Copying appearance themes..."); + await runCommand( + "node", + [rimrafBin, "../../output/browser/appearanceThemes"], + { + cwd: contentRoot, + }, + ); + await runCommand( + "node", + [ + cpxBin, + "appearanceThemes/**/*.css", + "../../output/browser/appearanceThemes", + ], + { cwd: contentRoot }, + ); + await runCommand( + "node", + [ + cpxBin, + "appearanceMigrations/**", + "../../output/browser/appearanceMigrations", + ], + { cwd: contentRoot }, + ); + + console.log("Build complete."); +}; + +run().catch((error) => { + console.error(error.message ?? error); + process.exit(1); +}); diff --git a/src/BloomBrowserUI/scripts/clean.js b/src/BloomBrowserUI/scripts/clean.js index 98a859671796..e7244efa061a 100644 --- a/src/BloomBrowserUI/scripts/clean.js +++ b/src/BloomBrowserUI/scripts/clean.js @@ -7,17 +7,27 @@ const fs = require("fs"); const path = require("path"); -const outputBase = path.resolve(__dirname, "../../../output/browser"); +const outputDirs = [path.resolve(__dirname, "../../../output/browser")]; +const quiet = process.argv.includes("--quiet"); -if (fs.existsSync(outputBase)) { - console.log(`\nCleaning output directory: ${outputBase}`); +for (const outputDir of outputDirs) { + if (!fs.existsSync(outputDir)) { + if (!quiet) { + console.log(`\nOutput directory does not exist: ${outputDir}`); + console.log("Nothing to clean.\n"); + } + continue; + } - // Delete all files and subdirectories - const entries = fs.readdirSync(outputBase); + if (!quiet) { + console.log(`\nCleaning output directory: ${outputDir}`); + } + + const entries = fs.readdirSync(outputDir); let deletedCount = 0; for (const entry of entries) { - const fullPath = path.join(outputBase, entry); + const fullPath = path.join(outputDir, entry); try { fs.rmSync(fullPath, { recursive: true, force: true }); deletedCount++; @@ -27,8 +37,7 @@ if (fs.existsSync(outputBase)) { } } - console.log(`✓ Deleted ${deletedCount} items from output directory\n`); -} else { - console.log(`\nOutput directory does not exist: ${outputBase}`); - console.log("Nothing to clean.\n"); + if (!quiet) { + console.log(`✓ Deleted ${deletedCount} items from output directory\n`); + } } diff --git a/src/BloomBrowserUI/scripts/compileLess.mjs b/src/BloomBrowserUI/scripts/compileLess.mjs new file mode 100644 index 000000000000..858097e40e58 --- /dev/null +++ b/src/BloomBrowserUI/scripts/compileLess.mjs @@ -0,0 +1,87 @@ +/* eslint-env node */ +/* global console, process */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const require = createRequire(import.meta.url); +const { LessWatchManager } = require("./watchLessManager.js"); + +function resolvePaths(options = {}) { + const browserUIRoot = + options.browserUIRoot ?? path.resolve(__dirname, ".."); + const outputBase = + options.outputBase ?? + path.resolve(browserUIRoot, "../../output/browser"); + + const repoRoot = + options.repoRoot ?? path.resolve(browserUIRoot, "..", ".."); + const metadataPath = + options.metadataPath ?? path.join(outputBase, ".less-watch-state.json"); + + return { browserUIRoot, outputBase, repoRoot, metadataPath }; +} + +export async function compileLessFiles(options = {}) { + const { browserUIRoot, outputBase, repoRoot, metadataPath } = + resolvePaths(options); + + const contentRoot = + options.contentRoot ?? path.resolve(browserUIRoot, "..", "content"); + + const logger = console; + + const targets = options.targets ?? [ + { + name: "browser-ui", + root: browserUIRoot, + outputBase, + }, + { + name: "branding", + root: path.join(contentRoot, "branding"), + outputBase: path.join(outputBase, "branding"), + }, + { + name: "templates", + root: path.join(contentRoot, "templates"), + outputBase: path.join(outputBase, "templates"), + }, + { + name: "bookLayout", + root: path.join(contentRoot, "bookLayout"), + outputBase: path.join(outputBase, "bookLayout"), + entries: ["basePage.less", "canvasElement.less"], + }, + ]; + + const manager = new LessWatchManager({ + repoRoot, + metadataPath, + targets, + logger, + }); + + await manager.initialize(); + const compiled = manager.compiledCount ?? 0; + const total = manager.entries?.size ?? 0; + const skipped = Math.max(0, total - compiled); + console.log( + `Less: ${compiled} compiled, ${skipped} up-to-date (${total} total)\n`, + ); +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "compileLess.mjs"; + +if (invokedDirectly) { + compileLessFiles().catch((err) => { + console.error("Failed to compile LESS files:", err); + process.exitCode = 1; + }); +} diff --git a/src/BloomBrowserUI/scripts/compilePug.mjs b/src/BloomBrowserUI/scripts/compilePug.mjs index 9874d4db5c42..d6289d6b1932 100644 --- a/src/BloomBrowserUI/scripts/compilePug.mjs +++ b/src/BloomBrowserUI/scripts/compilePug.mjs @@ -1,8 +1,8 @@ /* eslint-env node */ /* global console, process */ -import path from "path"; -import { pathToFileURL, fileURLToPath } from "url"; -import fs from "fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; import { glob } from "glob"; import pug from "pug"; @@ -21,8 +21,38 @@ function resolvePaths(options = {}) { return { browserUIRoot, contentRoot, outputBase }; } +function getMTime(filePath) { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } +} + +function needsRebuild(sourceFile, dependencyFiles, outputFile) { + if (!fs.existsSync(outputFile)) { + return true; + } + + const outputTime = getMTime(outputFile); + const timesToCheck = [sourceFile, ...(dependencyFiles ?? [])]; + for (const dep of timesToCheck) { + const depTime = getMTime(dep); + if (!depTime || depTime > outputTime) { + return true; + } + } + + return false; +} + export async function compilePugFiles(options = {}) { const { browserUIRoot, contentRoot, outputBase } = resolvePaths(options); + const { + logSummary = false, + logWhenNoChanges = false, + logFiles = false, + } = options; const browserUIPugFiles = glob.sync("**/*.pug", { cwd: browserUIRoot, @@ -40,9 +70,8 @@ export async function compilePugFiles(options = {}) { const allPugFiles = [...browserUIPugFiles, ...contentPugFiles]; - console.log( - `\nCompiling ${allPugFiles.length} Pug files (${browserUIPugFiles.length} from BloomBrowserUI, ${contentPugFiles.length} from content)...`, - ); + let compiled = 0; + let skipped = 0; for (const file of allPugFiles) { const isContentFile = file.startsWith(contentRoot + path.sep); @@ -53,27 +82,63 @@ export async function compilePugFiles(options = {}) { .replace(/\.pug$/i, ".html"); const outputFile = path.join(outputBase, relativePath); - const outputDir = path.dirname(outputFile); + const compiledTemplate = pug.compileFile(file, { + basedir: baseRoot, + pretty: true, + }); + + const dependencies = Array.from( + new Set( + (compiledTemplate.dependencies ?? []).map((dep) => + path.resolve(dep), + ), + ), + ); + + if (!needsRebuild(file, dependencies, outputFile)) { + skipped++; + continue; + } + + const outputDir = path.dirname(outputFile); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } - const html = pug.renderFile(file, { - basedir: baseRoot, - pretty: true, - }); + const html = compiledTemplate({}); fs.writeFileSync(outputFile, html); - const displayPath = path.relative(browserUIRoot, file); - console.log(` ✓ ${displayPath} → ${relativePath}`); + if (logFiles) { + const displayPath = path.relative(browserUIRoot, file); + console.log(` ✓ ${displayPath} → ${relativePath}`); + } + compiled++; } - console.log("Pug compilation complete!\n"); + const total = allPugFiles.length; + if (logSummary && (logWhenNoChanges || compiled > 0)) { + console.log( + `Pug: ${compiled} compiled, ${skipped} up-to-date (${total} total)\n`, + ); + } + + return { compiled, skipped, total }; } -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - compilePugFiles().catch((err) => { +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "compilePug.mjs"; + +if (invokedDirectly) { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + compilePugFiles({ + logSummary: verbose, + logWhenNoChanges: verbose, + logFiles: verbose, + }).catch((err) => { console.error("Failed to compile Pug files:", err); process.exitCode = 1; }); diff --git a/src/BloomBrowserUI/scripts/copyStaticFile.mjs b/src/BloomBrowserUI/scripts/copyStaticFile.mjs new file mode 100644 index 000000000000..736154bd3812 --- /dev/null +++ b/src/BloomBrowserUI/scripts/copyStaticFile.mjs @@ -0,0 +1,102 @@ +/* eslint-env node */ +/* global console, process */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const excludedExtensions = new Set([ + ".ts", + ".tsx", + ".less", + ".pug", + ".md", + ".bat", +]); + +function resolvePaths(options = {}) { + const browserUIRoot = + options.browserUIRoot ?? path.resolve(__dirname, ".."); + const outputBase = + options.outputBase ?? + path.resolve(browserUIRoot, "../../output/browser"); + + return { browserUIRoot, outputBase }; +} + +function needsCopy(sourceFile, outputFile) { + if (!fs.existsSync(outputFile)) { + return true; + } + const sourceStat = fs.statSync(sourceFile); + const outputStat = fs.statSync(outputFile); + return sourceStat.mtimeMs > outputStat.mtimeMs; +} + +function copyStaticFile(filePath, options = {}) { + const quiet = options.quiet ?? true; + if (!filePath) { + return false; + } + + const { browserUIRoot, outputBase } = resolvePaths(options); + const absolutePath = path.resolve(filePath); + + if (!fs.existsSync(absolutePath)) { + return false; + } + + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + return false; + } + + if (absolutePath.includes(`${path.sep}node_modules${path.sep}`)) { + return false; + } + + const relativePath = path + .relative(browserUIRoot, absolutePath) + .replace(/\\/g, "/"); + + if ( + relativePath === "tsconfig.json" || + relativePath.startsWith(".") || + excludedExtensions.has(path.extname(relativePath)) + ) { + return false; + } + + const outputFile = path.join(outputBase, relativePath); + + if (!needsCopy(absolutePath, outputFile)) { + return false; + } + + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.copyFileSync(absolutePath, outputFile); + if (!quiet) { + console.log(` ✓ Copied ${relativePath}`); + } + return true; +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "copyStaticFile.mjs"; + +if (invokedDirectly) { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + const filtered = args.filter((arg) => arg !== "--verbose"); + copyStaticFile(filtered[0], { quiet: !verbose }); +} + +export { copyStaticFile }; diff --git a/src/BloomBrowserUI/scripts/dev.mjs b/src/BloomBrowserUI/scripts/dev.mjs new file mode 100644 index 000000000000..403133d2711d --- /dev/null +++ b/src/BloomBrowserUI/scripts/dev.mjs @@ -0,0 +1,449 @@ +/* eslint-env node */ +/* global console, process */ +import { spawn } from "child_process"; +import path from "node:path"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import { fileURLToPath } from "node:url"; +import { glob } from "glob"; +import { compilePugFiles } from "./compilePug.mjs"; +import { copyStaticFile } from "./copyStaticFile.mjs"; +import { copyContentFile } from "../../content/scripts/copyContentFile.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const browserUIRoot = path.resolve(__dirname, ".."); +const contentRoot = path.resolve(browserUIRoot, "../content"); + +const isWindows = process.platform === "win32"; + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8")); + +const resolvePackageBin = (packageRoot, packageName, binName) => { + const packageJsonPath = path.resolve( + packageRoot, + "node_modules", + packageName, + "package.json", + ); + + const packageJson = readJson(packageJsonPath); + const binField = packageJson.bin; + let binRelativePath; + if (typeof binField === "string") { + binRelativePath = binField; + } else { + binRelativePath = binField?.[binName]; + } + + if (!binRelativePath) { + throw new Error( + `Unable to resolve bin \"${binName}\" from ${packageJsonPath}`, + ); + } + + return path.resolve( + packageRoot, + "node_modules", + packageName, + binRelativePath, + ); +}; + +const processes = []; +let isShuttingDown = false; +const isVerbose = process.argv.includes("--verbose"); + +const defaultVitePort = 5173; + +const parsePort = () => { + const arg = process.argv.find((value) => value?.startsWith("--port=")); + if (arg) { + const parsed = Number.parseInt(arg.split("=")[1], 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + if (process.env.PORT) { + const parsed = Number.parseInt(process.env.PORT, 10); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return defaultVitePort; +}; + +const isPortAvailable = (port) => + new Promise((resolve) => { + const server = net + .createServer() + .once("error", (err) => { + resolve(err.code !== "EADDRINUSE"); + }) + .once("listening", () => { + server.close(() => resolve(true)); + }) + .listen(port, "127.0.0.1"); + }); + +function spawnProcess(command, args, options = {}) { + const proc = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + ...options, + }); + + proc.stdout.on("data", (data) => process.stdout.write(data)); + proc.stderr.on("data", (data) => process.stderr.write(data)); + + proc.on("error", (err) => { + if (isShuttingDown) { + return; + } + console.error(`Process failed to start: ${command}`); + console.error(err); + cleanup(1); + }); + + proc.on("close", (code, signal) => { + if (isShuttingDown) { + return; + } + + if (signal) { + console.error(`Process exited due to signal ${signal}: ${command}`); + cleanup(1); + return; + } + + if (code !== 0) { + console.error(`Process exited with code ${code}: ${command}`); + cleanup(code ?? 1); + } + }); + + processes.push(proc); + return proc; +} + +function spawnNodeScript(scriptPath, args, options = {}) { + return spawnProcess(process.execPath, [scriptPath, ...args], { + shell: false, + ...options, + }); +} + +function startVite(port) { + return new Promise((resolve) => { + console.log("Starting Vite dev server...\n"); + + const viteBin = resolvePackageBin(browserUIRoot, "vite", "vite"); + let ready = false; + const vite = spawn( + process.execPath, + [viteBin, "--port", String(port), "--strictPort"], + { + cwd: browserUIRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + env: { + ...process.env, + PORT: String(port), + }, + }, + ); + + processes.push(vite); + + vite.stdout.on("data", (data) => { + process.stdout.write(data); + if (data.toString().includes("ready in")) { + ready = true; + resolve(); + } + }); + + vite.stderr.on("data", (data) => process.stderr.write(data)); + + vite.on("error", (err) => { + console.error("Vite failed to start:", err); + process.exit(1); + }); + + vite.on("close", (code) => { + if (!ready) { + console.error( + `Vite exited before becoming ready (code ${code}).`, + ); + process.exit(1); + } + }); + }); +} + +async function runInitialBuilds() { + let copiedCount = 0; + const pugResult = await compilePugFiles({ + logSummary: isVerbose, + logWhenNoChanges: isVerbose, + logFiles: isVerbose, + }); + + const staticFiles = glob.sync("**/*.*", { + cwd: browserUIRoot, + nodir: true, + absolute: true, + ignore: ["**/node_modules/**"], + }); + for (const file of staticFiles) { + if (copyStaticFile(file, { quiet: !isVerbose })) { + copiedCount++; + } + } + + const contentCopyJobs = [ + { + label: "template files", + pattern: + "templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}", + sourceBase: "templates", + destinationBase: "templates", + }, + { + label: "branding files", + pattern: + "branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}", + sourceBase: "branding", + destinationBase: "branding", + }, + { + label: "appearance theme files", + pattern: "appearanceThemes/**/*.css", + sourceBase: "appearanceThemes", + destinationBase: "appearanceThemes", + }, + { + label: "appearance migration files", + pattern: "appearanceMigrations/**", + sourceBase: "appearanceMigrations", + destinationBase: "appearanceMigrations", + }, + ]; + + for (const job of contentCopyJobs) { + const files = glob.sync(job.pattern, { + cwd: contentRoot, + nodir: true, + absolute: true, + }); + for (const file of files) { + if ( + copyContentFile(file, job.sourceBase, job.destinationBase, { + quiet: !isVerbose, + }) + ) { + copiedCount++; + } + } + } + + const compiledCount = pugResult?.compiled ?? 0; + const totalChanges = compiledCount + copiedCount; + if (totalChanges === 0) { + console.log("\nInitial build done (no changes).\n"); + return; + } + + if (isVerbose) { + const summaryParts = []; + if (compiledCount > 0) { + summaryParts.push(`Pug: ${compiledCount} compiled`); + } + if (copiedCount > 0) { + summaryParts.push(`${copiedCount} files copied`); + } + + const summaryText = summaryParts.length + ? ` (${summaryParts.join(", ")})` + : ""; + console.log(`\nInitial build done${summaryText}.\n`); + } else { + console.log("\nInitial build done.\n"); + } +} + +async function startWatchers() { + await runInitialBuilds(); + console.log("\nStarting file watchers...\n"); + + const onchangeBin = resolvePackageBin( + browserUIRoot, + "onchange", + "onchange", + ); + const nodeForOnchange = isWindows ? "node" : process.execPath; + + // Pug watcher - compile all pug files initially, then watch for changes + console.log("Watching pug files..."); + const verboseFlag = isVerbose ? ["--verbose"] : []; + + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "**/*.pug", + "../content/**/*.pug", + "--", + nodeForOnchange, + "./scripts/compilePug.mjs", + ...verboseFlag, + ], + { cwd: browserUIRoot }, + ); + + // Less watcher - consolidate BloomBrowserUI and content LESS processing + console.log("Watching LESS files..."); + spawnProcess(process.execPath, ["./scripts/watchLess.mjs", "--scope=all"], { + cwd: browserUIRoot, + shell: false, + }); + + // Static file watcher - only triggers on actual changes (no -i since copyStaticFile needs a specific file) + console.log("Watching browser UI static files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "**/*.*", + "--", + nodeForOnchange, + "./scripts/copyStaticFile.mjs", + "{{file}}", + ...verboseFlag, + ], + { cwd: browserUIRoot }, + ); + + // Content watchers (spawn directly to avoid printing full commands) + console.log("Watching template files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "templates", + "templates", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); + + console.log("Watching branding files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "branding", + "branding", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); + + console.log("Watching appearance theme files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "appearanceThemes/**/*.css", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "appearanceThemes", + "appearanceThemes", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); + + console.log("Watching appearance migration files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "appearanceMigrations/**", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "appearanceMigrations", + "appearanceMigrations", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); +} + +function cleanup(exitCode = 0) { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + console.log("\nShutting down..."); + for (const proc of processes) { + proc.kill(); + } + const normalizedExitCode = + typeof exitCode === "number" && Number.isFinite(exitCode) + ? exitCode + : 0; + process.exit(normalizedExitCode); +} + +process.on("SIGINT", () => cleanup(0)); +process.on("SIGTERM", () => cleanup(0)); + +async function main() { + if (!fs.existsSync(process.execPath)) { + throw new Error(`Node executable not found at ${process.execPath}`); + } + + const port = parsePort(); + const available = await isPortAvailable(port); + if (!available) { + console.error(`Port ${port} is already in use.`); + console.error( + `Stop the other dev server, or run: yarn dev --port=${port + 1}`, + ); + process.exit(1); + } + + await startVite(port); + await startWatchers(); +} + +main().catch((err) => { + console.error("Dev script failed:", err); + cleanup(1); +}); diff --git a/src/BloomBrowserUI/scripts/watchLess.d.ts b/src/BloomBrowserUI/scripts/watchLess.d.ts new file mode 100644 index 000000000000..ffb645f0d729 --- /dev/null +++ b/src/BloomBrowserUI/scripts/watchLess.d.ts @@ -0,0 +1,34 @@ +import type { RenderOutput } from "less"; + +export interface LessWatchTarget { + name: string; + root: string; + outputBase: string; + include?: string | string[]; + ignore?: string | string[]; + entries?: string[]; +} + +export interface LessWatchOptions { + repoRoot: string; + metadataPath: string; + targets: LessWatchTarget[]; + logger?: Console; + lessRenderer?: ( + input: string, + options: Record, + ) => Promise; +} + +export class LessWatchManager { + constructor(options: LessWatchOptions); + initialize(): Promise; + startWatching(): Promise; + dispose(): Promise; + handleFileAdded(target: LessWatchTarget, absPath: string): Promise; + handleFileChanged(absPath: string, reason: string): Promise; + handleFileRemoved(absPath: string): Promise; + entryDependencies: Map; + targets: LessWatchTarget[]; + repoRoot: string; +} diff --git a/src/BloomBrowserUI/scripts/watchLess.mjs b/src/BloomBrowserUI/scripts/watchLess.mjs new file mode 100644 index 000000000000..13df048c5aae --- /dev/null +++ b/src/BloomBrowserUI/scripts/watchLess.mjs @@ -0,0 +1,107 @@ +/* eslint-env node */ +/* global console, process */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { LessWatchManager } = require("./watchLessManager.js"); + +export { LessWatchManager }; + +async function runWatcherFromCli() { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const browserUIRoot = path.resolve(__dirname, ".."); + const repoRoot = path.resolve(browserUIRoot, "..", ".."); + const contentRoot = path.resolve(browserUIRoot, "..", "content"); + const outputRoot = path.resolve( + browserUIRoot, + "..", + "..", + "output", + "browser", + ); + + const scopeArg = process.argv.find((arg) => arg.startsWith("--scope=")); + const scope = scopeArg ? scopeArg.split("=")[1] : "all"; + const once = process.argv.includes("--once"); + + const targets = []; + if (scope === "all" || scope === "browser-ui") { + targets.push({ + name: "browser-ui", + root: browserUIRoot, + outputBase: outputRoot, + }); + } + if (scope === "all" || scope === "content") { + targets.push( + { + name: "branding", + root: path.join(contentRoot, "branding"), + outputBase: path.join(outputRoot, "branding"), + }, + { + name: "templates", + root: path.join(contentRoot, "templates"), + outputBase: path.join(outputRoot, "templates"), + }, + { + name: "bookLayout", + root: path.join(contentRoot, "bookLayout"), + outputBase: path.join(outputRoot, "bookLayout"), + entries: ["basePage.less", "canvasElement.less"], + }, + ); + } + + if (targets.length === 0) { + console.error(`Unknown scope "${scope}" supplied to watchLess`); + process.exit(1); + } + + const metadataPath = path.join(outputRoot, ".less-watch-state.json"); + + const quietLogger = { + log: () => {}, + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), + }; + + const manager = new LessWatchManager({ + repoRoot, + metadataPath, + targets, + logger: once ? quietLogger : undefined, + }); + + await manager.initialize(); + if (!once) { + await manager.startWatching(); + } + + function shutdown() { + manager + .dispose() + .catch((err) => console.error("Failed to stop watchers", err)) + .finally(() => process.exit(0)); + } + + if (!once) { + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "watchLess.mjs"; + +if (invokedDirectly) { + runWatcherFromCli().catch((err) => { + console.error("watchLess failed:", err); + process.exit(1); + }); +} diff --git a/src/BloomBrowserUI/scripts/watchLessManager.js b/src/BloomBrowserUI/scripts/watchLessManager.js new file mode 100644 index 000000000000..cd20612fdde6 --- /dev/null +++ b/src/BloomBrowserUI/scripts/watchLessManager.js @@ -0,0 +1,621 @@ +const chokidar = require("chokidar"); +const fs = require("fs"); +const path = require("path"); +const { glob } = require("glob"); +const less = require("less"); + +const isWindows = process.platform === "win32"; +const defaultIgnore = ["**/node_modules/**"]; + +function scanLessImports(sourceText) { + // Capture common LESS import forms: + // @import "a.less"; + // @import (reference) "a.less"; + // @import url("a.less"); + // @import (reference) url("a.less"); + // We intentionally ignore variable-based/dynamic import paths. + const imports = []; + const importRegex = + /@import\s*(?:\([^)]+\)\s*)?(?:url\(\s*)?["']([^"']+)["']\s*\)?\s*;?/gi; + let match; + while ((match = importRegex.exec(sourceText))) { + const raw = (match[1] ?? "").trim(); + if (!raw) { + continue; + } + if ( + raw.includes("@{") || + raw.includes("://") || + raw.startsWith("data:") + ) { + continue; + } + imports.push(raw); + } + return imports; +} + +function resolveLessImport(fromFilePath, importPath) { + if (!importPath || typeof importPath !== "string") { + return null; + } + + // Ignore module-style imports (e.g. ~package/path.less). If these become relevant, + // prefer relying on actual less compilation to discover them. + if (importPath.startsWith("~")) { + return null; + } + + const baseDir = path.dirname(fromFilePath); + const candidate = path.isAbsolute(importPath) + ? importPath + : path.resolve(baseDir, importPath); + + const ext = path.extname(candidate); + const candidates = []; + if (ext) { + candidates.push(candidate); + } else { + candidates.push(`${candidate}.less`); + candidates.push(candidate); + } + + for (const filePath of candidates) { + try { + if (fs.statSync(filePath).isFile()) { + return path.resolve(filePath); + } + } catch { + // try next candidate + } + } + + return null; +} + +function normalizePath(filePath) { + const resolved = path.resolve(filePath); + return isWindows ? resolved.toLowerCase() : resolved; +} + +function ensureDir(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function writeJsonAtomic(filePath, data) { + ensureDir(path.dirname(filePath)); + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + fs.renameSync(tmpPath, filePath); +} + +function pathToRepoRelative(repoRoot, absPath) { + return path.relative(repoRoot, absPath).replace(/\\/g, "/"); +} + +function repoRelativeToAbsolute(repoRoot, relativePath) { + return path.resolve(repoRoot, relativePath); +} + +function toArray(value, fallback) { + if (!value) { + return fallback.slice(); + } + return Array.isArray(value) ? value.slice() : [value]; +} + +class LessWatchManager { + constructor(options) { + this.repoRoot = options.repoRoot; + this.metadataPath = options.metadataPath; + this.targets = options.targets.map((target) => ({ + ...target, + root: path.resolve(target.root), + outputBase: path.resolve(target.outputBase), + include: toArray(target.include, ["**/*.less"]), + ignore: toArray(target.ignore, defaultIgnore), + explicitEntries: target.entries?.map((entry) => + path.resolve(target.root, entry), + ), + })); + this.logger = options.logger ?? console; + this.lessRenderer = options.lessRenderer ?? less.render; + this.metadataVersion = 1; + + this.entries = new Map(); + this.entryByPath = new Map(); + this.entryDependencies = new Map(); + this.dependencyToEntries = new Map(); + this.pendingBuilds = new Map(); + this.watchers = []; + this.dependencyWatcher = null; + + this.compiledCount = 0; + } + + async initialize() { + await this.loadMetadata(); + await this.registerInitialEntries(); + await this.ensureEntryDependenciesKnown(); + await this.ensureOutputsUpToDate(); + } + + async ensureEntryDependenciesKnown() { + // The watcher relies on the dependency graph to know what to rebuild. + // If we have outputs already (built by some other pipeline) and no metadata yet, + // we still need a best-effort dependency graph so changes rebuild dependents. + for (const entry of this.entries.values()) { + const deps = this.entryDependencies.get(entry.id); + if (deps && deps.length > 0) { + continue; + } + + const lessInput = fs.readFileSync(entry.entryPath, "utf8"); + const imports = scanLessImports(lessInput) + .map((importPath) => + resolveLessImport(entry.entryPath, importPath), + ) + .filter((dep) => !!dep); + + const dependencies = [path.resolve(entry.entryPath), ...imports]; + this.updateEntryDependencies(entry.id, dependencies); + } + } + + async startWatching() { + if (this.watchers.length > 0) { + return; + } + + const failFast = (promise) => + promise.catch((err) => { + this.logger.error(`[LESS] watcher failure:`, err); + process.exit(1); + }); + + for (const target of this.targets) { + const watcher = chokidar.watch(target.include, { + cwd: target.root, + ignored: target.ignore, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 50, + }, + }); + + watcher + .on("add", (file) => + failFast( + this.handleFileAdded( + target, + path.join(target.root, file), + ), + ), + ) + .on("change", (file) => + failFast( + this.handleFileChanged( + path.join(target.root, file), + `modified ${path.basename(file)}`, + ), + ), + ) + .on("unlink", (file) => + failFast( + this.handleFileRemoved(path.join(target.root, file)), + ), + ); + + this.watchers.push(watcher); + } + + this.dependencyWatcher = chokidar.watch([], { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 50, + }, + }); + + this.dependencyWatcher + .on("change", (file) => + failFast(this.handleFileChanged(file, "dependency changed")), + ) + .on("unlink", (file) => failFast(this.handleFileRemoved(file))); + + this.primeDependencyWatcher(); + } + + async dispose() { + for (const watcher of this.watchers) { + await watcher.close(); + } + this.watchers = []; + if (this.dependencyWatcher) { + await this.dependencyWatcher.close(); + this.dependencyWatcher = null; + } + } + + async loadMetadata() { + const data = readJson(this.metadataPath); + if (!data || data.version !== this.metadataVersion) { + return; + } + + for (const [entryId, deps] of Object.entries(data.entries ?? {})) { + const absDeps = deps.map((dep) => + repoRelativeToAbsolute(this.repoRoot, dep), + ); + this.entryDependencies.set(entryId, absDeps); + for (const dep of absDeps) { + this.linkDependency(entryId, dep, { watch: false }); + } + } + } + + async registerInitialEntries() { + for (const target of this.targets) { + const files = target.explicitEntries + ? target.explicitEntries + : await this.globEntries(target); + for (const file of files) { + this.registerEntry(file, target); + } + } + + for (const entryId of Array.from(this.entryDependencies.keys())) { + if (!this.entries.has(entryId)) { + this.clearDependencyMappings(entryId); + this.entryDependencies.delete(entryId); + } + } + } + + async globEntries(target) { + const results = new Set(); + for (const pattern of target.include) { + const files = await glob(pattern, { + cwd: target.root, + ignore: target.ignore, + nodir: true, + absolute: true, + }); + for (const file of files) { + results.add(path.resolve(file)); + } + } + return Array.from(results); + } + + registerEntry(entryPath, target) { + const absPath = path.resolve(entryPath); + const key = pathToRepoRelative(this.repoRoot, absPath); + if (this.entries.has(key)) { + return this.entries.get(key); + } + + const relativeToRoot = path.relative(target.root, absPath); + const outputPath = path.join( + target.outputBase, + relativeToRoot.replace(/\.less$/i, ".css"), + ); + + const entry = { + id: key, + entryPath: absPath, + outputPath, + target, + }; + + this.entries.set(key, entry); + this.entryByPath.set(normalizePath(absPath), key); + return entry; + } + + async ensureOutputsUpToDate() { + for (const entry of this.entries.values()) { + if (await this.needsBuild(entry)) { + await this.compileEntry(entry, "initial sync"); + } + } + await this.persistMetadata(); + } + + async needsBuild(entry) { + const outputMTime = this.getMTime(entry.outputPath); + if (!outputMTime) { + return true; + } + + const deps = this.entryDependencies.get(entry.id); + if (!deps || deps.length === 0) { + const sourceTime = this.getMTime(entry.entryPath); + return !sourceTime || sourceTime > outputMTime; + } + + for (const dep of deps) { + const depTime = this.getMTime(dep); + if (!depTime || depTime > outputMTime) { + return true; + } + } + return false; + } + + getMTime(filePath) { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } + } + + async handleFileAdded(target, absPath) { + this.registerEntry(absPath, target); + await this.queueBuildForPath(absPath, "file added"); + } + + async handleFileChanged(absPath, reason) { + const affected = this.collectAffectedEntries(absPath); + await Promise.all( + Array.from(affected).map((entryId) => + this.queueBuild(entryId, reason), + ), + ); + } + + async handleFileRemoved(absPath) { + const affected = this.collectAffectedEntries(absPath); + const key = normalizePath(absPath); + const entryId = this.entryByPath.get(key); + if (entryId) { + await this.removeEntry(entryId); + } + + await Promise.all( + Array.from(affected).map((affectedEntryId) => + this.queueBuild(affectedEntryId, "dependency removed"), + ), + ); + } + + collectAffectedEntries(absPath) { + // Rebuild entries that directly include the changed file, and also rebuild any entries + // that depend on those entries (transitively). + // Example: bloomWebFonts.less -> bloomUI.less -> editMode.less + const startKey = normalizePath(absPath); + const affected = new Set(); + const visitedFileKeys = new Set([startKey]); + const queue = [startKey]; + + while (queue.length > 0) { + const fileKey = queue.shift(); + + const directEntryId = this.entryByPath.get(fileKey); + if (directEntryId) { + affected.add(directEntryId); + } + + const dependents = this.dependencyToEntries.get(fileKey); + if (!dependents) { + continue; + } + + for (const dependentEntryId of dependents) { + if (!this.entries.has(dependentEntryId)) { + continue; + } + affected.add(dependentEntryId); + + const dependentEntry = this.entries.get(dependentEntryId); + if (!dependentEntry) { + continue; + } + const dependentEntryPathKey = normalizePath( + dependentEntry.entryPath, + ); + if (!visitedFileKeys.has(dependentEntryPathKey)) { + visitedFileKeys.add(dependentEntryPathKey); + queue.push(dependentEntryPathKey); + } + } + } + + return affected; + } + + async queueBuildForPath(absPath, reason) { + const key = normalizePath(absPath); + const entryId = this.entryByPath.get(key); + if (!entryId) { + return; + } + await this.queueBuild(entryId, reason); + } + + async queueBuild(entryId, reason) { + if (!this.entries.has(entryId)) { + return; + } + const pending = this.pendingBuilds.get(entryId) ?? Promise.resolve(); + const next = pending + .catch(() => {}) + .then(() => this.compileEntry(this.entries.get(entryId), reason)); + + this.pendingBuilds.set( + entryId, + next.finally(() => { + if (this.pendingBuilds.get(entryId) === next) { + this.pendingBuilds.delete(entryId); + } + }), + ); + + await next; + } + + async compileEntry(entry, reason) { + ensureDir(path.dirname(entry.outputPath)); + const lessInput = fs.readFileSync(entry.entryPath, "utf8"); + const result = await this.lessRenderer(lessInput, { + filename: entry.entryPath, + sourceMap: { + sourceMapFileInline: false, + outputSourceFiles: true, + sourceMapURL: `${path.basename(entry.outputPath)}.map`, + }, + }); + + let css = result.css; + if (result.map) { + css += `\n/*# sourceMappingURL=${path.basename(entry.outputPath)}.map */`; + fs.writeFileSync(`${entry.outputPath}.map`, result.map); + } + fs.writeFileSync(entry.outputPath, css); + + const dependencies = (result.imports ?? []).map((dep) => + path.resolve(dep), + ); + if (!dependencies.includes(path.resolve(entry.entryPath))) { + dependencies.unshift(path.resolve(entry.entryPath)); + } + + this.updateEntryDependencies(entry.id, dependencies); + await this.persistMetadata(); + + this.compiledCount += 1; + + this.logger.log( + `[LESS] ✓ ${entry.id} (${reason ?? "recompiled"}) → ${pathToRepoRelative( + this.repoRoot, + entry.outputPath, + )}`, + ); + } + + clearDependencyMappings(entryId) { + const prevDeps = this.entryDependencies.get(entryId) ?? []; + for (const dep of prevDeps) { + const key = normalizePath(dep); + const set = this.dependencyToEntries.get(key); + if (set) { + set.delete(entryId); + if (set.size === 0) { + this.dependencyToEntries.delete(key); + if (this.dependencyWatcher) { + this.dependencyWatcher.unwatch(dep); + } + } + } + } + } + + linkDependency(entryId, dep, options = {}) { + const abs = path.resolve(dep); + const key = normalizePath(abs); + let set = this.dependencyToEntries.get(key); + if (!set) { + set = new Set(); + this.dependencyToEntries.set(key, set); + } + set.add(entryId); + + const shouldWatch = options.watch ?? false; + if ( + shouldWatch && + this.dependencyWatcher && + !this.isInsideKnownTarget(abs) + ) { + this.dependencyWatcher.add(abs); + } + return abs; + } + + updateEntryDependencies(entryId, dependencies) { + this.clearDependencyMappings(entryId); + + const uniqueDeps = []; + const seen = new Set(); + for (const dep of dependencies) { + const abs = path.resolve(dep); + const key = normalizePath(abs); + if (seen.has(key)) { + continue; + } + seen.add(key); + uniqueDeps.push(abs); + this.linkDependency(entryId, abs, { watch: true }); + } + + this.entryDependencies.set(entryId, uniqueDeps); + } + + isInsideKnownTarget(filePath) { + const absPath = path.resolve(filePath); + return this.targets.some( + (target) => + absPath === target.root || + absPath.startsWith(`${target.root}${path.sep}`), + ); + } + + async removeEntry(entryId) { + const entry = this.entries.get(entryId); + if (!entry) { + return; + } + this.entries.delete(entryId); + this.entryByPath.delete(normalizePath(entry.entryPath)); + this.clearDependencyMappings(entryId); + this.entryDependencies.delete(entryId); + if (fs.existsSync(entry.outputPath)) { + fs.unlinkSync(entry.outputPath); + } + if (fs.existsSync(`${entry.outputPath}.map`)) { + fs.unlinkSync(`${entry.outputPath}.map`); + } + await this.persistMetadata(); + this.logger.warn(`[LESS] removed entry ${entryId}`); + } + + async persistMetadata() { + const entries = {}; + for (const [entryId, deps] of this.entryDependencies.entries()) { + entries[entryId] = deps.map((dep) => + path.relative(this.repoRoot, dep).replace(/\\/g, "/"), + ); + } + writeJsonAtomic(this.metadataPath, { + version: this.metadataVersion, + entries, + }); + } + + primeDependencyWatcher() { + if (!this.dependencyWatcher) { + return; + } + + for (const deps of this.entryDependencies.values()) { + for (const dep of deps ?? []) { + if (!this.isInsideKnownTarget(dep)) { + this.dependencyWatcher.add(dep); + } + } + } + } +} + +module.exports = { LessWatchManager }; diff --git a/src/BloomBrowserUI/vite.config.mts b/src/BloomBrowserUI/vite.config.mts index dac69aa98036..76637904e640 100644 --- a/src/BloomBrowserUI/vite.config.mts +++ b/src/BloomBrowserUI/vite.config.mts @@ -13,12 +13,12 @@ import { glob } from "glob"; import react from "@vitejs/plugin-react"; import { viteStaticCopy } from "vite-plugin-static-copy"; import * as fs from "fs"; -import less from "less"; import MarkdownIt from "markdown-it"; import markdownItContainer from "markdown-it-container"; import markdownItAttrs from "markdown-it-attrs"; import { playwright } from "@vitest/browser-playwright"; import { compilePugFiles } from "./scripts/compilePug.mjs"; +import { compileLessFiles } from "./scripts/compileLess.mjs"; // Custom plugin to compile Pug files to HTML // There are a couple of npm packages for pug, but as of October 2025, they are experimental @@ -38,73 +38,14 @@ function compilePugPlugin(): Plugin { // Custom plugin to compile LESS files to CSS // Similar to pug plugin - compiles standalone LESS files to CSS with sourcemaps +// Handles both BloomBrowserUI and content LESS files // Claude sonnet 4.5 came up with this. function compileLessPlugin(): Plugin { return { name: "compile-less", apply: "build", async closeBundle() { - // Find LESS files in BloomBrowserUI - const lessFiles = glob.sync("./**/*.less", { - ignore: ["**/node_modules/**"], - }); - - console.log(`\nCompiling ${lessFiles.length} LESS files...`); - - const outputBase = path.resolve(__dirname, "../../output/browser"); - - for (const file of lessFiles) { - // Normalize path separators - const normalizedFile = file.replace(/\\/g, "/"); - - // Convert to output path: "./bookEdit/css/editMode.less" -> "bookEdit/css/editMode.css" - const relativePath = normalizedFile - .replace("./", "") - .replace(".less", ".css"); - - const outputFile = path.join(outputBase, relativePath); - const outputDir = path.dirname(outputFile); - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - try { - // Read LESS file - const lessContent = fs.readFileSync(file, "utf8"); - - // Compile LESS to CSS with sourcemap - const result = await less.render(lessContent, { - filename: file, - sourceMap: { - sourceMapFileInline: false, - outputSourceFiles: true, - sourceMapURL: path.basename(outputFile) + ".map", - }, - }); - - // Write CSS file with sourcemap reference - let cssOutput = result.css; - if (result.map) { - cssOutput += `\n/*# sourceMappingURL=${path.basename(outputFile)}.map */`; - } - fs.writeFileSync(outputFile, cssOutput); - - // Write sourcemap if generated - if (result.map) { - const mapFile = outputFile + ".map"; - fs.writeFileSync(mapFile, result.map); - } - - console.log(` ✓ ${file} → ${relativePath}`); - } catch (error) { - console.error(` ✗ Error compiling ${file}:`, error); - throw error; // Exit build on LESS compilation error - } - } - - console.log(`LESS compilation complete!\n`); + await compileLessFiles(); }, }; } diff --git a/src/BloomBrowserUI/yarn.lock b/src/BloomBrowserUI/yarn.lock index ee407ac4f839..e142e06819b6 100644 --- a/src/BloomBrowserUI/yarn.lock +++ b/src/BloomBrowserUI/yarn.lock @@ -1611,6 +1611,16 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@blakeembrey/deque@^1.0.5": + version "1.0.5" + resolved "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz#f4fa17fc5ee18317ec01a763d355782c7b395eaf" + integrity sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg== + +"@blakeembrey/template@^1.0.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@blakeembrey/template/-/template-1.2.0.tgz#acd948eb29334e882019e8876b42ff6da1dcf528" + integrity sha512-w/63nURdkRPpg3AXbNr7lPv6HgOuVDyefTumiXsbXxtIwcuk5EXayWR5OpSwDjsQPgaYsfUSedMduaNOjAYY8A== + "@chromatic-com/storybook@4.1.2": version "4.1.2" resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-4.1.2.tgz#d9e6d5a552e125f40c78ac494706f0bd5c2147a2" @@ -3826,6 +3836,11 @@ anymatch@~3.1.1, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -4490,6 +4505,21 @@ chokidar@^1.6.0: optionalDependencies: fsevents "^1.0.0" +chokidar@^3.3.1, chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + chokidar@^3.4.0: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" @@ -4505,21 +4535,6 @@ chokidar@^3.4.0: optionalDependencies: fsevents "~2.3.1" -chokidar@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" - integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - chromatic@^12.0.0: version "12.2.0" resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-12.2.0.tgz#2f22865d66fa82d7c5565170f70eabb613223671" @@ -4876,7 +4891,7 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -8319,6 +8334,19 @@ once@^1.3.0: dependencies: wrappy "1" +onchange@^7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz#a6f0f7733e4d47014b4cd70aa1ad36c2b4cf3804" + integrity sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg== + dependencies: + "@blakeembrey/deque" "^1.0.5" + "@blakeembrey/template" "^1.0.0" + arg "^4.1.3" + chokidar "^3.3.1" + cross-spawn "^7.0.1" + ignore "^5.1.4" + tree-kill "^1.2.2" + onecolor@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/onecolor/-/onecolor-2.5.0.tgz#2256b651dc807c101f00aedbd49925c57a4431c1" @@ -10727,6 +10755,11 @@ tr46@^5.1.0: dependencies: punycode "^2.3.1" +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + ts-api-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" diff --git a/src/content/package.json b/src/content/package.json index a8c1cb3fd778..a8fc32f444e7 100644 --- a/src/content/package.json +++ b/src/content/package.json @@ -2,38 +2,39 @@ "license": "MIT", "scripts": { "build": "npm-run-all build:pug build:less build:branding:files build:templates:files build:appearance build:appearanceMigrations", - "watch": "npm-run-all watch:pug watch:less watch:branding:files", + "watch": "npm-run-all watch:pug watch:branding:files", + "watch:dev": "npm-run-all --parallel watch:branding:files watch:templates:files watch:appearance watch:appearanceMigrations", "//1": "TODO: currently we haven't figured this out with the simple build tools we're using here, so we're just building *all* pugs in this directory along with BloomBrowserUI/", "//2": "See https://github.com/Qard/onchange/issues/118", "//3": "The key to navigating these issues it to realize that the problem doesn't exist outside windows. Elsewhere, the terminal expands globs.", "//4": "I'm not making this actually do the BloomBrowserUI pug build, because it's currently part of the whole vite build, which turns around and builds this, so could be recursive.", - "build:pug": "echo \"Run a build in BloomBrowserUI to get pug files built.\"", + "build:pug": "node -e \"\"", "//NB we don't have a version of pug that handles globs, so we wrap with onchange": "", "//Notice that onchange can steer files to the right folder, which isn't doable with the command with built-in glob handling": "", "The -j option is how many jobs to do in parallel": "", "The -a option says to run them all before watching": "", "watch:pug": "onchange -j 9 -a \"**/*.pug\" -- pug {{file}} --pretty --out ../../output/browser/{{fileDir}}", "//": "______________________________________________________________________________________________________", - "build:pageSizes": "yarn ts-node pageSizes.ts", + "build:pageSizes": "ts-node pageSizes.ts", "build:less": "npm-run-all build:pageSizes build:less-inner", - "build:less-inner": "npm-run-all --parallel build:branding:less build:templates:less build:layout:less", + "build:less-inner": "node ../BloomBrowserUI/scripts/watchLess.mjs --scope=content --once", "// there is no current way to exclude node_modules, so we can't just do the whole directory.": "https://github.com/jonycheung/deadsimple-less-watch-compiler/issues/72", - "watch:less": "npm-run-all --parallel watch:branding:less watch:templates:less watch:layout:less", - "build:templates:files": "cpx \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" \"../../output/browser/templates\" -v", - "watch:templates:files": "cpx \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" \"../../output/browser/templates\" --watch -v", - "build:branding:files": "cpx \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm}\" ../../output/browser/branding -v", + "watch:less": "node ../BloomBrowserUI/scripts/watchLess.mjs --scope=content", + "build:templates:files": "cpx \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" \"../../output/browser/templates\"", + "watch:templates:files": "onchange -k -i -a \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" -- node ./scripts/copyContentFile.mjs {{file}} templates templates", + "build:branding:files": "cpx \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm}\" ../../output/browser/branding", "build:branding:less": "less-watch-compiler --source-map --run-once branding ../../output/browser/branding", "build:templates:less": "less-watch-compiler --source-map --run-once templates ../../output/browser/templates", "build:layout:less": "less-watch-compiler --source-map --run-once bookLayout ../../output/browser/bookLayout", "build:layout:less:main": "lessc --source-map bookLayout/basePage.less ../../output/browser/bookLayout/basePage.css && lessc --source-map bookLayout/canvasElement.less ../../output/browser/bookLayout/canvasElement.css", - "build:appearance": "yarn rimraf ../../output/browser/appearanceThemes && cpx \"appearanceThemes/**/*.css\" ../../output/browser/appearanceThemes -v", - "watch:appearance": "cpx \"appearanceThemes/**/*.css\" ../../output/browser/appearanceThemes --watch -v", - "watch:branding:files": "cpx \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}\" ../../output/browser/branding --watch -v", - "build:appearanceMigrations": "cpx \"appearanceMigrations/**\" ../../output/browser/appearanceMigrations -v", - "watch:appearanceMigrations": "cpx \"appearanceMigrations/**\" ../../output/browser/appearanceMigrations --watch -v", - "watch:branding:less": "onchange -k -i -a \"branding/**/*.less\" -- npm run build:branding:less", - "watch:templates:less": "onchange -k -i -a \"templates/**/*.less\" -- npm run build:templates:less", - "watch:layout:less": "onchange -k -i -a \"bookLayout/**/*.less\" -- npm run build:layout:less:main", + "build:appearance": "rimraf ../../output/browser/appearanceThemes && cpx \"appearanceThemes/**/*.css\" ../../output/browser/appearanceThemes", + "watch:appearance": "onchange -k -i -a \"appearanceThemes/**/*.css\" -- node ./scripts/copyContentFile.mjs {{file}} appearanceThemes appearanceThemes", + "watch:branding:files": "onchange -k -i -a \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}\" -- node ./scripts/copyContentFile.mjs {{file}} branding branding", + "build:appearanceMigrations": "cpx \"appearanceMigrations/**\" ../../output/browser/appearanceMigrations", + "watch:appearanceMigrations": "onchange -k -i -a \"appearanceMigrations/**\" -- node ./scripts/copyContentFile.mjs {{file}} appearanceMigrations appearanceMigrations", + "watch:branding:less": "onchange -k -i -a \"branding/**/*.less\" -- yarn build:branding:less", + "watch:templates:less": "onchange -k -i -a \"templates/**/*.less\" -- yarn build:templates:less", + "watch:layout:less": "onchange -k -i -a \"bookLayout/**/*.less\" -- yarn build:layout:less:main", "///": "______________________________________________________________________________________________________", "//This is slow compared to less-watch-compiler: watch:less": "onchange -a \"**/*.less\" -- lessc {{file}} ../../output/browser/{{fileDir}}", "//Note that this is really slow compared to cpx with the glob (buildBrandingFiles/watchBrandingFiles)": "//", diff --git a/src/content/scripts/copyContentFile.mjs b/src/content/scripts/copyContentFile.mjs new file mode 100644 index 000000000000..267e86b193b0 --- /dev/null +++ b/src/content/scripts/copyContentFile.mjs @@ -0,0 +1,94 @@ +/* eslint-env node */ +import path from "node:path"; +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const contentRoot = path.resolve(__dirname, ".."); +const outputRoot = path.resolve(contentRoot, "../../output/browser"); + +function needsCopy(sourceFile, destinationFile) { + if (!fs.existsSync(destinationFile)) { + return true; + } + + const sourceStat = fs.statSync(sourceFile); + const destinationStat = fs.statSync(destinationFile); + + if (sourceStat.size !== destinationStat.size) { + return true; + } + + return sourceStat.mtimeMs > destinationStat.mtimeMs; +} + +function copyContentFile(filePath, sourceBase, destinationBase, options = {}) { + const quiet = options.quiet ?? true; + if (!filePath || !sourceBase) { + return false; + } + + const absolutePath = path.resolve(filePath); + + if (!fs.existsSync(absolutePath)) { + return false; + } + + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + return false; + } + + const sourceBasePath = path.resolve(contentRoot, sourceBase); + if ( + !absolutePath.startsWith(sourceBasePath + path.sep) && + absolutePath !== sourceBasePath + ) { + return false; + } + + const relativePath = path.relative(sourceBasePath, absolutePath); + const destinationRoot = path.resolve(outputRoot, destinationBase || "."); + const destinationFile = path.join(destinationRoot, relativePath); + + if (!needsCopy(absolutePath, destinationFile)) { + return false; + } + + const destinationDir = path.dirname(destinationFile); + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir, { recursive: true }); + } + + fs.copyFileSync(absolutePath, destinationFile); + + const fromDisplay = path + .relative(contentRoot, absolutePath) + .replace(/\\/g, "/"); + const toDisplay = path + .relative(outputRoot, destinationFile) + .replace(/\\/g, "/"); + + if (!quiet) { + console.log(` ✓ Copied ${fromDisplay} -> ${toDisplay}`); + } + return true; +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "copyContentFile.mjs"; + +if (invokedDirectly) { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + const filtered = args.filter((arg) => arg !== "--verbose"); + const [filePath, sourceBase = ".", destinationBase = "."] = filtered; + copyContentFile(filePath, sourceBase, destinationBase, { + quiet: !verbose, + }); +} + +export { copyContentFile };