This commit is contained in:
2023-10-24 05:23:25 +07:00
commit 7a8ce17237
33 changed files with 11927 additions and 0 deletions

0
src/.gitkeep Normal file
View File

107
src/Article.elm Normal file
View File

@ -0,0 +1,107 @@
module Article exposing (..)
import BackendTask
import BackendTask.File as File
import BackendTask.Glob as Glob
-- import Cloudinary
import Date exposing (Date)
import FatalError exposing (FatalError)
import Json.Decode as Decode exposing (Decoder)
import Pages.Url exposing (Url)
import Route
-- import UnsplashImage
type alias BlogPost =
{ filePath : String
, slug : String
}
blogPostsGlob : BackendTask.BackendTask error (List { filePath : String, slug : String })
blogPostsGlob =
Glob.succeed BlogPost
|> Glob.captureFilePath
|> Glob.match (Glob.literal "content/blog/")
|> Glob.capture Glob.wildcard
|> Glob.match (Glob.literal ".md")
|> Glob.toBackendTask
allMetadata :
BackendTask.BackendTask
{ fatal : FatalError, recoverable : File.FileReadError Decode.Error }
(List ( Route.Route, ArticleMetadata ))
allMetadata =
blogPostsGlob
|> BackendTask.map
(\paths ->
paths
|> List.map
(\{ filePath, slug } ->
BackendTask.map2 Tuple.pair
(BackendTask.succeed <| Route.Blog__Slug_ { slug = slug })
(File.onlyFrontmatter frontmatterDecoder filePath)
)
)
|> BackendTask.resolve
|> BackendTask.map
(\articles ->
articles
|> List.filterMap
(\( route, metadata ) ->
if metadata.draft then
Nothing
else
Just ( route, metadata )
)
)
|> BackendTask.map
(List.sortBy
(\( route, metadata ) -> -(Date.toRataDie metadata.published))
)
type alias ArticleMetadata =
{ title : String
, description : String
, published : Date
-- , image : Url
, draft : Bool
}
frontmatterDecoder : Decoder ArticleMetadata
frontmatterDecoder =
Decode.map4 ArticleMetadata
(Decode.field "title" Decode.string)
(Decode.field "description" Decode.string)
(Decode.field "published"
(Decode.string
|> Decode.andThen
(\isoString ->
case Date.fromIsoString isoString of
Ok date ->
Decode.succeed date
Err error ->
Decode.fail error
)
)
)
-- (Decode.oneOf
-- [ Decode.field "image" imageDecoder
-- , Decode.field "unsplash" UnsplashImage.decoder |> Decode.map UnsplashImage.imagePath
-- ]
-- )
(Decode.field "draft" Decode.bool
|> Decode.maybe
|> Decode.map (Maybe.withDefault False)
)
-- imageDecoder : Decoder Url
-- imageDecoder =
-- Decode.string
-- |> Decode.map (\cloudinaryAsset -> Cloudinary.url cloudinaryAsset Nothing 800)

17
src/Ellie.elm Normal file
View File

@ -0,0 +1,17 @@
module Ellie exposing (outputTabElmCss)
import Html.Styled exposing (Html)
import Html.Styled.Attributes as StyledAttr
outputTabElmCss : String -> Html msg
outputTabElmCss ellieId =
Html.Styled.iframe
[ StyledAttr.src <| "https://ellie-app.com/embed/" ++ ellieId ++ "?panel=output"
, StyledAttr.style "width" "100%"
, StyledAttr.style "height" "400px"
, StyledAttr.style "border" "0"
, StyledAttr.style "overflow" "hidden"
, StyledAttr.attribute "sandbox" "allow-modals allow-forms allow-popups allow-scripts allow-same-origin"
]
[]

38
src/Link.elm Normal file
View File

