mirror of
https://github.com/aelve/guide.git
synced 2024-11-22 11:33:34 +03:00
Merge branch 'master' into sessions
This commit is contained in:
commit
ffddc9b720
@ -47,6 +47,8 @@ it means that there's an extensive comment somewhere else in the code, which you
|
||||
|
||||
### Main modules
|
||||
|
||||
THIS SECTION IS OUTDATED
|
||||
|
||||
There are 4 main modules – `Guide.hs`, `JS.hs`, `View.hs`, and `Types.hs`.
|
||||
|
||||
`Guide.hs` contains:
|
||||
|
15
guide.cabal
15
guide.cabal
@ -45,7 +45,7 @@ executable guide
|
||||
library
|
||||
exposed-modules:
|
||||
Guide.App
|
||||
Guide.Server
|
||||
Guide.Main
|
||||
Guide.ServerStuff
|
||||
Guide.Session
|
||||
Guide.Config
|
||||
@ -61,6 +61,7 @@ library
|
||||
Guide.Utils
|
||||
Guide.Merge
|
||||
Guide.Markdown
|
||||
Guide.Search
|
||||
Guide.JS
|
||||
Guide.Views
|
||||
Guide.Views.Page
|
||||
@ -79,7 +80,7 @@ library
|
||||
, Spock-lucid == 0.3.*
|
||||
, Spock-digestive
|
||||
, acid-state == 0.14.*
|
||||
, aeson == 0.11.*
|
||||
, aeson == 1.0.*
|
||||
, aeson-pretty
|
||||
, base >=4.9 && <4.10
|
||||
, base-prelude
|
||||
@ -100,25 +101,26 @@ library
|
||||
, feed >= 0.3.11 && < 0.4
|
||||
, filemanip == 0.3.6.*
|
||||
, filepath
|
||||
, fmt == 0.0.0.4
|
||||
, fmt == 0.2.*
|
||||
, focus
|
||||
, friendly-time == 0.4.*
|
||||
, fsnotify == 0.2.*
|
||||
, hashable
|
||||
, haskell-src-meta
|
||||
, http-api-data
|
||||
, http-types
|
||||
, hvect
|
||||
, ilist
|
||||
, iproute == 1.7.*
|
||||
, lucid >= 2.9.5 && < 3
|
||||
, megaparsec == 5.0.*
|
||||
, megaparsec == 5.*
|
||||
, microlens-platform >= 0.3.2
|
||||
, mmorph == 1.*
|
||||
, mtl >= 2.1.1
|
||||
, neat-interpolation == 0.3.*
|
||||
, network
|
||||
, network-uri
|
||||
, patches-vector
|
||||
, path-pieces
|
||||
, random >= 1.1
|
||||
, reroute
|
||||
, safecopy
|
||||
@ -136,6 +138,7 @@ library
|
||||
, transformers
|
||||
, uniplate
|
||||
, unix
|
||||
, utf8-string
|
||||
, vector
|
||||
, wai
|
||||
, wai-middleware-metrics
|
||||
@ -166,7 +169,7 @@ test-suite tests
|
||||
MergeSpec
|
||||
Selenium
|
||||
type: exitcode-stdio-1.0
|
||||
build-depends: QuickCheck < 2.9
|
||||
build-depends: QuickCheck < 2.10
|
||||
, base < 5
|
||||
, base-prelude
|
||||
, cmark
|
||||
|
@ -28,14 +28,23 @@ import Data.Default
|
||||
import Guide.Utils
|
||||
|
||||
|
||||
-- | Site config. Stored in @config.json@.
|
||||
data Config = Config {
|
||||
_baseUrl :: Url,
|
||||
_googleToken :: Text,
|
||||
_adminPassword :: Text,
|
||||
_prerender :: Bool,
|
||||
_discussLink :: Maybe Url }
|
||||
_baseUrl :: Url, -- ^ URL where the site is deployed. Used
|
||||
-- for generating feeds (which require
|
||||
-- absolute URLs)
|
||||
_googleToken :: Text, -- ^ Google site verification token. Will
|
||||
-- be inserted into all pages
|
||||
_adminPassword :: Text, -- ^ Password for the admin user
|
||||
_prerender :: Bool, -- ^ Whether to prerender all pages when
|
||||
-- the app is started
|
||||
_discussLink :: Maybe Url -- ^ Link to a place to discuss the site.
|
||||
-- Will be placed in the header
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Default instance: no base URL, no Google token, empty password, no
|
||||
-- prerendering, no discussion link.
|
||||
instance Default Config where
|
||||
def = Config {
|
||||
_baseUrl = "/",
|
||||
@ -61,6 +70,8 @@ instance ToJSON Config where
|
||||
"prerender" .= _prerender,
|
||||
"discuss-link" .= _discussLink ]
|
||||
|
||||
-- | Read config from @config.json@ (creating a default config if the file
|
||||
-- doesn't exist).
|
||||
readConfig :: IO Config
|
||||
readConfig = do
|
||||
let filename = "config.json"
|
||||
@ -78,6 +89,7 @@ readConfig = do
|
||||
writeConfig cfg
|
||||
return cfg
|
||||
|
||||
-- | Write a config to @config.json@.
|
||||
writeConfig :: Config -> IO ()
|
||||
writeConfig cfg = do
|
||||
-- Create-and-rename is safer than just rewriting the file
|
||||
@ -85,5 +97,6 @@ writeConfig cfg = do
|
||||
BSL.writeFile newFile (Aeson.encodePretty cfg)
|
||||
renameFile newFile "config.json"
|
||||
|
||||
-- | Apply a function to the config.
|
||||
modifyConfig :: (Config -> IO Config) -> IO ()
|
||||
modifyConfig func = writeConfig =<< func =<< readConfig
|
||||
|
@ -431,7 +431,7 @@ itemToFeedEntry baseUrl category item = do
|
||||
Atom.entryContent = Just (Atom.HTMLContent (TL.unpack entryContent)) }
|
||||
where
|
||||
entryLink = baseUrl //
|
||||
format "{}#item-{}" (categorySlug category, item^.uid)
|
||||
format "{}#item-{}" (categorySlug category) (item^.uid)
|
||||
entryBase = Atom.nullEntry
|
||||
(T.unpack (uidToText (item^.uid)))
|
||||
(Atom.TextString (T.unpack (item^.name)))
|
||||
|
@ -122,8 +122,8 @@ class JSFunction a where
|
||||
instance JSFunction JS where
|
||||
makeJSFunction fName fParams fDef =
|
||||
let paramList = T.intercalate "," fParams
|
||||
in JS $ "function "%<fName>%"("%<paramList>%") {\n"
|
||||
%<indent 2 (build fDef)>%
|
||||
in JS $ format "function "#|fName|#"("#|paramList|#") {\n"
|
||||
#|indent 2 (build fDef)|#
|
||||
"}\n"
|
||||
|
||||
-- This generates a function that takes arguments and produces a Javascript
|
||||
@ -131,12 +131,12 @@ instance JSFunction JS where
|
||||
instance JSParams a => JSFunction (a -> JS) where
|
||||
makeJSFunction fName _fParams _fDef = \args ->
|
||||
let paramList = T.intercalate "," (map fromJS (jsParams args))
|
||||
in JS $ ""%<fName>%"("%<paramList>%");"
|
||||
in JS $ format "{}({});" fName paramList
|
||||
|
||||
-- This isn't a standalone function and so it doesn't have to be listed in
|
||||
-- 'allJSFunctions'.
|
||||
assign :: ToJS x => JS -> x -> JS
|
||||
assign v x = JS $ format "{} = {};" (v, toJS x)
|
||||
assign v x = JS $ format "{} = {};" v (toJS x)
|
||||
|
||||
-- TODO: all links here shouldn't be absolute [absolute-links]
|
||||
|
||||
@ -710,20 +710,20 @@ newtype JQuerySelector = JQuerySelector Text
|
||||
deriving (ToJS, T.Buildable)
|
||||
|
||||
selectId :: Text -> JQuerySelector
|
||||
selectId x = JQuerySelector $ format "#{}" [x]
|
||||
selectId x = JQuerySelector $ format "#{}" x
|
||||
|
||||
selectUid :: Uid Node -> JQuerySelector
|
||||
selectUid x = JQuerySelector $ format "#{}" [x]
|
||||
selectUid x = JQuerySelector $ format "#{}" x
|
||||
|
||||
selectClass :: Text -> JQuerySelector
|
||||
selectClass x = JQuerySelector $ format ".{}" [x]
|
||||
selectClass x = JQuerySelector $ format ".{}" x
|
||||
|
||||
selectParent :: JQuerySelector -> JQuerySelector
|
||||
selectParent x = JQuerySelector $ format ":has(> {})" [x]
|
||||
selectParent x = JQuerySelector $ format ":has(> {})" x
|
||||
|
||||
selectChildren :: JQuerySelector -> JQuerySelector -> JQuerySelector
|
||||
selectChildren a b = JQuerySelector $ format "{} > {}" (a, b)
|
||||
selectChildren a b = JQuerySelector $ format "{} > {}" a b
|
||||
|
||||
selectSection :: JQuerySelector -> Text -> JQuerySelector
|
||||
selectSection a b = JQuerySelector $ format "{} > .section.{}" (a, b)
|
||||
selectSection a b = JQuerySelector $ format "{} > .section.{}" a b
|
||||
|
||||
|
@ -12,7 +12,7 @@ The main module.
|
||||
* Run 'main' to actually start the server.
|
||||
* Run 'mainWith' to run it with a custom config.
|
||||
-}
|
||||
module Guide.Server
|
||||
module Guide.Main
|
||||
(
|
||||
main,
|
||||
mainWith,
|
@ -13,7 +13,7 @@ module Guide.Markdown
|
||||
-- * Types
|
||||
MarkdownInline(..),
|
||||
MarkdownBlock(..),
|
||||
MarkdownBlockWithTOC(..),
|
||||
MarkdownTree(..),
|
||||
|
||||
-- * Lenses
|
||||
mdHtml,
|
||||
@ -26,11 +26,12 @@ module Guide.Markdown
|
||||
-- * Converting text to Markdown
|
||||
toMarkdownInline,
|
||||
toMarkdownBlock,
|
||||
toMarkdownBlockWithTOC,
|
||||
toMarkdownTree,
|
||||
|
||||
-- * Misc
|
||||
renderMD,
|
||||
markdownNull,
|
||||
extractPreface,
|
||||
)
|
||||
where
|
||||
|
||||
@ -76,15 +77,15 @@ data MarkdownBlock = MarkdownBlock {
|
||||
markdownBlockMdHtml :: ByteString,
|
||||
markdownBlockMdMarkdown :: ![MD.Node] }
|
||||
|
||||
data MarkdownBlockWithTOC = MarkdownBlockWithTOC {
|
||||
markdownBlockWithTOCMdText :: Text,
|
||||
markdownBlockWithTOCMdTree :: !(Document Text ByteString),
|
||||
markdownBlockWithTOCMdIdPrefix :: Text,
|
||||
markdownBlockWithTOCMdTOC :: Forest ([MD.Node], Text) }
|
||||
data MarkdownTree = MarkdownTree {
|
||||
markdownTreeMdText :: Text,
|
||||
markdownTreeMdTree :: !(Document Text ByteString),
|
||||
markdownTreeMdIdPrefix :: Text,
|
||||
markdownTreeMdTOC :: Forest ([MD.Node], Text) }
|
||||
|
||||
makeFields ''MarkdownInline
|
||||
makeFields ''MarkdownBlock
|
||||
makeFields ''MarkdownBlockWithTOC
|
||||
makeFields ''MarkdownTree
|
||||
|
||||
parseMD :: Text -> [MD.Node]
|
||||
parseMD s =
|
||||
@ -139,6 +140,19 @@ stringify = T.concat . map go
|
||||
HTML_BLOCK _ -> ""
|
||||
HTML_INLINE _ -> ""
|
||||
|
||||
-- | Extract everything before the first heading.
|
||||
--
|
||||
-- Note that if you render 'mdText' of the produced Markdown block, it won't
|
||||
-- necessarily parse into 'mdHtml' from the same block. It's because rendered
|
||||
-- Markdown might depend on links that are defined further in the tree.
|
||||
extractPreface :: MarkdownTree -> MarkdownBlock
|
||||
extractPreface = mkBlock . preface . view mdTree
|
||||
where
|
||||
mkBlock x = MarkdownBlock {
|
||||
markdownBlockMdText = annSource x,
|
||||
markdownBlockMdHtml = renderMD (annValue x),
|
||||
markdownBlockMdMarkdown = annValue x }
|
||||
|
||||
-- | Flatten Markdown by concatenating all block elements.
|
||||
extractInlines :: [MD.Node] -> [MD.Node]
|
||||
extractInlines = concatMap go
|
||||
@ -236,12 +250,12 @@ toMarkdownBlock s = MarkdownBlock {
|
||||
doc = parseMD s
|
||||
html = renderMD doc
|
||||
|
||||
toMarkdownBlockWithTOC :: Text -> Text -> MarkdownBlockWithTOC
|
||||
toMarkdownBlockWithTOC idPrefix s = MarkdownBlockWithTOC {
|
||||
markdownBlockWithTOCMdText = s,
|
||||
markdownBlockWithTOCMdIdPrefix = idPrefix,
|
||||
markdownBlockWithTOCMdTree = tree,
|
||||
markdownBlockWithTOCMdTOC = toc }
|
||||
toMarkdownTree :: Text -> Text -> MarkdownTree
|
||||
toMarkdownTree idPrefix s = MarkdownTree {
|
||||
markdownTreeMdText = s,
|
||||
markdownTreeMdIdPrefix = idPrefix,
|
||||
markdownTreeMdTree = tree,
|
||||
markdownTreeMdTOC = toc }
|
||||
where
|
||||
blocks :: [MD.Node]
|
||||
blocks = parseMD s
|
||||
@ -281,7 +295,7 @@ instance Show MarkdownInline where
|
||||
show = show . view mdText
|
||||
instance Show MarkdownBlock where
|
||||
show = show . view mdText
|
||||
instance Show MarkdownBlockWithTOC where
|
||||
instance Show MarkdownTree where
|
||||
show = show . view mdText
|
||||
|
||||
instance A.ToJSON MarkdownInline where
|
||||
@ -292,7 +306,7 @@ instance A.ToJSON MarkdownBlock where
|
||||
toJSON md = A.object [
|
||||
"text" A..= (md^.mdText),
|
||||
"html" A..= T.decodeUtf8 (md^.mdHtml) ]
|
||||
instance A.ToJSON MarkdownBlockWithTOC where
|
||||
instance A.ToJSON MarkdownTree where
|
||||
toJSON md = A.object [
|
||||
"text" A..= (md^.mdText) ]
|
||||
|
||||
@ -302,7 +316,7 @@ instance ToHtml MarkdownInline where
|
||||
instance ToHtml MarkdownBlock where
|
||||
toHtmlRaw = toHtml
|
||||
toHtml = toHtmlRaw . view mdHtml
|
||||
instance ToHtml MarkdownBlockWithTOC where
|
||||
instance ToHtml MarkdownTree where
|
||||
toHtmlRaw = toHtml
|
||||
toHtml = toHtmlRaw . renderDoc . view mdTree
|
||||
where
|
||||
@ -330,14 +344,14 @@ instance SafeCopy MarkdownBlock where
|
||||
kind = base
|
||||
putCopy = contain . safePut . view mdText
|
||||
getCopy = contain $ toMarkdownBlock <$> safeGet
|
||||
instance SafeCopy MarkdownBlockWithTOC where
|
||||
instance SafeCopy MarkdownTree where
|
||||
version = 0
|
||||
kind = base
|
||||
putCopy md = contain $ do
|
||||
safePut (md ^. mdIdPrefix)
|
||||
safePut (md ^. mdText)
|
||||
getCopy = contain $
|
||||
toMarkdownBlockWithTOC <$> safeGet <*> safeGet
|
||||
toMarkdownTree <$> safeGet <*> safeGet
|
||||
|
||||
-- | Is a piece of Markdown empty?
|
||||
markdownNull :: HasMdText a Text => a -> Bool
|
||||
|
78
src/Guide/Search.hs
Normal file
78
src/Guide/Search.hs
Normal file
@ -0,0 +1,78 @@
|
||||
-- | The code that powers the searchbox.
|
||||
module Guide.Search
|
||||
(
|
||||
SearchResult(..),
|
||||
search,
|
||||
)
|
||||
where
|
||||
|
||||
|
||||
import Imports
|
||||
|
||||
-- Text
|
||||
import qualified Data.Text.All as T
|
||||
-- Sets
|
||||
import qualified Data.Set as S
|
||||
|
||||
import Guide.Types
|
||||
import Guide.State
|
||||
import Guide.Markdown
|
||||
|
||||
|
||||
-- | A search result.
|
||||
data SearchResult
|
||||
-- | Category's title matches the query
|
||||
= SRCategory Category
|
||||
-- | Item's name matches the query
|
||||
| SRItem Category Item
|
||||
-- | Item's ecosystem matches the query
|
||||
| SRItemEcosystem Category Item
|
||||
deriving (Show, Generic)
|
||||
|
||||
{- | Find things matching a simple text query, and return results ranked by
|
||||
importance. Categories are considered more important than items.
|
||||
|
||||
Currently 'search' doesn't do any fuzzy search whatsoever – only direct word
|
||||
matches are considered. See 'match' for the description of the matching
|
||||
algorithm.
|
||||
-}
|
||||
search :: Text -> GlobalState -> [SearchResult]
|
||||
search query gs =
|
||||
-- category titles
|
||||
sortByRank [(SRCategory cat, rank)
|
||||
| cat <- gs^.categories
|
||||
, let rank = match query (cat^.title)
|
||||
, rank > 0 ] ++
|
||||
-- item names
|
||||
sortByRank [(SRItem cat item, rank)
|
||||
| cat <- gs^.categories
|
||||
, item <- cat^.items
|
||||
, let rank = match query (item^.name)
|
||||
, rank > 0 ] ++
|
||||
-- item ecosystems
|
||||
sortByRank [(SRItemEcosystem cat item, rank)
|
||||
| cat <- gs^.categories
|
||||
, item <- cat^.items
|
||||
, let rank = match query (item^.ecosystem.mdText)
|
||||
, rank > 0 ]
|
||||
where
|
||||
sortByRank :: [(a, Int)] -> [a]
|
||||
sortByRank = map fst . sortOn (Down . snd)
|
||||
|
||||
{- | How many words in two strings match?
|
||||
|
||||
Words are defined as sequences of letters, digits and characters like “-”;
|
||||
separators are everything else. Comparisons are case-insensitive.
|
||||
-}
|
||||
match :: Text -> Text -> Int
|
||||
match a b = common (getWords a) (getWords b)
|
||||
where
|
||||
isWordPart c = isLetter c || isDigit c || c == '-'
|
||||
getWords =
|
||||
map T.toTitle .
|
||||
filter (not . T.null) .
|
||||
T.split (not . isWordPart)
|
||||
|
||||
-- | Find how many elements two lists have in common.
|
||||
common :: Ord a => [a] -> [a] -> Int
|
||||
common a b = S.size (S.intersection (S.fromList a) (S.fromList b))
|
@ -56,10 +56,13 @@ import Guide.Utils
|
||||
import Guide.Markdown
|
||||
|
||||
|
||||
-- | Global state of the site.
|
||||
data ServerState = ServerState {
|
||||
_config :: Config,
|
||||
_db :: DB }
|
||||
_config :: Config, -- ^ Config (doesn't change in runtime)
|
||||
_db :: DB -- ^ DB connection
|
||||
}
|
||||
|
||||
-- | Get config in a Spock monad.
|
||||
getConfig :: (Monad m, HasSpock m, SpockState m ~ ServerState)
|
||||
=> m Config
|
||||
getConfig = _config <$> Spock.getState
|
||||
@ -70,6 +73,9 @@ type DB = AcidState GlobalState
|
||||
|
||||
-- | Update something in the database. Don't forget to 'invalidateCache' or
|
||||
-- use 'uncache' when you update something that is cached.
|
||||
--
|
||||
-- Example: @dbUpdate (DeleteCategory catId)@
|
||||
--
|
||||
dbUpdate :: (MonadIO m, HasSpock m, SpockState m ~ ServerState,
|
||||
EventState event ~ GlobalState, UpdateEvent event)
|
||||
=> event -> m (EventResult event)
|
||||
@ -80,6 +86,9 @@ dbUpdate x = do
|
||||
Acid.update db x
|
||||
|
||||
-- | Read something from the database.
|
||||
--
|
||||
-- Example: @dbQuery (GetCategory catId)@.
|
||||
--
|
||||
dbQuery :: (MonadIO m, HasSpock m, SpockState m ~ ServerState,
|
||||
EventState event ~ GlobalState, QueryEvent event)
|
||||
=> event -> m (EventResult event)
|
||||
@ -150,8 +159,8 @@ addEdit ed = do
|
||||
--
|
||||
-- TODO: make this do cache invalidation.
|
||||
--
|
||||
-- TODO: many of these don't work when the changed category/item/etc has been
|
||||
-- deleted; this should change.
|
||||
-- TODO: many of these don't work when the changed category, item, etc has
|
||||
-- been deleted; this should change.
|
||||
undoEdit :: (MonadIO m, HasSpock m, SpockState m ~ ServerState)
|
||||
=> Edit -> m (Either String ())
|
||||
undoEdit (Edit'AddCategory catId _) = do
|
||||
@ -238,6 +247,8 @@ undoEdit (Edit'MoveItem itemId direction) = do
|
||||
undoEdit (Edit'MoveTrait itemId traitId direction) = do
|
||||
Right () <$ dbUpdate (MoveTrait itemId traitId (not direction))
|
||||
|
||||
-- | Given an edit, invalidate cache items that should be invalidated when
|
||||
-- that edit is undone.
|
||||
invalidateCacheForEdit
|
||||
:: (MonadIO m, HasSpock m, SpockState m ~ ServerState)
|
||||
=> Edit -> m ()
|
||||
@ -297,12 +308,15 @@ invalidateCacheForEdit ed = do
|
||||
-- Handler helpers
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | A path piece for items
|
||||
itemVar :: Path '[Uid Item] 'Open
|
||||
itemVar = "item" <//> var
|
||||
|
||||
-- | A path piece for categories
|
||||
categoryVar :: Path '[Uid Category] 'Open
|
||||
categoryVar = "category" <//> var
|
||||
|
||||
-- | A path pieces for traits
|
||||
traitVar :: Path '[Uid Trait] 'Open
|
||||
traitVar = "trait" <//> var
|
||||
|
||||
|
@ -307,7 +307,7 @@ addItem catId itemId name' created' kind' = do
|
||||
_itemConsDeleted = [],
|
||||
_itemEcosystem = toMarkdownBlock "",
|
||||
_itemNotes = let pref = "item-notes-" <> uidToText itemId <> "-"
|
||||
in toMarkdownBlockWithTOC pref "",
|
||||
in toMarkdownTree pref "",
|
||||
_itemLink = Nothing,
|
||||
_itemKind = kind' }
|
||||
categoryById catId . items %= (++ [newItem])
|
||||
@ -444,7 +444,7 @@ setItemNotes :: Uid Item -> Text -> Acid.Update GlobalState (Edit, Item)
|
||||
setItemNotes itemId notes' = do
|
||||
let pref = "item-notes-" <> uidToText itemId <> "-"
|
||||
oldNotes <- itemById itemId . notes <<.=
|
||||
toMarkdownBlockWithTOC pref notes'
|
||||
toMarkdownTree pref notes'
|
||||
let edit = Edit'SetItemNotes itemId (oldNotes ^. mdText) notes'
|
||||
(edit,) <$> use (itemById itemId)
|
||||
|
||||
@ -575,7 +575,7 @@ restoreCategory catId pos = do
|
||||
Nothing -> return (Left "category not found in deleted categories")
|
||||
Just category -> do
|
||||
categoriesDeleted %= deleteFirst (hasUid catId)
|
||||
categories %= insertAtGuaranteed pos category
|
||||
categories %= insertOrAppend pos category
|
||||
return (Right ())
|
||||
|
||||
restoreItem :: Uid Item -> Int -> Acid.Update GlobalState (Either String ())
|
||||
@ -588,7 +588,7 @@ restoreItem itemId pos = do
|
||||
let item = fromJust (find (hasUid itemId) (category^.itemsDeleted))
|
||||
let category' = category
|
||||
& itemsDeleted %~ deleteFirst (hasUid itemId)
|
||||
& items %~ insertAtGuaranteed pos item
|
||||
& items %~ insertOrAppend pos item
|
||||
categories . each . filtered ourCategory .= category'
|
||||
categoriesDeleted . each . filtered ourCategory .= category'
|
||||
return (Right ())
|
||||
@ -609,7 +609,7 @@ restoreTrait itemId traitId pos = do
|
||||
(Just trait, _) -> do
|
||||
let item' = item
|
||||
& prosDeleted %~ deleteFirst (hasUid traitId)
|
||||
& pros %~ insertAtGuaranteed pos trait
|
||||
& pros %~ insertOrAppend pos trait
|
||||
let category' = category
|
||||
& items . each . filtered (hasUid itemId) .~ item'
|
||||
& itemsDeleted . each . filtered (hasUid itemId) .~ item'
|
||||
@ -619,7 +619,7 @@ restoreTrait itemId traitId pos = do
|
||||
(_, Just trait) -> do
|
||||
let item' = item
|
||||
& consDeleted %~ deleteFirst (hasUid traitId)
|
||||
& cons %~ insertAtGuaranteed pos trait
|
||||
& cons %~ insertOrAppend pos trait
|
||||
let category' = category
|
||||
& items . each . filtered (hasUid itemId) .~ item'
|
||||
& itemsDeleted . each . filtered (hasUid itemId) .~ item'
|
||||
|
@ -90,6 +90,7 @@ For an explanation of deriveSafeCopySorted, see Note [acid-state].
|
||||
-- Trait
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | A trait (pro or con). Traits are stored in items.
|
||||
data Trait = Trait {
|
||||
_traitUid :: Uid Trait,
|
||||
_traitContent :: MarkdownInline }
|
||||
@ -109,6 +110,7 @@ instance A.ToJSON Trait where
|
||||
-- Item
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Kind of an item (items can be libraries, tools, etc).
|
||||
data ItemKind
|
||||
= Library {_itemKindHackageName :: Maybe Text}
|
||||
| Tool {_itemKindHackageName :: Maybe Text}
|
||||
@ -154,20 +156,23 @@ instance A.ToJSON ItemSection where
|
||||
-- TODO: add a field like “people to ask on IRC about this library if you
|
||||
-- need help”
|
||||
|
||||
-- | An item (usually a library). Items are stored in categories.
|
||||
data Item = Item {
|
||||
_itemUid :: Uid Item,
|
||||
_itemName :: Text,
|
||||
_itemCreated :: UTCTime,
|
||||
_itemGroup_ :: Maybe Text,
|
||||
_itemDescription :: MarkdownBlock,
|
||||
_itemPros :: [Trait],
|
||||
_itemProsDeleted :: [Trait],
|
||||
_itemCons :: [Trait],
|
||||
_itemConsDeleted :: [Trait],
|
||||
_itemEcosystem :: MarkdownBlock,
|
||||
_itemNotes :: MarkdownBlockWithTOC,
|
||||
_itemLink :: Maybe Url,
|
||||
_itemKind :: ItemKind }
|
||||
_itemUid :: Uid Item, -- ^ Item ID
|
||||
_itemName :: Text, -- ^ Item title
|
||||
_itemCreated :: UTCTime, -- ^ When the item was created
|
||||
_itemGroup_ :: Maybe Text, -- ^ Item group (affects item's color)
|
||||
_itemDescription :: MarkdownBlock, -- ^ Item summary
|
||||
_itemPros :: [Trait], -- ^ Pros (positive traits)
|
||||
_itemProsDeleted :: [Trait], -- ^ Deleted pros go here (so that
|
||||
-- it'd be easy to restore them)
|
||||
_itemCons :: [Trait], -- ^ Cons (negative traits)
|
||||
_itemConsDeleted :: [Trait], -- ^ Deleted cons go here
|
||||
_itemEcosystem :: MarkdownBlock, -- ^ The ecosystem section
|
||||
_itemNotes :: MarkdownTree, -- ^ The notes section
|
||||
_itemLink :: Maybe Url, -- ^ Link to homepage or something
|
||||
_itemKind :: ItemKind -- ^ Is it a library, tool, etc
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
deriveSafeCopySorted 11 'extension ''Item
|
||||
@ -184,10 +189,11 @@ instance A.ToJSON Item where
|
||||
-- Category
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Category status
|
||||
data CategoryStatus
|
||||
= CategoryStub
|
||||
| CategoryWIP
|
||||
| CategoryFinished
|
||||
= CategoryStub -- ^ “Stub” = just created
|
||||
| CategoryWIP -- ^ “WIP” = work in progress
|
||||
| CategoryFinished -- ^ “Finished” = complete or nearly complete
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
deriveSafeCopySimple 2 'extension ''CategoryStatus
|
||||
@ -210,27 +216,29 @@ instance Migrate CategoryStatus where
|
||||
migrate CategoryMostlyDone_v1 = CategoryFinished
|
||||
migrate CategoryFinished_v1 = CategoryFinished
|
||||
|
||||
-- | A category
|
||||
data Category = Category {
|
||||
_categoryUid :: Uid Category,
|
||||
_categoryTitle :: Text,
|
||||
-- | The “grandcategory” of the category (“meta”, “basics”, “specialised
|
||||
-- needs”, etc)
|
||||
_categoryGroup_ :: Text,
|
||||
-- | Enabled sections in this category. For instance, if this set contains
|
||||
-- 'ItemNotesSection', then notes will be shown for each item.
|
||||
_categoryUid :: Uid Category,
|
||||
_categoryTitle :: Text,
|
||||
-- | When the category was created
|
||||
_categoryCreated :: UTCTime,
|
||||
-- | The “grandcategory” of the category (“meta”, “basics”, etc)
|
||||
_categoryGroup_ :: Text,
|
||||
_categoryStatus :: CategoryStatus,
|
||||
_categoryNotes :: MarkdownBlock,
|
||||
-- | Items stored in the category
|
||||
_categoryItems :: [Item],
|
||||
-- | Items that were deleted from the category. We keep them here to make
|
||||
-- it easier to restore them
|
||||
_categoryItemsDeleted :: [Item],
|
||||
-- | Enabled sections in this category. E.g, if this set contains
|
||||
-- 'ItemNotesSection', then notes will be shown for each item
|
||||
_categoryEnabledSections :: Set ItemSection,
|
||||
_categoryCreated :: UTCTime,
|
||||
_categoryStatus :: CategoryStatus,
|
||||
_categoryNotes :: MarkdownBlock,
|
||||
-- | All groups of items belonging to the category, as well as their
|
||||
-- colors. We could assign colors to items when we render the category
|
||||
-- (something like “if haven't seen this group yet, assign a new color to
|
||||
-- it and render it with this color”, but this way is easier and also
|
||||
-- allows us to keep the colors of all other groups the same when one item
|
||||
-- has been deleted.
|
||||
_categoryGroups :: Map Text Hue,
|
||||
_categoryItems :: [Item],
|
||||
_categoryItemsDeleted :: [Item] }
|
||||
-- colors. Storing colors explicitly lets us keep colors consistent when
|
||||
-- all items in a group are deleted
|
||||
_categoryGroups :: Map Text Hue
|
||||
}
|
||||
deriving (Show, Generic)
|
||||
|
||||
deriveSafeCopySorted 11 'extension ''Category
|
||||
@ -258,13 +266,17 @@ instance A.ToJSON Category where
|
||||
toJSON = A.genericToJSON A.defaultOptions {
|
||||
A.fieldLabelModifier = over _head toLower . drop (T.length "_category") }
|
||||
|
||||
-- | Category identifier (used in URLs). E.g. for a category with title
|
||||
-- “Performance optimization” and UID “t3c9hwzo” the slug would be
|
||||
-- @performance-optimization-t3c9hwzo@.
|
||||
categorySlug :: Category -> Text
|
||||
categorySlug category =
|
||||
format "{}-{}" (makeSlug (category^.title), category^.uid)
|
||||
format "{}-{}" (makeSlug (category^.title)) (category^.uid)
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
-- Utils
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | A useful predicate; @hasUid x@ compares given object's UID with @x@.
|
||||
hasUid :: HasUid a (Uid u) => Uid u -> a -> Bool
|
||||
hasUid u x = x^.uid == u
|
||||
|
@ -19,7 +19,7 @@ module Guide.Utils
|
||||
moveUp,
|
||||
moveDown,
|
||||
deleteFirst,
|
||||
insertAtGuaranteed,
|
||||
insertOrAppend,
|
||||
ordNub,
|
||||
|
||||
-- * 'Eq'
|
||||
@ -96,7 +96,7 @@ import Data.IP
|
||||
import Lucid hiding (for_)
|
||||
import Web.Spock as Spock
|
||||
import Text.HTML.SanitizeXSS (sanitaryURI)
|
||||
import Web.PathPieces
|
||||
import Web.HttpApiData
|
||||
import qualified Network.Wai as Wai
|
||||
-- Feeds
|
||||
import qualified Text.Atom.Feed as Atom
|
||||
@ -110,6 +110,9 @@ import qualified Language.Haskell.TH.Syntax as TH (lift)
|
||||
import Language.Haskell.TH.Quote (QuasiQuoter(..))
|
||||
import Language.Haskell.Meta (parseExp)
|
||||
import Data.Generics.Uniplate.Data (transform)
|
||||
-- needed for 'sanitiseUrl'
|
||||
import qualified Codec.Binary.UTF8.String as UTF8
|
||||
import qualified Network.URI as URI
|
||||
|
||||
|
||||
----------------------------------------------------------------------------
|
||||
@ -126,15 +129,24 @@ moveDown :: (a -> Bool) -> [a] -> [a]
|
||||
moveDown p (x:y:xs) = if p x then y : x : xs else x : moveDown p (y:xs)
|
||||
moveDown _ xs = xs
|
||||
|
||||
-- | Delete the first element that satisfies the predicate (if such an
|
||||
-- element is present).
|
||||
deleteFirst :: (a -> Bool) -> [a] -> [a]
|
||||
deleteFirst _ [] = []
|
||||
deleteFirst f (x:xs) = if f x then xs else x : deleteFirst f xs
|
||||
|
||||
insertAtGuaranteed :: Int -> a -> [a] -> [a]
|
||||
insertAtGuaranteed _ a [] = [a]
|
||||
insertAtGuaranteed 0 a xs = a:xs
|
||||
insertAtGuaranteed n a (x:xs) = x : insertAtGuaranteed (n-1) a xs
|
||||
-- | Insert given element into the list, or append it to the list if the
|
||||
-- position is outside the list bounds.
|
||||
insertOrAppend
|
||||
:: Int -- ^ Preferred position
|
||||
-> a -- ^ Element to insert
|
||||
-> [a]
|
||||
-> [a]
|
||||
insertOrAppend _ a [] = [a]
|
||||
insertOrAppend 0 a xs = a:xs
|
||||
insertOrAppend n a (x:xs) = x : insertOrAppend (n-1) a xs
|
||||
|
||||
-- | A version of 'works in @O(n log n)@ instead of @O(n^2)@.
|
||||
ordNub :: Ord a => [a] -> [a]
|
||||
ordNub = go mempty
|
||||
where
|
||||
@ -146,6 +158,9 @@ ordNub = go mempty
|
||||
-- Eq
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Like 'comparing', but for 'Eq' for 'Eq' comparison instead of 'Ord'. Can
|
||||
-- be used with e.g. 'grourison instead of 'Ord'. Can be usedpBy'. with e.g.
|
||||
-- 'groupBy'.
|
||||
equating :: Eq b => (a -> b) -> (a -> a -> Bool)
|
||||
equating f = (==) `on` f
|
||||
|
||||
@ -153,17 +168,28 @@ equating f = (==) `on` f
|
||||
-- Urls
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | A type for URLs.
|
||||
type Url = Text
|
||||
|
||||
-- | Return 'Nothing' if the URL is unsafe (e.g. a @javascript:@ or @data:@
|
||||
-- URL). Otherwise return the original URL, possibly adding @http://@ to it
|
||||
-- if it doesn't have a scheme.
|
||||
sanitiseUrl :: Url -> Maybe Url
|
||||
sanitiseUrl u
|
||||
| not (sanitaryURI u) = Nothing
|
||||
| "http:" `T.isPrefixOf` u = Just u
|
||||
| "https:" `T.isPrefixOf` u = Just u
|
||||
| otherwise = Just ("http://" <> u)
|
||||
| not (sanitaryURI u) = Nothing
|
||||
| otherwise =
|
||||
Just $ case URI.uriScheme <$> parse (T.toString u) of
|
||||
Nothing -> "http://" <> u
|
||||
Just "" -> "http://" <> u
|
||||
_ -> u
|
||||
where
|
||||
-- code taken from implementation of 'sanitaryURI'
|
||||
parse = URI.parseURIReference . escape
|
||||
escape = URI.escapeURIString URI.isAllowedInURI .
|
||||
UTF8.encodeString
|
||||
|
||||
-- | Make text suitable for inclusion into an URL (by turning spaces into
|
||||
-- hyphens and so on)
|
||||
-- hyphens and so on).
|
||||
makeSlug :: Text -> Text
|
||||
makeSlug =
|
||||
T.intercalate "-" . T.words .
|
||||
@ -208,7 +234,9 @@ sockAddrToIP _ = Nothing
|
||||
|
||||
-- | Unique id, used for many things – categories, items, and anchor ids.
|
||||
newtype Uid a = Uid {uidToText :: Text}
|
||||
deriving (Eq, Ord, Show, PathPiece, T.Buildable, Hashable, A.ToJSON)
|
||||
deriving (Eq, Ord, Show,
|
||||
ToHttpApiData, FromHttpApiData,
|
||||
T.Buildable, Hashable, A.ToJSON)
|
||||
|
||||
-- This instance is written manually because otherwise it produces a warning:
|
||||
-- • Redundant constraint: SafeCopy a
|
||||
@ -222,11 +250,12 @@ instance SafeCopy (Uid a) where
|
||||
instance IsString (Uid a) where
|
||||
fromString = Uid . T.pack
|
||||
|
||||
-- | Generate a random text of given length from characters @a-z@ and digits.
|
||||
randomText :: MonadIO m => Int -> m Text
|
||||
randomText n = liftIO $ do
|
||||
-- We don't want the 1st char to be a digit. Just in case (I don't really
|
||||
-- have a good reason). Maybe to prevent Javascript from doing automatic
|
||||
-- conversions or something (tho it should never happen).
|
||||
-- conversions or something (though it should never happen).
|
||||
x <- randomRIO ('a', 'z')
|
||||
let randomChar = do
|
||||
i <- randomRIO (0, 35)
|
||||
@ -235,17 +264,36 @@ randomText n = liftIO $ do
|
||||
xs <- replicateM (n-1) randomChar
|
||||
return (T.pack (x:xs))
|
||||
|
||||
-- For probability tables, see
|
||||
-- https://en.wikipedia.org/wiki/Birthday_problem#Probability_table
|
||||
|
||||
-- | Generate a random UID of length 12.
|
||||
--
|
||||
-- Probability of collision for
|
||||
--
|
||||
-- * a million UIDs: approximately 1e-6
|
||||
-- * a billion UIDs: approximately 0.25
|
||||
--
|
||||
randomLongUid :: MonadIO m => m (Uid a)
|
||||
randomLongUid = Uid <$> randomText 12
|
||||
|
||||
-- These are only used for items and categories (because their uids can occur
|
||||
-- in links and so they should look a bit nicer).
|
||||
-- | Generate a random UID of length 8.
|
||||
--
|
||||
-- These UIDs are only used for items and categories (because their uids can
|
||||
-- occur in links and so they should look a bit nicer).
|
||||
--
|
||||
-- Probability of collision for
|
||||
--
|
||||
-- * a hundred thousand UIDs: approximately 0.5%
|
||||
-- * a million UIDs: approximately 40%
|
||||
--
|
||||
randomShortUid :: MonadIO m => m (Uid a)
|
||||
randomShortUid = Uid <$> randomText 8
|
||||
|
||||
-- | A marker for Uids that would be used with HTML nodes
|
||||
data Node
|
||||
|
||||
-- | Generate a HTML @id@ attribute from an 'Uid'.
|
||||
uid_ :: Uid Node -> Attribute
|
||||
uid_ = id_ . uidToText
|
||||
|
||||
@ -253,9 +301,11 @@ uid_ = id_ . uidToText
|
||||
-- Lucid
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Include Javascript into page by creating a @<script>@ tag.
|
||||
includeJS :: Monad m => Url -> HtmlT m ()
|
||||
includeJS url = with (script_ "") [src_ url]
|
||||
|
||||
-- | Include CSS into page.
|
||||
includeCSS :: Monad m => Url -> HtmlT m ()
|
||||
includeCSS url = link_ [rel_ "stylesheet", type_ "text/css", href_ url]
|
||||
|
||||
@ -263,11 +313,15 @@ includeCSS url = link_ [rel_ "stylesheet", type_ "text/css", href_ url]
|
||||
-- Spock
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Serve an Atom feed.
|
||||
atomFeed :: MonadIO m => Atom.Feed -> ActionCtxT ctx m ()
|
||||
atomFeed feed = do
|
||||
setHeader "Content-Type" "application/atom+xml; charset=utf-8"
|
||||
bytes $ T.encodeUtf8 (T.pack (XML.ppElement (Atom.xmlFeed feed)))
|
||||
|
||||
-- | Get details of the request:
|
||||
--
|
||||
-- @(time, IP, referrer, user-agent)@
|
||||
getRequestDetails
|
||||
:: (MonadIO m, HasSpock (ActionCtxT ctx m))
|
||||
=> ActionCtxT ctx m (UTCTime, Maybe IP, Maybe Text, Maybe Text)
|
||||
@ -301,6 +355,10 @@ getRequestDetails = do
|
||||
-- Template Haskell
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Parse a Haskell expression with haskell-src-meta. The difference between
|
||||
-- @[|exp|]@ and @[hs|exp|]@ is the the former requires all variables in
|
||||
-- @exp@ to be present in scope at the moment of generation, but the latter
|
||||
-- doesn't. This makes 'hs' useful for 'changelog'.
|
||||
hs :: QuasiQuoter
|
||||
hs = QuasiQuoter {
|
||||
quoteExp = either fail TH.lift . parseExp,
|
||||
@ -308,12 +366,19 @@ hs = QuasiQuoter {
|
||||
quoteType = fail "hs: can't parse types",
|
||||
quoteDec = fail "hs: can't parse declarations" }
|
||||
|
||||
-- | Print splices generated by a TH splice (the printing will happen during
|
||||
-- compilation, as a GHC warning). Useful for debugging.
|
||||
--
|
||||
-- For instance, you can dump splices generated with 'makeLenses' by
|
||||
-- replacing a top-level invocation of 'makeLenses' in your code with:
|
||||
--
|
||||
-- @dumpSplices $ makeLenses ''Foo@
|
||||
--
|
||||
dumpSplices :: DecsQ -> DecsQ
|
||||
dumpSplices x = do
|
||||
ds <- x
|
||||
-- “reportWarning (pprint ds)” doesn't work in Emacs because of
|
||||
-- haskell-mode's parsing of compiler messages
|
||||
mapM_ (reportWarning . pprint) ds
|
||||
let code = lines (pprint ds)
|
||||
reportWarning ("\n" ++ unlines (map (" " ++) code))
|
||||
return ds
|
||||
|
||||
bangNotStrict :: Q Bang
|
||||
@ -337,6 +402,7 @@ data Change
|
||||
-- the final version of the record is)
|
||||
| Added String Exp
|
||||
|
||||
-- | An ADT for versions. Only used in invocations of 'changelog'.
|
||||
data TypeVersion = Current Int | Past Int
|
||||
deriving (Show)
|
||||
|
||||
@ -539,9 +605,22 @@ changelog bareTyName (newVer, Past oldVer) changes = do
|
||||
-- Return everything
|
||||
sequence [oldTypeDecl, migrateInstanceDecl]
|
||||
|
||||
data GenConstructor = Copy Name | Custom String [(String, Q Type)]
|
||||
-- | A type for specifying what constructors existed in an old version of a
|
||||
-- sum datatype.
|
||||
data GenConstructor
|
||||
= Copy Name -- ^ Just reuse the constructor
|
||||
-- existing now.
|
||||
| Custom String [(String, Q Type)] -- ^ The previous version had a
|
||||
-- constructor with such-and-such
|
||||
-- name and such-and-such fields.
|
||||
|
||||
genVer :: Name -> Int -> [GenConstructor] -> Q [Dec]
|
||||
-- | Generate an old version of a sum type (used for 'SafeCopy').
|
||||
genVer
|
||||
:: Name -- ^ Name of type to generate old version for
|
||||
-> Int -- ^ Version to generate
|
||||
-> [GenConstructor] -- ^ List of constructors in the version we're
|
||||
-- generating
|
||||
-> Q [Dec]
|
||||
genVer tyName ver constructors = do
|
||||
-- Get information about the new version of the datatype
|
||||
TyConI (DataD _cxt _name _vars _kind cons _deriving) <- reify tyName
|
||||
@ -590,9 +669,24 @@ genVer tyName ver constructors = do
|
||||
(cxt [])
|
||||
return [decl]
|
||||
|
||||
data MigrateConstructor = CopyM Name | CustomM String ExpQ
|
||||
-- | A type for migrating constructors from an old version of a sum datatype.
|
||||
data MigrateConstructor
|
||||
= CopyM Name -- ^ Copy constructor without changes
|
||||
| CustomM String ExpQ -- ^ The old constructor with such-and-such name
|
||||
-- should be turned into a value of the new type
|
||||
-- (i.e. type of current version) using
|
||||
-- such-and-such code.
|
||||
|
||||
migrateVer :: Name -> Int -> [MigrateConstructor] -> Q Exp
|
||||
-- | Generate 'SafeCopy' migration code for a sum datatype.
|
||||
--
|
||||
-- See @instance Migrate Edit@ for an example.
|
||||
migrateVer
|
||||
:: Name -- ^ Type we're migrating to
|
||||
-> Int -- ^ Version we're migrating from
|
||||
-> [MigrateConstructor] -- ^ For each constructor existing in the (old
|
||||
-- version of) type, a specification of how to
|
||||
-- migrate it.
|
||||
-> Q Exp
|
||||
migrateVer tyName ver constructors = do
|
||||
-- Get information about the new version of the datatype
|
||||
TyConI (DataD _cxt _name _vars _kind cons _deriving) <- reify tyName
|
||||
@ -636,6 +730,7 @@ migrateVer tyName ver constructors = do
|
||||
-- STM
|
||||
----------------------------------------------------------------------------
|
||||
|
||||
-- | Lift an 'STM' action to any IO-supporting monad.
|
||||
liftSTM :: MonadIO m => STM a -> m a
|
||||
liftSTM = liftIO . atomically
|
||||
|
||||
|
@ -50,6 +50,7 @@ import qualified Data.Aeson as A
|
||||
import Guide.Config
|
||||
import Guide.State
|
||||
import Guide.Types
|
||||
import Guide.Search
|
||||
import Guide.Utils
|
||||
import Guide.JS (JS(..))
|
||||
import qualified Guide.JS as JS
|
||||
@ -313,7 +314,7 @@ renderEdits globalState edits = do
|
||||
let editBlocks = groupBy (equating getIP) edits
|
||||
let ipNum = length $ groupWith getIP edits
|
||||
h1_ $ toHtml @Text $
|
||||
"Pending edits (IPs: "%<ipNum>%", blocks: "%<length editBlocks>%")"
|
||||
"Pending edits (IPs: "#|ipNum|#", blocks: "#|length editBlocks|#")"
|
||||
for_ editBlocks $ \editBlock -> div_ $ do
|
||||
blockNode <- thisNode
|
||||
h2_ $ do
|
||||
@ -529,18 +530,7 @@ renderHaskellRoot globalState mbSearchQuery =
|
||||
onEnter $ JS.addCategoryAndRedirect [inputValue] ]
|
||||
case mbSearchQuery of
|
||||
Nothing -> renderCategoryList (globalState^.categories)
|
||||
Just query' -> do
|
||||
let queryWords = T.words query'
|
||||
let rank :: Category -> Int
|
||||
rank cat = sum [
|
||||
length (queryWords `intersect` (cat^..items.each.name)),
|
||||
length (queryWords `intersect` T.words (cat^.title)) ]
|
||||
let rankedCategories
|
||||
| null queryWords = globalState^.categories
|
||||
| otherwise = filter ((/= 0) . rank) .
|
||||
reverse . sortOn rank
|
||||
$ globalState^.categories
|
||||
renderSearchResults rankedCategories
|
||||
Just query' -> renderSearchResults (search query' globalState)
|
||||
-- TODO: maybe add a button like “give me random category that is
|
||||
-- unfinished”
|
||||
|
||||
@ -700,13 +690,39 @@ renderCategoryList allCats = cached CacheCategoryList $ do
|
||||
a_ [class_ "category-link", href_ (categoryLink category)] $
|
||||
toHtml (category^.title)
|
||||
|
||||
-- | Render a page with search results (just a list of categories).
|
||||
renderSearchResults :: Monad m => [Category] -> HtmlT m ()
|
||||
renderSearchResults cats = do
|
||||
div_ [id_ "categories-search-results"] $
|
||||
for_ cats $ \category -> do
|
||||
a_ [class_ "category-link", href_ (categoryLink category)] $
|
||||
toHtml (category^.title)
|
||||
-- | Render a <div> with search results.
|
||||
renderSearchResults :: Monad m => [SearchResult] -> HtmlT m ()
|
||||
renderSearchResults rs = do
|
||||
div_ [id_ "search-results"] $
|
||||
mapM_ renderSearchResult rs
|
||||
|
||||
-- | Render one search result.
|
||||
renderSearchResult :: Monad m => SearchResult -> HtmlT m ()
|
||||
renderSearchResult r = do
|
||||
div_ [class_ "search-result"] $
|
||||
case r of
|
||||
SRCategory cat -> do
|
||||
a_ [class_ "category-link", href_ (categoryLink cat)] $
|
||||
toHtml (cat^.title)
|
||||
div_ [class_ "category-description notes-like"] $
|
||||
toHtml (extractPreface $ toMarkdownTree "" $ cat^.notes.mdText)
|
||||
SRItem cat item -> do
|
||||
a_ [class_ "category-link in-item-sr", href_ (categoryLink cat)] $
|
||||
toHtml (cat^.title)
|
||||
span_ [class_ "breadcrumb"] "»"
|
||||
a_ [class_ "item-link", href_ (itemLink cat item)] $
|
||||
toHtml (item^.name)
|
||||
div_ [class_ "description notes-like"] $
|
||||
toHtml (item^.description)
|
||||
SRItemEcosystem cat item -> do
|
||||
a_ [class_ "category-link in-item-sr", href_ (categoryLink cat)] $
|
||||
toHtml (cat^.title)
|
||||
span_ [class_ "breadcrumb"] "»"
|
||||
a_ [class_ "item-link", href_ (itemLink cat item)] $
|
||||
toHtml (item^.name)
|
||||
span_ [class_ "item-link-addition"] "'s ecosystem"
|
||||
div_ [class_ "ecosystem notes-like"] $
|
||||
toHtml (item^.ecosystem)
|
||||
|
||||
{- Note [enabled sections]
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
@ -109,7 +109,7 @@ import Guide.Views.Utils.Input
|
||||
-- | Add a script that does something on page load.
|
||||
onPageLoad :: Monad m => JS -> HtmlT m ()
|
||||
onPageLoad js = script_ $
|
||||
"$(document).ready(function(){"%<js>%"});"
|
||||
"$(document).ready(function(){"#|js|#"});"
|
||||
|
||||
-- | Add some empty space.
|
||||
emptySpan :: Monad m => Text -> HtmlT m ()
|
||||
@ -119,18 +119,18 @@ emptySpan w = span_ [style_ ("margin-left:" <> w)] mempty
|
||||
onEnter :: JS -> Attribute
|
||||
onEnter handler = onkeydown_ $
|
||||
"if (event.keyCode == 13 || event.keyCode == 10) {"
|
||||
%<handler>%" return false;}\n"
|
||||
#|handler|#" return false;}\n"
|
||||
|
||||
onCtrlEnter :: JS -> Attribute
|
||||
onCtrlEnter handler = onkeydown_ $
|
||||
"if ((event.keyCode == 13 || event.keyCode == 10) && " <>
|
||||
"(event.metaKey || event.ctrlKey)) {"
|
||||
%<handler>%" return false;}\n"
|
||||
#|handler|#" return false;}\n"
|
||||
|
||||
onEscape :: JS -> Attribute
|
||||
onEscape handler = onkeydown_ $
|
||||
"if (event.keyCode == 27) {"
|
||||
%<handler>%" return false;}\n"
|
||||
#|handler|#" return false;}\n"
|
||||
|
||||
textInput :: Monad m => [Attribute] -> HtmlT m ()
|
||||
textInput attrs = input_ (type_ "text" : attrs)
|
||||
@ -142,7 +142,7 @@ clearInput :: JS
|
||||
clearInput = JS "this.value = '';"
|
||||
|
||||
onFormSubmit :: (JS -> JS) -> Attribute
|
||||
onFormSubmit f = onsubmit_ $ format "{} return false;" [f (JS "this")]
|
||||
onFormSubmit f = onsubmit_ $ format "{} return false;" (f (JS "this"))
|
||||
|
||||
button :: Monad m => Text -> [Attribute] -> JS -> HtmlT m ()
|
||||
button value attrs handler =
|
||||
@ -191,7 +191,7 @@ markdownEditor
|
||||
-> HtmlT m ()
|
||||
markdownEditor attr (view mdText -> s) submit cancel instr = do
|
||||
textareaUid <- randomLongUid
|
||||
let val = JS $ "document.getElementById(\""%<textareaUid>%"\").value"
|
||||
let val = JS $ "document.getElementById(\""#|textareaUid|#"\").value"
|
||||
-- Autocomplete has to be turned off thanks to
|
||||
-- <http://stackoverflow.com/q/8311455>.
|
||||
textarea_ ([uid_ textareaUid,
|
||||
@ -223,7 +223,7 @@ smallMarkdownEditor
|
||||
-> HtmlT m ()
|
||||
smallMarkdownEditor attr (view mdText -> s) submit mbCancel instr = do
|
||||
textareaId <- randomLongUid
|
||||
let val = JS $ "document.getElementById(\""%<textareaId>%"\").value"
|
||||
let val = JS $ "document.getElementById(\""#|textareaId|#"\").value"
|
||||
textarea_ ([class_ "fullwidth", uid_ textareaId, autocomplete_ "off"] ++
|
||||
[onEnter (submit val)] ++
|
||||
[onEscape cancel | Just cancel <- [mbCancel]] ++
|
||||
@ -249,18 +249,18 @@ thisNode = do
|
||||
return (JS.selectParent (JS.selectUid uid'))
|
||||
|
||||
itemNodeId :: Item -> Text
|
||||
itemNodeId item = format "item-{}" [item^.uid]
|
||||
itemNodeId item = format "item-{}" (item^.uid)
|
||||
|
||||
categoryNodeId :: Category -> Text
|
||||
categoryNodeId category = format "category-{}" [category^.uid]
|
||||
categoryNodeId category = format "category-{}" (category^.uid)
|
||||
|
||||
-- TODO: another absolute link to get rid of [absolute-links]
|
||||
categoryLink :: Category -> Url
|
||||
categoryLink category = format "/haskell/{}" [categorySlug category]
|
||||
categoryLink category = format "/haskell/{}" (categorySlug category)
|
||||
|
||||
itemLink :: Category -> Item -> Url
|
||||
itemLink category item =
|
||||
format "/haskell/{}#{}" (categorySlug category, itemNodeId item)
|
||||
format "/haskell/{}#{}" (categorySlug category) (itemNodeId item)
|
||||
|
||||
-- See Note [show-hide]; wheh changing these, also look at 'JS.switchSection'.
|
||||
shown, noScriptShown :: Attribute
|
||||
|
@ -1,7 +1,7 @@
|
||||
module Main where
|
||||
|
||||
import qualified Guide.Server
|
||||
import qualified Guide.Main
|
||||
import Prelude (IO)
|
||||
|
||||
main :: IO ()
|
||||
main = Guide.Server.main
|
||||
main = Guide.Main.main
|
||||
|
10
stack.yaml
10
stack.yaml
@ -1,17 +1,15 @@
|
||||
resolver: lts-7.9
|
||||
resolver: lts-8.13
|
||||
|
||||
packages:
|
||||
- location: .
|
||||
- location:
|
||||
git: https://github.com/aelve/stache-plus
|
||||
commit: e8e7967d561148167eb1fe4112c6ad0e091490ab
|
||||
commit: 789aeabbf8069dec80647160f127d047e8f5a330
|
||||
extra-dep: true
|
||||
|
||||
extra-deps:
|
||||
- cmark-sections-0.1.0.2
|
||||
- http-client-0.5.1
|
||||
- edit-distance-vector-1.0.0.4
|
||||
- cmark-sections-0.1.0.3
|
||||
- patches-vector-0.1.5.4
|
||||
- fmt-0.0.0.4
|
||||
- fmt-0.2.0.0
|
||||
- Spock-digestive-0.3.0.0
|
||||
- digestive-functors-0.8.2.0
|
||||
|
4
static/cog.svg
Normal file
4
static/cog.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8" viewBox="0 0 8 8">
|
||||
<path d="M3.5 0l-.5 1.19c-.1.03-.19.08-.28.13l-1.19-.5-.72.72.5 1.19c-.05.1-.09.18-.13.28l-1.19.5v1l1.19.5c.04.1.08.18.13.28l-.5 1.19.72.72 1.19-.5c.09.04.18.09.28.13l.5 1.19h1l.5-1.19c.09-.04.19-.08.28-.13l1.19.5.72-.72-.5-1.19c.04-.09.09-.19.13-.28l1.19-.5v-1l-1.19-.5c-.03-.09-.08-.19-.13-.28l.5-1.19-.72-.72-1.19.5c-.09-.04-.19-.09-.28-.13l-.5-1.19h-1zm.5 2.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5-1.5-.67-1.5-1.5.67-1.5 1.5-1.5z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 528 B |
@ -132,12 +132,6 @@ body {
|
||||
margin-top: 3em;
|
||||
}
|
||||
|
||||
#categories-search-results {
|
||||
margin-top: 3em;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.category-group {
|
||||
width: 340px;
|
||||
padding: 0 30px;
|
||||
@ -180,7 +174,6 @@ body {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
#categories-search-results .category-link,
|
||||
.categories-finished .category-link {
|
||||
font-size: 21px;
|
||||
font-weight: 600;
|
||||
@ -191,6 +184,40 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* search results */
|
||||
|
||||
#search-results {
|
||||
margin-top: 3em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 0.9em 1.1em;
|
||||
margin: 1.5em 1px;
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
|
||||
}
|
||||
|
||||
.search-result .category-link {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.search-result .category-link.in-item-sr {
|
||||
font-size: 21px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.search-result .item-link, .search-result .item-link-addition {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.search-result .breadcrumb {
|
||||
color: #999;
|
||||
font-size: 22px;
|
||||
margin: 0px 6px;
|
||||
}
|
||||
|
||||
/* category pages */
|
||||
|
||||
.category {
|
||||
|
@ -88,7 +88,7 @@ HTML: item-info-controls
|
||||
</span>
|
||||
<span>
|
||||
{{> img-button
|
||||
src = "/pencil.svg"
|
||||
src = "/cog.svg"
|
||||
title = "edit item info"
|
||||
class = "edit-item-info"
|
||||
action = [| editItemInfo({{{%js item.uid}}}); |] }}
|
||||
@ -152,64 +152,84 @@ function editItemInfo(itemUid) {
|
||||
switchSection("#item-" + itemUid + " .item-info", "editing");
|
||||
}
|
||||
|
||||
CSS
|
||||
------------------------------------------------------------
|
||||
.item-info-edit-form label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.item-info-edit-form {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-btn-group {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.form-btn-group .save {
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
HTML: item-info-edit-form
|
||||
------------------------------------------------------------
|
||||
{{! "autocomplete=off" everywhere: http://stackoverflow.com/q/8311455 }}
|
||||
<form onsubmit="submitItemInfo('{{item.uid}}', this); return false;">
|
||||
<label>
|
||||
Name<br>
|
||||
<input name="name" value="{{item.name}}"
|
||||
type="text" autocomplete="off">
|
||||
<form class="item-info-edit-form" onsubmit="submitItemInfo('{{item.uid}}', this); return false;">
|
||||
<label for="name">
|
||||
Name
|
||||
</label>
|
||||
<br>
|
||||
<input id="name" name="name" value="{{item.name}}"
|
||||
type="text" autocomplete="off">
|
||||
|
||||
<label>
|
||||
Kind<br>
|
||||
<select name="kind" autocomplete="off">
|
||||
{{! possible_kinds would have stuff like “library”, “tool”, “other” }}
|
||||
{{#possible_kinds}}
|
||||
<option value="{{name}}" {{%selectIf selected}}>{{caption}}</option>
|
||||
{{/possible_kinds}}
|
||||
<label for="kind">
|
||||
Kind
|
||||
</label>
|
||||
<select id="kind" name="kind" autocomplete="off">
|
||||
{{! possible_kinds would have stuff like “library”, “tool”, “other” }}
|
||||
{{#possible_kinds}}
|
||||
<option value="{{name}}" {{%selectIf selected}}>{{caption}}</option>
|
||||
{{/possible_kinds}}
|
||||
</select>
|
||||
|
||||
<label for="hackage-name">
|
||||
Name on Hackage
|
||||
</label>
|
||||
<input id="hackage-name" name="hackage-name" value="{{#item.kind.hackageName}}{{.}}{{/item.kind.hackageName}}"
|
||||
type="text" autocomplete="off">
|
||||
|
||||
<label for="site">
|
||||
Site (optional)
|
||||
</label>
|
||||
<input id="site" name="link" value="{{item.link}}"
|
||||
type="text" autocomplete="off">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="group">
|
||||
Group
|
||||
</label>
|
||||
{{! When “new group” is selected in the list, we show a field for
|
||||
entering new group's name }}
|
||||
<select id="group" name="group" onchange="itemGroupSelectHandler(this);"
|
||||
autocomplete="off">
|
||||
<option value="-" {{%selectIf item_no_group}}>-</option>
|
||||
{{# category_groups }}
|
||||
<option value="{{name}}" {{%selectIf selected}}>{{name}}</option>
|
||||
{{/ category_groups }}
|
||||
<option value="">New group...</option>
|
||||
</select>
|
||||
</label>
|
||||
<br>
|
||||
|
||||
<label>
|
||||
Name on Hackage<br>
|
||||
<input name="hackage-name" value="{{#item.kind.hackageName}}{{.}}{{/item.kind.hackageName}}"
|
||||
type="text" autocomplete="off">
|
||||
</label>
|
||||
<br>
|
||||
|
||||
<label>
|
||||
Site (optional)<br>
|
||||
<input name="link" value="{{item.link}}"
|
||||
type="text" autocomplete="off">
|
||||
</label>
|
||||
<br>
|
||||
|
||||
<label>
|
||||
Group<br>
|
||||
{{! When “new group” is selected in the list, we show a field for
|
||||
entering new group's name }}
|
||||
<select name="group" onchange="itemGroupSelectHandler(this);"
|
||||
autocomplete="off">
|
||||
<option value="-" {{%selectIf item_no_group}}>-</option>
|
||||
{{# category_groups }}
|
||||
<option value="{{name}}" {{%selectIf selected}}>{{name}}</option>
|
||||
{{/ category_groups }}
|
||||
<option value="">New group...</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
<input hidden class="custom-group-input" name="custom-group"
|
||||
type="text" autocomplete="off">
|
||||
<br>
|
||||
</div>
|
||||
|
||||
<input value="Save" class="save" type="submit">
|
||||
<input value="Cancel" class="cancel" type="button"
|
||||
onclick="itemInfoCancelEdit('{{item.uid}}');">
|
||||
<div class="form-btn-group">
|
||||
<input value="Save" class="save" type="submit">
|
||||
<input value="Cancel" class="cancel" type="button"
|
||||
onclick="itemInfoCancelEdit('{{item.uid}}');">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
|
@ -101,7 +101,7 @@ tests = describe "Markdown" $ do
|
||||
describe "block+toc Markdown" $ do
|
||||
it "renders correctly" $ do
|
||||
let s = "x\n\n# foo\n\n## foo\n\ny"
|
||||
htmlToText (toMarkdownBlockWithTOC "i-" s) `shouldBe`
|
||||
htmlToText (toMarkdownTree "i-" s) `shouldBe`
|
||||
"<p>x</p>\n\
|
||||
\<h1><span id=\"i-foo\"></span>foo</h1>\
|
||||
\<h2><span id=\"i-foo_\"></span>foo</h2>\
|
||||
@ -113,7 +113,7 @@ tests = describe "Markdown" $ do
|
||||
headingMD = MD.Node Nothing (TEXT "foo") []
|
||||
foo2MD = MD.Node (Just (PosInfo 7 1 7 1)) PARAGRAPH
|
||||
[MD.Node Nothing (TEXT "y") []]
|
||||
(toMarkdownBlockWithTOC "i-" s ^. mdTree) `shouldBe` Document {
|
||||
(toMarkdownTree "i-" s ^. mdTree) `shouldBe` Document {
|
||||
prefaceAnn = "<p>x</p>\n",
|
||||
preface = Ann "x\n\n" [prefaceMD],
|
||||
sections = [
|
||||
@ -134,7 +134,7 @@ tests = describe "Markdown" $ do
|
||||
it "has a correct TOC" $ do
|
||||
let s = "x\n\n# foo\n\n## foo\n\ny"
|
||||
let headingMD = MD.Node Nothing (TEXT "foo") []
|
||||
(toMarkdownBlockWithTOC "i-" s ^. mdTOC) `shouldBe` [
|
||||
(toMarkdownTree "i-" s ^. mdTOC) `shouldBe` [
|
||||
Node {rootLabel = ([headingMD],"i-foo"),
|
||||
subForest = [
|
||||
Node {rootLabel = ([headingMD],"i-foo_"),
|
||||
@ -157,7 +157,7 @@ blockMarkdowns f = do
|
||||
describe "block MD" $
|
||||
f ((view mdText &&& htmlToText) . toMarkdownBlock)
|
||||
describe "block+toc MD" $
|
||||
f ((view mdText &&& htmlToText) . toMarkdownBlockWithTOC "")
|
||||
f ((view mdText &&& htmlToText) . toMarkdownTree "")
|
||||
|
||||
mdInlineExamples :: [Text]
|
||||
mdInlineExamples = [
|
||||
|
@ -28,7 +28,7 @@ import Selenium
|
||||
import qualified Test.WebDriver.Common.Keys as Key
|
||||
|
||||
-- Site
|
||||
import qualified Guide.Server
|
||||
import qualified Guide.Main
|
||||
import Guide.Config (Config(..))
|
||||
|
||||
|
||||
@ -611,7 +611,7 @@ run ts = do
|
||||
--
|
||||
-- Using 'Slave.fork' in 'Guide.mainWith' ensures that threads started
|
||||
-- inside of 'mainWith' will be killed too when the thread dies.
|
||||
tid <- Slave.fork $ Guide.Server.mainWith Config {
|
||||
tid <- Slave.fork $ Guide.Main.mainWith Config {
|
||||
_baseUrl = "/",
|
||||
_googleToken = "some-google-token",
|
||||
_adminPassword = "123",
|
||||
|
Loading…
Reference in New Issue
Block a user