{-# LANGUAGE QuasiQuotes, TypeFamilies #-} module Nirum.Targets.Docs ( Docs (..) , blockToHtml , makeFilePath , makeUri , moduleTitle ) where import Data.Char import qualified Data.List import Data.Maybe import GHC.Exts (IsList (fromList, toList)) import qualified Data.ByteString as BS import Data.ByteString.Lazy (toStrict) import qualified Text.Email.Parser as E import Data.Map.Strict (Map, mapKeys, mapWithKey, unions) import qualified Data.Text as T import qualified Data.Text.Lazy as TL import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Data.SemVer import System.FilePath import Text.Blaze (ToMarkup (preEscapedToMarkup)) import Text.Blaze.Html.Renderer.Utf8 (renderHtml) import Text.Cassius import Text.Hamlet (Html, shamlet) import Text.InterpolatedString.Perl6 (q) import Text.Julius import Nirum.Constructs (Construct (toCode)) import Nirum.Constructs.Declaration (Documented (docsBlock)) import qualified Nirum.Constructs.Declaration as DE import qualified Nirum.Constructs.DeclarationSet as DES import qualified Nirum.Constructs.Docs as D import Nirum.Constructs.Identifier ( Identifier , toNormalizedString , toNormalizedText ) import Nirum.Constructs.Module (Module (Module, docs)) import Nirum.Constructs.ModulePath (ModulePath) import Nirum.Constructs.Name (Name (facialName)) import qualified Nirum.Constructs.Service as S import qualified Nirum.Constructs.TypeDeclaration as TD import qualified Nirum.Constructs.TypeExpression as TE import Nirum.Docs ( Block (..) , Inline (..) , extractTitle , filterReferences , transformReferences , trimTitle ) import Nirum.Docs.Html (render, renderInlines, renderLinklessInlines) import Nirum.Package import Nirum.Package.Metadata hiding (target) import qualified Nirum.Package.ModuleSet as MS import Nirum.TypeInstance.BoundModule import Nirum.Version (versionText) data Docs = Docs { docsTitle :: T.Text , docsOpenGraph :: [OpenGraph] , docsStyle :: T.Text , docsHeader :: T.Text , docsFooter :: T.Text } deriving (Eq, Ord, Show) type Error = T.Text data CurrentPage = IndexPage | ModulePage ModulePath | DocumentPage FilePath deriving (Eq, Show) data OpenGraph = OpenGraph { ogTag :: T.Text , ogContent :: T.Text } deriving (Eq, Ord, Show) makeFilePath :: ModulePath -> FilePath makeFilePath modulePath' = foldl () "" $ map toNormalizedString (toList modulePath') ++ ["index.html"] -- FIXME: remove trailing index.html on production makeUri :: ModulePath -> T.Text makeUri modulePath' = T.intercalate "/" $ map toNormalizedText (toList modulePath') ++ ["index.html"] layout :: Package Docs -> Int -> CurrentPage -> T.Text -> Html -> Html layout pkg dirDepth currentPage title body = layout' pkg dirDepth currentPage title body Nothing layout' :: Package Docs -> Int -> CurrentPage -> T.Text -> Html -> Maybe Html -> Html layout' pkg@Package { metadata = md, modules = ms } dirDepth currentPage title body footer = [shamlet| $doctype 5 $if (title == docsTitle (target pkg)) #{title} $else #{title} — #{docsTitle (target pkg)} <meta name="generator" content="Nirum #{versionText}"> $forall Author { name = name' } <- authors md <meta name="author" content="#{name'}"> $forall OpenGraph { ogTag, ogContent } <- docsOpenGraph $ target pkg <meta property="#{ogTag}" content="#{ogContent}"> <link rel="stylesheet" href="#{root}style.css"> <link rel="stylesheet" href="#{hljsCss}"> <script src="#{root}nirum.js"></script> <script src="#{hljsJs}"></script> <script src="#{root}nirumHighlight.js"></script> <body> #{preEscapedToMarkup $ docsHeader $ target pkg} <nav> $if currentPage == IndexPage <a class="index selected" href="#{root}index.html"> <strong> #{docsTitle $ target pkg} <small.version>#{Data.SemVer.toText $ version md} $else <a class="index" href="#{root}index.html"> <span> #{docsTitle $ target pkg} <small.version>#{Data.SemVer.toText $ version md} $if not (null documentPairs) <ul.manuals.toc> $forall (docPath, doc) <- documentPairs $if currentPage == DocumentPage docPath <li.selected> <a href="#{root}#{documentHtmlPath docPath}"> <strong> #{renderDocumentTitle docPath doc} $else <li> <a href="#{root}#{documentHtmlPath docPath}"> #{renderDocumentTitle docPath doc} <ul.modules.toc> $forall (modulePath', mod) <- modulePairs $if currentPage == ModulePage modulePath' <li.selected> <a href="#{root}#{makeUri modulePath'}"> <strong> <code>#{toCode modulePath'}</code> $maybe tit <- moduleTitle mod — #{tit} $else <li> <a href="#{root}#{makeUri modulePath'}"> <code>#{toCode modulePath'}</code> $maybe tit <- moduleTitle mod — #{tit} <article>#{body} $maybe f <- footer <footer>#{f} #{preEscapedToMarkup $ docsFooter $ target pkg} |] where root :: T.Text root = T.replicate dirDepth "../" modulePairs :: [(ModulePath, Module)] modulePairs = MS.toAscList ms documentPairs :: [(FilePath, D.Docs)] documentPairs = Data.List.sortOn documentSortKey (toList $ fst $ listDocuments pkg) documentSortKey :: (FilePath, D.Docs) -> (Bool, Int, FilePath) documentSortKey ("", _) = (False, 0, "") documentSortKey (fp@(fp1 : _), _) = (isUpper fp1, length (filter (== pathSeparator) fp), fp) hljsBase :: T.Text hljsBase = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/" hljsCss :: T.Text hljsCss = T.concat [hljsBase, "styles/github.min.css"] hljsJs :: T.Text hljsJs = T.concat [hljsBase, "highlight.min.js"] typeExpression :: BoundModule Docs -> TE.TypeExpression -> Html typeExpression _ expr = [shamlet|#{typeExpr expr}|] where typeExpr :: TE.TypeExpression -> Html typeExpr expr' = [shamlet| $case expr' $of TE.TypeIdentifier ident #{toCode ident} $of TE.OptionModifier type' #{typeExpr type'}? $of TE.SetModifier elementType {#{typeExpr elementType}} $of TE.ListModifier elementType [#{typeExpr elementType}] $of TE.MapModifier keyType valueType {#{typeExpr keyType}: #{typeExpr valueType}} |] module' :: BoundModule Docs -> Html module' docsModule = layout pkg depth (ModulePage docsModulePath) path [shamlet| $maybe tit <- headingTitle <h1> <dfn><code>#{path}</code> — #{tit} $nothing <h1><code>#{path}</code> $maybe m <- mod' $maybe d <- docsBlock m #{blockToHtml (trimTitle d)} $forall (ident, decl) <- types' <div class="#{showKind decl}" id="#{toNormalizedText ident}"> #{typeDecl docsModule ident decl} |] where docsModulePath :: ModulePath docsModulePath = modulePath docsModule pkg :: Package Docs pkg = boundPackage docsModule path :: T.Text path = toCode docsModulePath types' :: [(Identifier, TD.TypeDeclaration)] types' = [ (facialName $ DE.name decl, decl) | decl <- DES.toList $ boundTypes docsModule , case decl of TD.Import {} -> False _ -> True ] mod' :: Maybe Module mod' = resolveModule docsModulePath pkg headingTitle :: Maybe Html headingTitle = do m <- mod' moduleTitle m depth :: Int depth = length $ toList docsModulePath blockToHtml :: Block -> Html blockToHtml = preEscapedToMarkup . render . transformReferences replaceUrl where replaceUrl :: T.Text -> T.Text replaceUrl url | isAbsoluteUrl url = url | otherwise = let (path, frag) = T.break (== '#') url in T.pack (documentHtmlPath $ T.unpack path) `T.append` frag isAbsoluteUrl :: T.Text -> Bool isAbsoluteUrl url | T.null rest = False | T.null scheme = True | otherwise = T.all testChar scheme && isAlpha (T.head scheme) where (scheme, rest) = T.break (== ':') url testChar :: Char -> Bool testChar c = (isAscii c && isAlphaNum c) || c == '.' || c == '-' typeDecl :: BoundModule Docs -> Identifier -> TD.TypeDeclaration -> Html typeDecl mod' ident tc@TD.TypeDeclaration { TD.type' = TD.Alias cname } = [shamlet| <h2 id="#{toNormalizedText ident}"> type <dfn><code>#{toNormalizedText ident}</code></dfn> = # <code.type>#{typeExpression mod' cname}</code> $maybe d <- docsBlock tc #{blockToHtml d} |] typeDecl mod' ident tc@TD.TypeDeclaration { TD.type' = TD.UnboxedType innerType } = [shamlet| <h2 id="#{toNormalizedText ident}"> unboxed <dfn><code>#{toNormalizedText ident}</code> (<code>#{typeExpression mod' innerType}</code>) $maybe d <- docsBlock tc #{blockToHtml d} |] typeDecl _ ident tc@TD.TypeDeclaration { TD.type' = TD.EnumType members } = [shamlet| <h2 id="#{toNormalizedText ident}"> enum <dfn><code>#{toNormalizedText ident}</code></dfn> $maybe d <- docsBlock tc #{blockToHtml d} <dl class="members"> $forall decl <- DES.toList members <dt class="member-name"> <code>#{nameText $ DE.name decl} <dd class="member-doc"> $maybe d <- docsBlock decl #{blockToHtml d} |] typeDecl mod' ident tc@TD.TypeDeclaration { TD.type' = TD.RecordType fields } = [shamlet| <h2 id="#{toNormalizedText ident}"> record <dfn><code>#{toNormalizedText ident}</code></dfn> $maybe d <- docsBlock tc #{blockToHtml d} <dl.fields> $forall fieldDecl@(TD.Field _ fieldType _) <- DES.toList fields <dt> <code.type>#{typeExpression mod' fieldType} <var><code>#{nameText $ DE.name fieldDecl}</code> <dd> $maybe d <- docsBlock fieldDecl #{blockToHtml d} |] typeDecl mod' ident tc@TD.TypeDeclaration { TD.type' = unionType@TD.UnionType { TD.defaultTag = defaultTag } } = [shamlet| <h2 id="#{toNormalizedText ident}"> union <dfn><code>#{toNormalizedText ident}</code></dfn> $maybe d <- docsBlock tc #{blockToHtml d} $forall (default_, tagDecl@(TD.Tag _ fields _)) <- tagList <h3 .tag :default_:.default-tag> $if default_ default tag # $else tag # <dfn><code>#{nameText $ DE.name tagDecl}</code> $maybe d <- docsBlock tagDecl #{blockToHtml d} <dl.fields> $forall fieldDecl@(TD.Field _ fieldType _) <- DES.toList fields <dt> <code.type>#{typeExpression mod' fieldType} <var><code>#{nameText $ DE.name fieldDecl}</code> <dd> $maybe d <- docsBlock fieldDecl #{blockToHtml d} |] where tagList :: [(Bool, TD.Tag)] tagList = [ (defaultTag == Just tag, tag) | tag <- DES.toList (TD.tags unionType) ] typeDecl _ ident TD.TypeDeclaration { TD.type' = TD.PrimitiveType {} } = [shamlet| <h2 id="#{toNormalizedText ident}"> primitive <code>#{toNormalizedText ident}</code> |] typeDecl mod' ident tc@TD.ServiceDeclaration { TD.service = S.Service methods } = [shamlet| <h2 id="#{toNormalizedText ident}"> service <dfn><code>#{toNormalizedText ident}</code></dfn> $maybe d <- docsBlock tc #{blockToHtml d} $forall md@(S.Method _ ps ret err _) <- DES.toList methods <h3.method> $maybe retType <- ret <span.return> <code.type>#{typeExpression mod' retType} <dfn> <code>#{nameText $ DE.name md} <span.parentheses>() $maybe errType <- err <span.error> throws <code.error.type>#{typeExpression mod' errType} $maybe d <- docsBlock md #{blockToHtml d} <dl.parameters> $forall paramDecl@(S.Parameter _ paramType _) <- DES.toList ps $maybe d <- docsBlock paramDecl <dt> <code.type>#{typeExpression mod' paramType} <code><var>#{nameText $ DE.name paramDecl}</var> <dd>#{blockToHtml d} |] typeDecl _ _ TD.Import {} = error ("It shouldn't happen; please report it to Nirum's bug tracker:\n" ++ "https://github.com/nirum-lang/nirum/issues") nameText :: Name -> T.Text nameText = toNormalizedText . facialName showKind :: TD.TypeDeclaration -> T.Text showKind TD.ServiceDeclaration {} = "service" showKind TD.TypeDeclaration { TD.type' = type'' } = case type'' of TD.Alias {} -> "alias" TD.UnboxedType {} -> "unboxed" TD.EnumType {} -> "enum" TD.RecordType {} -> "record" TD.UnionType {} -> "union" TD.PrimitiveType {} -> "primitive" showKind TD.Import {} = "import" readmePage :: Package Docs -> D.Docs -> Html readmePage pkg docs' = layout pkg 0 IndexPage (docsTitle $ target pkg) content where content :: Html content = blockToHtml $ D.toBlock docs' documentPage :: Package Docs -> FilePath -> D.Docs -> Html documentPage pkg filePath docs' = layout pkg depth (DocumentPage filePath) title' content where title' :: T.Text title' = documentTitleText filePath docs' content :: Html content = blockToHtml $ D.toBlock docs' depth :: Int depth = length (splitPath (documentHtmlPath filePath)) - 1 documentHtmlPath :: FilePath -> FilePath documentHtmlPath = (-<.> "html") documentTitle :: FilePath -> D.Docs -> [Inline] documentTitle filePath document = case extractTitle $ D.toBlock document of Just (_, inlines) -> inlines Nothing -> [Text $ T.pack filePath] renderDocumentTitle :: FilePath -> D.Docs -> Html renderDocumentTitle filePath = preEscapedToMarkup . renderLinklessInlines . documentTitle filePath documentTitleText :: FilePath -> D.Docs -> T.Text documentTitleText filePath = renderInlines' . documentTitle filePath where renderInline :: Inline -> T.Text renderInline (Text t) = t renderInline SoftLineBreak = "\n" renderInline HardLineBreak = "\n" renderInline (HtmlInline _) = "" renderInline (Code code') = code' renderInline (Emphasis inlines) = renderInlines inlines renderInline (Strong inlines) = renderInlines inlines renderInline (Link _ _ inlines) = renderInlines inlines renderInline (Image _ title) | T.null title = "" | otherwise = title renderInlines' :: [Inline] -> T.Text renderInlines' = T.concat . fmap renderInline contents :: Package Docs -> Html contents pkg@Package { metadata = md , modules = ms } = layout' pkg 0 IndexPage title body $ case authors md of [] -> Nothing _ : _ -> Just footer where body :: Html body = [shamlet| <h1>Modules $forall (modulePath', mod) <- MS.toAscList ms <h2> <a href="#{makeUri modulePath'}"> <code>#{toCode modulePath'}</code> $maybe tit <- moduleTitle mod — #{tit} |] title :: T.Text title = docsTitle $ target pkg footer :: Html footer = [shamlet| <dl> <dt.author> $if 1 < length (authors md) Authors $else Author $forall Author { name = n, uri = u, email = e } <- authors md $maybe uri' <- u <dd.author><a href="#{show uri'}">#{n}</a> $nothing $maybe email' <- e <dd.author><a href="mailto:#{emailText email'}">#{n}</a> $nothing <dd.author>#{n} |] emailText :: E.EmailAddress -> T.Text emailText = decodeUtf8 . E.toByteString moduleTitle :: Module -> Maybe Html moduleTitle Module { docs = docs' } = do d <- docs' t <- D.title d nodes <- case t of Heading _ inlines _ -> Just $ filterReferences inlines _ -> Nothing return $ preEscapedToMarkup $ renderInlines nodes stylesheet :: TL.Text stylesheet = renderCss ([cassius| @import url(#{fontUrl}) body padding: 0 margin: 0 font-family: Source Sans Pro color: #{gray8} article line-height: 1.3 code font-family: Source Code Pro font-weight: 300 background-color: #{gray1} strong code font-weight: 400 pre padding: 16px 10px background-color: #{gray1} code, code.hljs background: none div border-top: 1px solid #{gray3} h1, h2, h3, h4, h5, h6 code background-color: #{gray3} h1, h2, h3, h4, h5, h6, dt font-weight: bold code font-weight: 400 h2 a.pilcrow visibility: hidden margin-left: 10px h2:hover a.pilcrow visibility: visible a text-decoration: none a:link color: #{indigo8} a:visited color: #{graph8} a:hover text-decoration: underline dd p margin-top: 0 nav position: fixed left: 0 top: 0 bottom: 0 width: #{navWidth} overflow-y: auto border-right: 1px solid #{gray2} background-color: #{gray0} code background: none a.index, ul.toc li a display: block padding: 0 1em margin: 1em 0 overflow: hidden white-space: nowrap text-overflow: ellipsis color: #{gray8} .selected > a, a.selected color: #{indigo8} !important a.index text-decoration: none .version margin-left: 0.2em color: #{gray5} .version:before content: '(' .version:after content: ')' a.index.selected .version color: #{indigo3} a.index:hover span, strong text-decoration: underline ul.toc margin: 0 padding: 0 border-top: 1px solid #{gray2} li display: block article, footer margin-left: #{navWidth} padding: 1em > h1, > h2, > h3, > h4, > h5, > h6, > dl &:first-child margin-top: 0 footer border-top: 1px solid #{gray2} |] undefined) where fontUrl :: T.Text fontUrl = T.concat [ "https://fonts.googleapis.com/css" , "?family=Source+Code+Pro:300,400|Source+Sans+Pro" ] -- from Open Color https://yeun.github.io/open-color/ gray0 :: Color gray0 = Color 0xf8 0xf9 0xfa gray1 :: Color gray1 = Color 0xf1 0xf3 0xf5 gray2 :: Color gray2 = Color 0xe9 0xec 0xef gray3 :: Color gray3 = Color 0xde 0xe2 0xe6 gray5 :: Color gray5 = Color 0xad 0xb5 0xbd gray8 :: Color gray8 = Color 0x34 0x3a 0x40 graph8 :: Color graph8 = Color 0x9c 0x36 0xb5 indigo3 :: Color indigo3 = Color 0x91 0xa7 0xff indigo8 :: Color indigo8 = Color 0x3b 0x5b 0xdb navWidth :: PixelSize navWidth = PixelSize 300 javascript :: T.Text javascript = [q| window.addEventListener('load', function () { document.querySelectorAll('h2[id]').forEach(function(node) { var anchor = document.createElement("a"); anchor.className = "pilcrow"; anchor.text = "\xb6"; anchor.href = "#" + node.id; node.appendChild(anchor); }); }); |] nirumHighlightJavascript :: TL.Text nirumHighlightJavascript = renderJavascript ([julius| hljs.registerLanguage('nirum', function(hljs) { // Unfortunately CDN for highlight.js does not deliver // non-bundled unminified source, so we minify it by hand. // See also: // https://github.com/highlightjs/highlight.js/blob/master/tools/utility.js var begin = 'b'; var beginKeywords = 'bK'; var className = 'cN'; var contains = 'c'; var end = 'e'; var excludeEnd = 'eE'; var keywords = 'k'; var relevance = 'r'; var C_LINE_COMMENT_MODE = hljs.CLCM; var HASH_COMMENT_MODE = hljs.HCM; var APOS_STRING_MODE = hljs.ASM; var QUOTE_STRING_MODE = hljs.QSM; var NUMBER_MODE = hljs.NM; var NIRUM_IDENTIFIER_RE = '(`)?[a-zA-Z][a-zA-Z0-9\\-_]*\\1'; var NIRUM_IDENTIFIER_MODE = { [className]: 'title', [begin]: NIRUM_IDENTIFIER_RE, [relevance]: 0, }; return { [keywords]: { keyword: 'record enum unboxed type union service' + ' import throws as default', built_in: 'bigint decimal int32 int64 float32 float64' + ' text binary datetime date bool uuid uri', }, [contains]: [ // comments C_LINE_COMMENT_MODE, HASH_COMMENT_MODE, // string literals APOS_STRING_MODE, QUOTE_STRING_MODE, // number literals NUMBER_MODE, // annotations { [className]: 'meta', [begin]: '@\\\s*' + NIRUM_IDENTIFIER_RE, }, // typedefs { [className]: 'class', [beginKeywords]: 'enum record union service', [end]: '[\\(=]', [excludeEnd]: true, [contains]: [ C_LINE_COMMENT_MODE, HASH_COMMENT_MODE, NIRUM_IDENTIFIER_MODE, ] }, ], } }); hljs.initHighlightingOnLoad(); |] undefined :: Javascript) compilePackage' :: Package Docs -> Map FilePath (Either Error BS.ByteString) compilePackage' pkg = unions [ fromList [ ("style.css", Right $ encodeUtf8 css) , ("nirum.js", Right $ encodeUtf8 javascript) , ( "nirumHighlight.js" , Right $ encodeUtf8 $ TL.toStrict nirumHighlightJavascript ) , ( "index.html" , Right $ toStrict $ renderHtml $ case readme of Nothing -> contents pkg Just readme' -> readmePage pkg readme' ) ] , fromList [ ( makeFilePath $ modulePath m , Right $ toStrict $ renderHtml $ module' m ) | m <- modules' ] , mapKeys documentHtmlPath $ fmap (Right . toStrict . renderHtml) (mapWithKey (documentPage pkg) documents') ] where paths' :: [ModulePath] paths' = MS.keys $ modules pkg modules' :: [BoundModule Docs] modules' = mapMaybe (`resolveBoundModule` pkg) paths' css = T.concat [TL.toStrict stylesheet, "\n\n", docsStyle $ target pkg] (documents', readme) = listDocuments pkg listDocuments :: Package Docs -> (Map FilePath D.Docs, Maybe D.Docs) listDocuments Package { documents = documents' } = case Data.List.break (isReadme . fst) pairs of (a, (_, readme) : b) -> (fromList (a ++ b), Just readme) (a, []) -> (fromList a, Nothing) where isReadme :: FilePath -> Bool isReadme fp = "readme.md" == map toLower (takeFileName fp) pairs :: [(FilePath, D.Docs)] pairs = toList documents' instance Target Docs where type CompileResult Docs = BS.ByteString type CompileError Docs = Error targetName _ = "docs" parseTarget table = do title <- stringField "title" table opengraphs <- optional $ opengraphsField "opengraphs" table style <- optional $ stringField "style" table header <- optional $ stringField "header" table footer <- optional $ stringField "footer" table return Docs { docsTitle = title , docsOpenGraph = fromMaybe [] opengraphs , docsStyle = fromMaybe "" style , docsHeader = fromMaybe "" header , docsFooter = fromMaybe "" footer } compilePackage = compilePackage' showCompileError _ = id toByteString _ = id opengraphsField :: MetadataField -> Table -> Either MetadataError [OpenGraph] opengraphsField field' table = do array <- tableArrayField field' table opengraphs' <- mapM parseOpenGraph array return $ toList opengraphs' where parseOpenGraph :: Table -> Either MetadataError OpenGraph parseOpenGraph t = do tag' <- stringField "tag" t content' <- stringField "content" t return OpenGraph { ogTag = tag' , ogContent = content' }