@ -0,0 +1,38 @@
module Link exposing (Link, external, internal, link)
import Html.Styled exposing (Attribute, Html, a)
import Html.Styled.Attributes as Attr
import Route exposing (Route)
external : String -> Link
external =
ExternalLink
internal : Route -> Link
internal =
RouteLink
type Link
= RouteLink Route
| ExternalLink String
link : Link -> List (Attribute msg) -> List (Html msg) -> Html msg
link link_ attrs children =
case link_ of
RouteLink route ->
Route.toLink
(\anchorAttrs ->
a
(List.map Attr.fromUnstyled anchorAttrs ++ attrs)
children
)
route
ExternalLink string ->
a
(Attr.href string :: attrs)
children

236
src/MarkdownCodec.elm Normal file
View File

@ -0,0 +1,236 @@
module MarkdownCodec exposing (isPlaceholder, noteTitle, titleAndDescription, withFrontmatter, withoutFrontmatter)
import BackendTask exposing (BackendTask)
import BackendTask.File as StaticFile
import FatalError exposing (FatalError)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Extra
import List.Extra
import Markdown.Block as Block exposing (Block)
import Markdown.Parser
import Markdown.Renderer
import MarkdownExtra
isPlaceholder : String -> BackendTask FatalError (Maybe ())
isPlaceholder filePath =
filePath
|> StaticFile.bodyWithoutFrontmatter
|> BackendTask.allowFatal
|> BackendTask.andThen
(\rawContent ->
Markdown.Parser.parse rawContent
|> Result.mapError (\_ -> FatalError.fromString "Markdown error")
|> Result.map
(\blocks ->
List.any
(\block ->
case block of
Block.Heading _ inlines ->
False
_ ->
True
)
blocks
|> not
)
|> BackendTask.fromResult
)
|> BackendTask.map
(\bool ->
if bool then
Nothing
else
Just ()
)
noteTitle : String -> BackendTask FatalError String
noteTitle filePath =
titleFromFrontmatter filePath
|> BackendTask.andThen
(\maybeTitle ->
maybeTitle
|> Maybe.map BackendTask.succeed
|> Maybe.withDefault
(StaticFile.bodyWithoutFrontmatter filePath
|> BackendTask.allowFatal
|> BackendTask.andThen
(\rawContent ->
Markdown.Parser.parse rawContent
|> Result.mapError (\_ -> FatalError.fromString "Markdown error")
|> Result.map
(\blocks ->
List.Extra.findMap
(\block ->
case block of
Block.Heading Block.H1 inlines ->
Just (Block.extractInlineText inlines)
_ ->
Nothing
)
blocks
)
|> Result.andThen
(Result.fromMaybe <|
FatalError.fromString ("Expected to find an H1 heading for page " ++ filePath)
)
|> BackendTask.fromResult
)
)
)
titleAndDescription : String -> BackendTask FatalError { title : String, description : String }
titleAndDescription filePath =
filePath
|> StaticFile.onlyFrontmatter
(Decode.map2 (\title description -> { title = title, description = description })
(Json.Decode.Extra.optionalField "title" Decode.string)
(Json.Decode.Extra.optionalField "description" Decode.string)
)
|> BackendTask.allowFatal
|> BackendTask.andThen
(\metadata ->
Maybe.map2 (\title description -> { title = title, description = description })
metadata.title
metadata.description
|> Maybe.map BackendTask.succeed
|> Maybe.withDefault
(StaticFile.bodyWithoutFrontmatter filePath
|> BackendTask.allowFatal
|> BackendTask.andThen
(\rawContent ->
Markdown.Parser.parse rawContent
|> Result.mapError (\_ -> FatalError.fromString "Markdown error")
|> Result.map
(\blocks ->
Maybe.map
(\title ->
{ title = title
, description =
case metadata.description of
Just description ->
description
Nothing ->
findDescription blocks
}
)
(case metadata.title of
Just title ->
Just title
Nothing ->
findH1 blocks
)
)
|> Result.andThen (Result.fromMaybe <| FatalError.fromString <| "Expected to find an H1 heading for page " ++ filePath)
|> BackendTask.fromResult
)
)
)
findH1 : List Block -> Maybe String
findH1 blocks =
List.Extra.findMap
(\block ->
case block of
Block.Heading Block.H1 inlines ->
Just (Block.extractInlineText inlines)
_ ->
Nothing
)
blocks
findDescription : List Block -> String
findDescription blocks =
blocks
|> List.Extra.findMap
(\block ->
case block of
Block.Paragraph inlines ->
Just (MarkdownExtra.extractInlineText inlines)
_ ->
Nothing
)
|> Maybe.withDefault ""
titleFromFrontmatter : String -> BackendTask FatalError (Maybe String)
titleFromFrontmatter filePath =
StaticFile.onlyFrontmatter
(Json.Decode.Extra.optionalField "title" Decode.string)
filePath
|> BackendTask.allowFatal
withoutFrontmatter :
Markdown.Renderer.Renderer view
-> String
-> BackendTask FatalError (List Block)
withoutFrontmatter renderer filePath =
(filePath
|> StaticFile.bodyWithoutFrontmatter
|> BackendTask.allowFatal
|> BackendTask.andThen
(\rawBody ->
rawBody
|> Markdown.Parser.parse
|> Result.mapError (\_ -> FatalError.fromString "Couldn't parse markdown.")
|> BackendTask.fromResult
)
)
|> BackendTask.andThen
(\blocks ->
blocks
|> Markdown.Renderer.render renderer
-- we don't want to encode the HTML since it contains functions so it's not serializable
-- but we can at least make sure there are no errors turning it into HTML before encoding it
|> Result.map (\_ -> blocks)
|> Result.mapError (\error -> FatalError.fromString error)
|> BackendTask.fromResult
)
withFrontmatter :
(frontmatter -> List Block -> value)
-> Decoder frontmatter
-> Markdown.Renderer.Renderer view
-> String
-> BackendTask FatalError value
withFrontmatter constructor frontmatterDecoder_ renderer filePath =
BackendTask.map2 constructor
(StaticFile.onlyFrontmatter
frontmatterDecoder_
filePath
|> BackendTask.allowFatal
)
(StaticFile.bodyWithoutFrontmatter
filePath
|> BackendTask.allowFatal
|> BackendTask.andThen
(\rawBody ->
rawBody
|> Markdown.Parser.parse
|> Result.mapError (\_ -> FatalError.fromString "Couldn't parse markdown.")
|> BackendTask.fromResult
)
|> BackendTask.andThen
(\blocks ->
blocks
|> Markdown.Renderer.render renderer
-- we don't want to encode the HTML since it contains functions so it's not serializable
-- but we can at least make sure there are no errors turning it into HTML before encoding it
|> Result.map (\_ -> blocks)
|> Result.mapError (\error -> FatalError.fromString error)
|> BackendTask.fromResult
)
)

