diff --git a/spago.dhall b/spago.dhall index 28ed1b6..2504af6 100644 --- a/spago.dhall +++ b/spago.dhall @@ -29,12 +29,14 @@ , "node-readline" , "optparse" , "profunctor" + , "profunctor-lenses" , "search-trie" , "string-parsers" , "strings" , "test-unit" , "web-dom" , "web-html" + , "web-storage" ] , packages = ./packages.dhall diff --git a/src/Docs/Search/App.purs b/src/Docs/Search/App.purs index 0359584..ce014e9 100644 --- a/src/Docs/Search/App.purs +++ b/src/Docs/Search/App.purs @@ -1,13 +1,14 @@ -- | This is the main module of the client-side Halogen app. module Docs.Search.App where -import Prelude - import Docs.Search.App.SearchField as SearchField import Docs.Search.App.SearchResults as SearchResults +import Docs.Search.App.Sidebar as Sidebar import Docs.Search.Extra (whenJust) +import Docs.Search.ModuleIndex as ModuleIndex import Docs.Search.PackageIndex as PackageIndex +import Prelude import Control.Coroutine as Coroutine import Data.Maybe (Maybe(..)) import Data.Newtype (wrap) @@ -17,6 +18,7 @@ import Halogen as H import Halogen.Aff as HA import Halogen.VDom.Driver (runUI) import MarkdownIt as MD +import Web.DOM.ChildNode as ChildNode import Web.DOM.Document as Document import Web.DOM.Element as Element import Web.DOM.Node as Node @@ -24,10 +26,12 @@ import Web.DOM.ParentNode as ParentNode import Web.DOM.Text as Text import Web.Event.EventTarget (addEventListener, eventListener) import Web.HTML as HTML +import Web.HTML.Event.HashChangeEvent.EventTypes (hashchange) import Web.HTML.HTMLDocument as HTMLDocument import Web.HTML.HTMLElement (fromElement) import Web.HTML.Window as Window -import Web.HTML.Event.HashChangeEvent.EventTypes (hashchange) +import Web.HTML.Event.EventTypes (focus) + main :: Effect Unit main = do @@ -40,11 +44,21 @@ main = do -- Initialize a `markdown-it` instance (we need it to render the docs as markdown) markdownIt <- MD.newMarkdownIt MD.Default mempty - whenJust mbContainers \ { searchField, searchResults, pageContents } -> do + whenJust mbContainers \ { searchField + , searchResults + , pageContents + , sidebarContainer + , realSidebar } -> do + + -- Hide real sidebar completely - we are going to recreate it as Halogen component. + ChildNode.remove $ Element.toChildNode realSidebar + HA.runHalogenAff do packageIndex <- PackageIndex.loadPackageIndex + moduleIndex <- ModuleIndex.loadModuleIndex let initialSearchEngineState = { packageIndex: packageIndex + , moduleIndex: moduleIndex , index: mempty , typeIndex: mempty } @@ -53,6 +67,9 @@ main = do SearchResults.mkComponent initialSearchEngineState pageContents markdownIt sfio <- runUI SearchField.component unit searchField + sbio <- do + component <- Sidebar.mkComponent moduleIndex + runUI component unit sidebarContainer srio <- runUI resultsComponent unit searchResults sfio.subscribe $ @@ -72,6 +89,16 @@ main = do addEventListener hashchange listener true (Window.toEventTarget win) + -- Subscribe to window focus events + H.liftEffect do + + listener <- + eventListener \event -> + launchAff_ do + sbio.query $ H.tell Sidebar.UpdateModuleGrouping + + addEventListener focus listener true (Window.toEventTarget win) + insertStyle :: Document.Document -> Effect Unit insertStyle doc = do @@ -102,24 +129,49 @@ insertStyle doc = do /* Add a margin between badge icons and package/module names. */ margin-right: 0.25em; } + .li-package { + font-weight: bold; + cursor: pointer; + color: #c4953a; + } + /* Make spaces narrower in the sidebar */ + .li-package > details > ul { + margin-top: auto; + margin-bottom: auto; + } + #group-modules__label, #group-modules__input { + display: inline-block; + vertical-align: middle; + color: #666666; + font-size: 0.8rem; + font-weight: 300; + letter-spacing: 1px; + margin-bottom: -0.8em; + } + summary:focus { + outline: none; + } """ mbHead <- ParentNode.querySelector (wrap "head") (Document.toParentNode doc) whenJust mbHead \head -> do - contents <- Document.createTextNode styleContents doc style <- Document.createElement "style" doc void $ Node.appendChild (Text.toNode contents) (Element.toNode style) void $ Node.appendChild (Element.toNode style) (Element.toNode head) + -- | Query the DOM for specific elements that should always be present. getContainers :: Document.Document -> Effect (Maybe { searchField :: HTML.HTMLElement , searchResults :: HTML.HTMLElement - , pageContents :: Element.Element }) + , pageContents :: Element.Element + , sidebarContainer :: HTML.HTMLElement + , realSidebar :: Element.Element + }) getContainers doc = do let docPN = Document.toParentNode doc mbBanner <- @@ -128,13 +180,21 @@ getContainers doc = do ParentNode.querySelector (wrap ".everything-except-footer") docPN mbContainer <- ParentNode.querySelector (wrap ".everything-except-footer > .container") docPN + mbMainContainer <- + ParentNode.querySelector (wrap ".everything-except-footer > main") docPN + mbRealSidebar <- + ParentNode.querySelector (wrap ".col--aside") docPN case unit of - _ | Just banner <- mbBanner - , Just everything <- mbEverything - , Just pageContents <- mbContainer -> do + _ | Just banner <- mbBanner + , Just everything <- mbEverything + , Just pageContents <- mbContainer + , Just mainContainer <- mbMainContainer + , Just realSidebar <- mbRealSidebar -> do search <- Document.createElement "div" doc void $ Node.appendChild (Element.toNode search) (Element.toNode banner) - pure $ fromElement search >>= \searchField -> - fromElement everything >>= \searchResults -> - pure { searchField, searchResults, pageContents } + pure do + searchField <- fromElement search + searchResults <- fromElement everything + sidebarContainer <- fromElement mainContainer + pure { searchField, searchResults, pageContents, realSidebar, sidebarContainer } | otherwise -> pure Nothing diff --git a/src/Docs/Search/App/SearchResults.purs b/src/Docs/Search/App/SearchResults.purs index a79f494..11c6d45 100644 --- a/src/Docs/Search/App/SearchResults.purs +++ b/src/Docs/Search/App/SearchResults.purs @@ -168,7 +168,7 @@ hidePageContents hidePageContents = do state <- H.get H.liftEffect do - Element.setAttribute "style" "display: none" state.contents + Element.setAttribute "style" "display: none;" state.contents render diff --git a/src/Docs/Search/App/Sidebar.purs b/src/Docs/Search/App/Sidebar.purs new file mode 100644 index 0000000..c69b510 --- /dev/null +++ b/src/Docs/Search/App/Sidebar.purs @@ -0,0 +1,157 @@ +module Docs.Search.App.Sidebar where + +import Docs.Search.Config (config) +import Docs.Search.ModuleIndex (ModuleIndex) +import Docs.Search.Types (ModuleName, PackageName) + +import Prelude +import Data.Array as Array +import Data.Lens ((.~)) +import Data.Lens.Record (prop) +import Data.List (foldr) +import Data.Map (Map) +import Data.Map as Map +import Data.Maybe (Maybe(..), isJust) +import Data.Newtype (wrap) +import Data.Set (Set) +import Data.Set as Set +import Data.Symbol (SProxy(..)) +import Data.Tuple.Nested (type (/\), (/\)) +import Effect (Effect) +import Effect.Aff (Aff) +import Halogen as H +import Halogen.HTML as HH +import Halogen.HTML.Events as HE +import Halogen.HTML.Properties as HP +import Web.HTML as HTML +import Web.HTML.Window as Window +import Web.Storage.Storage as Storage + + +data Action = ToggleGrouping GroupingMode + +data Query a = UpdateModuleGrouping a + +data GroupingMode = GroupByPackage | DontGroup + +derive instance groupingModeEq :: Eq GroupingMode + + +type State = { moduleIndex :: Map PackageName (Set ModuleName) + , groupingMode :: GroupingMode + , moduleNames :: Array ModuleName + } + + +mkComponent + :: forall i + . ModuleIndex + -> Aff (H.Component HH.HTML Query i Action Aff) +mkComponent moduleIndex = do + groupingMode <- H.liftEffect loadGroupingModeFromLocalStorage + pure $ + H.mkComponent + { initialState: const { moduleIndex + , groupingMode + , moduleNames + } + , render + , eval: H.mkEval $ H.defaultEval { handleAction = handleAction + , handleQuery = handleQuery + } + } + where + moduleNames = Array.sort $ Array.fromFoldable $ foldr Set.union mempty moduleIndex + + +handleAction + :: forall o + . Action + -> H.HalogenM State Action () o Aff Unit +handleAction (ToggleGrouping groupingMode) = do + H.modify_ (_groupingMode .~ groupingMode) + + H.liftEffect do + window <- HTML.window + localStorage <- Window.localStorage window + + if groupingMode == GroupByPackage + then Storage.setItem config.groupModulesItem "true" localStorage + else Storage.removeItem config.groupModulesItem localStorage + + +handleQuery + :: forall a i + . Query a + -> H.HalogenM State i () Action Aff (Maybe a) +handleQuery (UpdateModuleGrouping next) = do + oldGroupingMode <- H.get <#> _.groupingMode + newGroupingMode <- H.liftEffect loadGroupingModeFromLocalStorage + when (oldGroupingMode /= newGroupingMode) do + H.modify_ (_groupingMode .~ newGroupingMode) + pure Nothing + + +render + :: forall m + . State + -> H.ComponentHTML Action () m +render { moduleIndex, groupingMode, moduleNames } = + + HH.div [ HP.classes [ wrap "col", wrap "col--aside" ] ] + + [ HH.h3_ [ HH.text "Modules" ] + , HH.input [ HP.id_ "group-modules__input" + , HP.type_ HP.InputCheckbox + , HP.checked (groupingMode == GroupByPackage) + , HE.onChecked $ Just <<< ToggleGrouping <<< isCheckedToGroupingMode + ] + + , HH.text " " + , HH.label [ HP.for "group-modules__input" + , HP.id_ "group-modules__label" + ] + [ HH.text " GROUP BY PACKAGE" ] + + , HH.ul_ $ if groupingMode == GroupByPackage + then renderPackageEntry <$> packageList + else renderModuleName <$> moduleNames + ] + where + + renderPackageEntry (packageName /\ modules) = + HH.li [ HP.classes [ wrap "li-package" ] ] + [ HH.details_ + [ HH.summary_ [ HH.text packageName ] + , HH.ul_ $ Set.toUnfoldable modules <#> renderModuleName + ] + ] + + renderModuleName moduleName = + HH.li_ + [ HH.a [ HP.href (moduleName <> ".html") ] + [ HH.text moduleName ] + ] + + packageList :: Array (String /\ Set ModuleName) + packageList = Map.toUnfoldable moduleIndex + + +-- | Decide whether to group modules by package in the sidebar, using localStorage. +loadGroupingModeFromLocalStorage :: Effect GroupingMode +loadGroupingModeFromLocalStorage = do + window <- HTML.window + localStorage <- Window.localStorage window + mbGroupModules <- Storage.getItem config.groupModulesItem localStorage + pure $ if isJust mbGroupModules then GroupByPackage else DontGroup + + +-- | Convert checkbox status to sidebar mode +isCheckedToGroupingMode :: Boolean -> GroupingMode +isCheckedToGroupingMode = if _ then GroupByPackage else DontGroup + + +-- Some optics: + +_groupingMode :: forall a b rest. (a -> b) -> { groupingMode :: a | rest } -> { groupingMode :: b | rest } +_groupingMode = prop (SProxy :: SProxy "groupingMode") diff --git a/src/Docs/Search/Config.purs b/src/Docs/Search/Config.purs index bf5c4cb..86b9fab 100644 --- a/src/Docs/Search/Config.purs +++ b/src/Docs/Search/Config.purs @@ -7,6 +7,9 @@ config :: { declIndexDirectory :: String , mkIndexPartLoadPath :: Int -> String , mkIndexPartPath :: Int -> String + , moduleIndexPath :: String + , moduleIndexLoadPath :: String + , groupModulesItem :: String , packageInfoPath :: String , packageInfoLoadPath :: String , mkShapeScriptPath :: String -> String @@ -43,9 +46,13 @@ config = \(partId :: Int) -> "html/index/declarations/" <> show partId <> ".js" , mkIndexPartLoadPath: \(partId :: Int) -> "./index/declarations/" <> show partId <> ".js" - , packageInfoPath: "generated-docs/html/index/packages.json" - -- ^ Path to package index. - , packageInfoLoadPath: "./index/packages.json" + , moduleIndexPath: "generated-docs/html/index/modules.js" + , moduleIndexLoadPath: "./index/modules.js" + -- ^ Used to load mode index to the browser scope. + , groupModulesItem: "PureScriptDocsSearchGroupModules" + -- ^ localStorage key to save sidebar checkbox value to. + , packageInfoPath: "generated-docs/html/index/packages.js" + , packageInfoLoadPath: "./index/packages.js" -- ^ Used to load package index to the browser scope. , resultsCount: 25 -- ^ How many results to show by default? diff --git a/src/Docs/Search/Declarations.purs b/src/Docs/Search/Declarations.purs index 403bfbb..de17249 100644 --- a/src/Docs/Search/Declarations.purs +++ b/src/Docs/Search/Declarations.purs @@ -4,15 +4,16 @@ import Docs.Search.DocsJson (ChildDeclType(..), ChildDeclaration(..), DeclType(. import Docs.Search.PackageIndex (Scores) import Docs.Search.SearchResult (ResultInfo(..), SearchResult(..)) import Docs.Search.TypeDecoder (Constraint(..), QualifiedName(..), Type(..), Kind, joinForAlls) +import Docs.Search.Types (ModuleName, PackageName) import Prelude - import Control.Alt ((<|>)) import Data.Array ((!!)) import Data.Array as Array import Data.Foldable (foldr) import Data.List (List, (:)) import Data.List as List +import Data.Map as Map import Data.Maybe (Maybe(..), fromMaybe) import Data.Newtype (class Newtype, unwrap, wrap) import Data.Search.Trie (Trie, alter) @@ -20,11 +21,6 @@ import Data.String.CodeUnits (stripPrefix, stripSuffix, toCharArray) import Data.String.Common (split) as String import Data.String.Common (toLower) import Data.String.Pattern (Pattern(..)) -import Data.Map as Map - - -type ModuleName = String -type PackageName = String newtype Declarations @@ -191,7 +187,7 @@ getLevelAndName DeclExternKind name = { name, declLevel: KindLevel } -- | Extract package name from `sourceSpan.name`, which contains path to -- | the source file. If `ModuleName` string starts with `Prim.`, it's a -- | built-in (guaranteed by the compiler). -extractPackageName :: ModuleName -> Maybe SourceSpan -> String +extractPackageName :: ModuleName -> Maybe SourceSpan -> PackageName extractPackageName moduleName _ | String.split (Pattern ".") moduleName !! 0 == Just "Prim" = "" extractPackageName _ Nothing = "" diff --git a/src/Docs/Search/Engine.purs b/src/Docs/Search/Engine.purs index a4300cb..5e0b376 100644 --- a/src/Docs/Search/Engine.purs +++ b/src/Docs/Search/Engine.purs @@ -1,6 +1,7 @@ module Docs.Search.Engine where import Docs.Search.PackageIndex (PackageIndex, PackageResult) +import Docs.Search.ModuleIndex (ModuleIndex) import Docs.Search.SearchResult (SearchResult, typeOfResult) import Docs.Search.TypeQuery (TypeQuery(..), parseTypeQuery, penalty) @@ -36,6 +37,7 @@ type EngineState index typeIndex = { index :: index , typeIndex :: typeIndex , packageIndex :: PackageIndex + , moduleIndex :: ModuleIndex } @@ -44,9 +46,10 @@ mkEngineState . index -> typeIndex -> PackageIndex + -> ModuleIndex -> EngineState index typeIndex -mkEngineState index typeIndex packageIndex = - { index, typeIndex, packageIndex } +mkEngineState index typeIndex packageIndex moduleIndex = + { index, typeIndex, packageIndex, moduleIndex } data Result diff --git a/src/Docs/Search/IndexBuilder.purs b/src/Docs/Search/IndexBuilder.purs index 41c107c..0200d02 100644 --- a/src/Docs/Search/IndexBuilder.purs +++ b/src/Docs/Search/IndexBuilder.purs @@ -1,16 +1,16 @@ module Docs.Search.IndexBuilder where +import Docs.Search.BrowserEngine (getPartId) import Docs.Search.Config (config) import Docs.Search.Declarations (Declarations(..), mkDeclarations) import Docs.Search.DocsJson (DocsJson) import Docs.Search.Extra ((>#>)) -import Docs.Search.BrowserEngine (getPartId) +import Docs.Search.ModuleIndex (ModuleIndex, mkModuleIndex) import Docs.Search.PackageIndex (PackageInfo, mkPackageInfo, mkScores) import Docs.Search.SearchResult (SearchResult) import Docs.Search.TypeIndex (TypeIndex, mkTypeIndex) import Prelude - import Data.Argonaut.Core (stringify) import Data.Argonaut.Decode (decodeJson) import Data.Argonaut.Encode (encodeJson) @@ -78,6 +78,7 @@ run' cfg = do index = mkDeclarations scores docsJsons typeIndex = mkTypeIndex scores docsJsons packageInfo = mkPackageInfo packageMetas + moduleIndex = mkModuleIndex index createDirectories cfg @@ -85,6 +86,7 @@ run' cfg = do ignore <$> parallel (writeIndex cfg index) <*> parallel (writeTypeIndex cfg typeIndex) <*> parallel (writePackageInfo packageInfo) + <*> parallel (writeModuleIndex moduleIndex) <*> parallel (if cfg.noPatch then pure unit else patchDocs cfg) @@ -105,7 +107,7 @@ run' cfg = do show countOfPackages <> " packages to the search index." - where ignore _ _ _ _ _ = unit + where ignore _ _ _ _ _ _ _ = unit -- | Exit early if something is missing. @@ -220,6 +222,14 @@ writePackageInfo packageInfo = do where header = "window.DocsSearchPackageIndex = " + +writeModuleIndex :: ModuleIndex -> Aff Unit +writeModuleIndex moduleIndex = do + writeTextFile UTF8 config.moduleIndexPath $ + header <> stringify (encodeJson moduleIndex) + where + header = "window.DocsSearchModuleIndex = " + -- | Get a mapping from index parts to index contents. getIndex :: Declarations -> Map Int (Array (Tuple String (Array SearchResult))) getIndex (Declarations trie) = diff --git a/src/Docs/Search/Interactive.purs b/src/Docs/Search/Interactive.purs index a4ad5da..e3fd65e 100644 --- a/src/Docs/Search/Interactive.purs +++ b/src/Docs/Search/Interactive.purs @@ -2,6 +2,7 @@ module Docs.Search.Interactive where import Docs.Search.Declarations (Declarations, mkDeclarations) +import Docs.Search.ModuleIndex (mkModuleIndex) import Docs.Search.DocsJson (DataDeclType(..)) import Docs.Search.Engine (mkEngineState, Result(..)) import Docs.Search.Engine as Engine @@ -51,7 +52,8 @@ run cfg = launchAff_ $ do index = mkDeclarations scores docsJsons typeIndex = docsJsons >>= resultsWithTypes scores packageIndex = mkPackageIndex $ mkPackageInfo packageMetas - engineState = mkEngineState (unwrap index) typeIndex packageIndex + moduleIndex = mkModuleIndex index + engineState = mkEngineState (unwrap index) typeIndex packageIndex moduleIndex let countOfDefinitions = Trie.size $ unwrap index countOfTypeDefinitions = Array.length typeIndex diff --git a/src/Docs/Search/ModuleIndex.js b/src/Docs/Search/ModuleIndex.js new file mode 100644 index 0000000..fd74abc --- /dev/null +++ b/src/Docs/Search/ModuleIndex.js @@ -0,0 +1,24 @@ +/* global exports */ + +exports.load = function (url) { + return function () { + return new Promise(function (resolve, reject) { + if (typeof window.DocsSearchModuleIndex === 'undefined') { + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = url; + script.addEventListener('load', function () { + if (typeof window.DocsSearchModuleIndex === 'undefined') { + reject(new Error("Couldn't load module index")); + } else { + resolve(window.DocsSearchModuleIndex); + } + }); + script.addEventListener('error', reject); + document.body.appendChild(script); + } else { + resolve(window.DocsSearchModuleIndex); + } + }); + }; +}; diff --git a/src/Docs/Search/ModuleIndex.purs b/src/Docs/Search/ModuleIndex.purs new file mode 100644 index 0000000..bf61eea --- /dev/null +++ b/src/Docs/Search/ModuleIndex.purs @@ -0,0 +1,50 @@ +module Docs.Search.ModuleIndex where + +import Docs.Search.Config (config) +import Docs.Search.Declarations (Declarations(..)) +import Docs.Search.SearchResult (SearchResult(..)) +import Docs.Search.Types (ModuleName, PackageName) + +import Prelude +import Control.Promise (Promise, toAffE) +import Data.Argonaut.Core (Json) +import Data.Argonaut.Decode (decodeJson) +import Data.Either (hush) +import Data.Foldable (foldr) +import Data.List (List) +import Data.Map (Map) +import Data.Map as Map +import Data.Maybe (fromMaybe) +import Data.Search.Trie as Trie +import Data.Set (Set) +import Data.Set as Set +import Effect (Effect) +import Effect.Aff (Aff) + + +type ModuleIndex = Map PackageName (Set ModuleName) + + +-- | Constructs a mapping from packages to modules +mkModuleIndex :: Declarations -> ModuleIndex +mkModuleIndex (Declarations trie) = + foldr (Map.unionWith Set.union) mempty $ extract <$> Trie.values trie + where + extract + :: List SearchResult + -> Map PackageName (Set ModuleName) + extract = foldr (Map.unionWith Set.union) mempty <<< map mkEntry + where + mkEntry (SearchResult { packageName, moduleName }) = + Map.singleton packageName (Set.singleton moduleName) + + +loadModuleIndex :: Aff ModuleIndex +loadModuleIndex = do + json <- toAffE $ load config.moduleIndexLoadPath + pure $ fromMaybe mempty $ hush $ decodeJson json + + +foreign import load + :: String + -> Effect (Promise Json) diff --git a/src/Docs/Search/PackageIndex.js b/src/Docs/Search/PackageIndex.js index 29bf0ed..a68a8a6 100644 --- a/src/Docs/Search/PackageIndex.js +++ b/src/Docs/Search/PackageIndex.js @@ -9,7 +9,7 @@ exports.load = function (url) { script.src = url; script.addEventListener('load', function () { if (typeof window.DocsSearchPackageIndex === 'undefined') { - reject(new Error("Couldn't load index for type shape " + shape)); + reject(new Error("Couldn't load package index.")); } else { resolve(window.DocsSearchPackageIndex); } diff --git a/src/Docs/Search/Types.purs b/src/Docs/Search/Types.purs new file mode 100644 index 0000000..1f65654 --- /dev/null +++ b/src/Docs/Search/Types.purs @@ -0,0 +1,5 @@ +module Docs.Search.Types where + +type ModuleName = String + +type PackageName = String