114
src/MarkdownExtra.elm Normal file
View File

@ -0,0 +1,114 @@
module MarkdownExtra exposing (extractInlineText)
import Markdown.Block exposing (Block(..), Html(..), Inline(..), ListItem(..))
extractInlineText : List Inline -> String
extractInlineText inlines =
List.foldl extractTextHelp "" inlines
extractTextHelp : Inline -> String -> String
extractTextHelp inline text =
case inline of
Text str ->
text ++ str
HardLineBreak ->
text ++ " "
CodeSpan str ->
text ++ str
Link _ title inlines ->
text ++ (title |> Maybe.withDefault (extractInlineText inlines))
Image _ _ inlines ->
text ++ extractInlineText inlines
HtmlInline html ->
case html of
HtmlElement _ _ blocks ->
blocks
|> Markdown.Block.foldl
(\block soFar ->
soFar ++ extractInlineBlockText block
)
text
_ ->
text
Strong inlines ->
text ++ extractInlineText inlines
Emphasis inlines ->
text ++ extractInlineText inlines
Strikethrough inlines ->
text ++ extractInlineText inlines
extractInlineBlockText : Block -> String
extractInlineBlockText block =
case block of
Paragraph inlines ->
extractInlineText inlines
HtmlBlock html ->
case html of
HtmlElement _ _ blocks ->
blocks
|> Markdown.Block.foldl
(\nestedBlock soFar ->
soFar ++ extractInlineBlockText nestedBlock
)
""
_ ->
""
UnorderedList tight items ->
items
|> List.map
(\(ListItem task blocks) ->
blocks
|> List.map extractInlineBlockText
|> String.join "\n"
)
|> String.join "\n"
OrderedList tight int items ->
items
|> List.map
(\blocks ->
blocks
|> List.map extractInlineBlockText
|> String.join "\n"
)
|> String.join "\n"
BlockQuote blocks ->
blocks
|> List.map extractInlineBlockText
|> String.join "\n"
Heading headingLevel inlines ->
extractInlineText inlines
Table header rows ->
[ header
|> List.map .label
|> List.map extractInlineText
, rows
|> List.map (List.map extractInlineText)
|> List.concat
]
|> List.concat
|> String.join "\n"
CodeBlock { body } ->
body
ThematicBreak ->
""

View File

@ -0,0 +1,312 @@
module TailwindMarkdownRenderer exposing (renderer)
import Css
import Ellie
import Html.Styled as Html
import Html.Styled.Attributes as Attr exposing (css)
import Markdown.Block as Block
import Markdown.Html
import Markdown.Renderer
import Oembed
import SyntaxHighlight
import Tailwind.Theme as Theme
import Tailwind.Utilities as Tw
renderer : Markdown.Renderer.Renderer (Html.Html msg)
renderer =
{ heading = heading
, paragraph = Html.p []
, thematicBreak = Html.hr [] []
, text = Html.text
, strong = \content -> Html.strong [ css [ Tw.font_bold ] ] content
, emphasis = \content -> Html.em [ css [ Tw.italic ] ] content
, blockQuote = Html.blockquote []
, codeSpan =
\content ->
Html.code
[ css
[ Tw.font_semibold
, Tw.font_medium
, Css.color (Css.rgb 226 0 124) |> Css.important
]
]
[ Html.text content ]
--, codeSpan = code
, link =
\{ destination } body ->
Html.a
[ Attr.href destination
, css
[ Tw.underline
]
]
body
, hardLineBreak = Html.br [] []
, image =
\image ->
case image.title of
Just _ ->
Html.img [ Attr.src image.src, Attr.alt image.alt ] []
Nothing ->
Html.img [ Attr.src image.src, Attr.alt image.alt ] []
, unorderedList =
\items ->
Html.ul []
(items
|> List.map
(\item ->
case item of
Block.ListItem task children ->
let
checkbox =
case task of
Block.NoTask ->
Html.text ""
Block.IncompleteTask ->
Html.input
[ Attr.disabled True
, Attr.checked False
, Attr.type_ "checkbox"
]
[]
Block.CompletedTask ->
Html.input
[ Attr.disabled True
, Attr.checked True
, Attr.type_ "checkbox"
]
[]
in
Html.li [] (checkbox :: children)
)
)
, orderedList =
\startingIndex items ->
Html.ol
(case startingIndex of
1 ->
[ Attr.start startingIndex ]
_ ->
[]
)
(items
|> List.map
(\itemBlocks ->
Html.li []
itemBlocks
)
)
, html =
Markdown.Html.oneOf
[ Markdown.Html.tag "oembed"
(\url _ ->
Oembed.view [] Nothing url
|> Maybe.map Html.fromUnstyled
|> Maybe.withDefault (Html.div [] [])
)
|> Markdown.Html.withAttribute "url"
, Markdown.Html.tag "ellie-output"
(\ellieId _ ->
Ellie.outputTabElmCss ellieId
)
|> Markdown.Html.withAttribute "id"
]
, codeBlock = codeBlock
--\{ body, language } ->
-- let
-- classes =
-- -- Only the first word is used in the class
-- case Maybe.map String.words language of
-- Just (actualLanguage :: _) ->
-- [ Attr.class <| "language-" ++ actualLanguage ]
--
-- _ ->
-- []
-- in
-- Html.pre []
-- [ Html.code classes
-- [ Html.text body
-- ]
-- ]
, table =
Html.table
[ {-
table-layout: auto;
text-align: left;
width: 100%;
margin-top: 2em;
margin-bottom: 2em;
-}
css
[--Tw.table_auto
--, Tw.w_full
--, Tw.mt_4
--, Tw.mb_4
]
]
, tableHeader = Html.thead []
, tableBody = Html.tbody []
, tableRow = Html.tr []
, strikethrough =
\children -> Html.del [] children
, tableHeaderCell =
\maybeAlignment ->
let
attrs =
maybeAlignment
|> Maybe.map
(\alignment ->
case alignment of
Block.AlignLeft ->
"left"
Block.AlignCenter ->
"center"
Block.AlignRight ->
"right"
)
|> Maybe.map Attr.align
|> Maybe.map List.singleton
|> Maybe.withDefault []
in
Html.th attrs
, tableCell =
\maybeAlignment ->
let
attrs =
maybeAlignment
|> Maybe.map
(\alignment ->
case alignment of
Block.AlignLeft ->
"left"
Block.AlignCenter ->
"center"
Block.AlignRight ->
"right"
)
|> Maybe.map Attr.align
|> Maybe.map List.singleton
|> Maybe.withDefault []
in
Html.td attrs
}
rawTextToId : String -> String
rawTextToId rawText =
rawText
|> String.split " "
|> String.join "-"
|> String.toLower
heading : { level : Block.HeadingLevel, rawText : String, children : List (Html.Html msg) } -> Html.Html msg
heading { level, rawText, children } =
case level of
Block.H1 ->
Html.h1
[ css
[ Tw.text_4xl
, Tw.font_bold
, Tw.tracking_tight
, Tw.mt_2
, Tw.mb_4
]
]
children
Block.H2 ->
Html.h2
[ Attr.id (rawTextToId rawText)
, Attr.attribute "name" (rawTextToId rawText)
, css
[ Tw.text_3xl
, Tw.font_semibold
, Tw.tracking_tight
, Tw.mt_10
, Tw.pb_1
, Tw.border_b
]
]
[ Html.a
[ Attr.href <| "#" ++ rawTextToId rawText
, css
[ Tw.no_underline |> Css.important
]
]
(children
++ [ Html.span
[ Attr.class "anchor-icon"
, css
[ Tw.ml_2
, Tw.text_color Theme.gray_500
, Tw.select_none
]
]
[ Html.text "#" ]
]
)
]
_ ->
(case level of
Block.H1 ->
Html.h1
Block.H2 ->
Html.h2
Block.H3 ->
Html.h3
Block.H4 ->
Html.h4
Block.H5 ->
Html.h5
Block.H6 ->
Html.h6
)
[ css
[ Tw.font_bold
, Tw.text_lg
, Tw.mt_8
, Tw.mb_4
]
]
children
--code : String -> Element msg
--code snippet =
-- Element.el
-- [ Element.Background.color
-- (Element.rgba255 50 50 50 0.07)
-- , Element.Border.rounded 2
-- , Element.paddingXY 5 3
-- , Font.family [ Font.typeface "Roboto Mono", Font.monospace ]
-- ]
-- (Element.text snippet)
--
--
codeBlock : { body : String, language : Maybe String } -> Html.Html msg
codeBlock details =
SyntaxHighlight.elm details.body
|> Result.map (SyntaxHighlight.toBlockHtml (Just 1))
|> Result.map Html.fromUnstyled
|> Result.withDefault (Html.pre [] [ Html.code [] [ Html.text details.body ] ])