Merge branch 'master' into pr/90

This commit is contained in:
Dillon Kearns 2020-05-11 12:05:38 -07:00
commit 29719cc428
60 changed files with 6109 additions and 3727 deletions

View File

@ -40,6 +40,15 @@
"contributions": [
"code"
]
},
{
"login": "lukewestby",
"name": "Luke Westby",
"avatar_url": "https://avatars1.githubusercontent.com/u/1508245?v=4",
"profile": "https://sunrisemovement.com",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
@ -47,5 +56,6 @@
"projectOwner": "dillonkearns",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true
"skipCi": true,
"commitConvention": "none"
}

View File

@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
## [4.0.1] - 2020-03-28
### Added
- You can now host your `elm-pages` site in a sub-directory. For example, you could host it at mysite.com/blog, where the top-level mysite.com/ is hosting a different app.
This works using [HTML `<base>` tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base). The paths you get from `PagePath.toString` and `ImagePath.toString`
will use relative paths (e.g. `blog/my-article`) instead of absolute URLs (e.g. `/blog/my-article`), so you can take advantage of this functionality by just making sure you
use the path helpers and don't hardcode any absolute URL strings. See https://github.com/dillonkearns/elm-pages/pull/73.
## [4.0.0] - 2020-03-04
### Changed

View File

@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
## [Unreleased]
## [1.3.0] - 2020-03-28
### Added
- You can now host your `elm-pages` site in a sub-directory. For example, you could host it at mysite.com/blog, where the top-level mysite.com/ is hosting a different app.
This works using [HTML `<base>` tags](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base). The paths you get from `PagePath.toString` and `ImagePath.toString`
will use relative paths (e.g. `blog/my-article`) instead of absolute URLs (e.g. `/blog/my-article`), so you can take advantage of this functionality by just making sure you
use the path helpers and don't hardcode any absolute URL strings. See https://github.com/dillonkearns/elm-pages/pull/73.
## [1.2.11] - 2020-03-18
### Fixed

View File

@ -1,9 +1,7 @@
# `elm-pages` [![Netlify Status](https://api.netlify.com/api/v1/badges/8ee4a674-4f37-4f16-b99e-607c0a02ee75/deploy-status)](https://app.netlify.com/sites/elm-pages/deploys) [![Build Status](https://github.com/dillonkearns/elm-pages/workflows/Elm%20CI/badge.svg)](https://github.com/dillonkearns/elm-pages/actions?query=branch%3Amaster) [![npm](https://img.shields.io/npm/v/elm-pages.svg)](https://npmjs.com/package/elm-pages) [![Elm package](https://img.shields.io/elm-package/v/dillonkearns/elm-pages.svg)](https://package.elm-lang.org/packages/dillonkearns/elm-pages/latest/)
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-3-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-5-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/dillonkearns/elm-pages-starter)
@ -165,12 +163,12 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://citric.id"><img src="https://avatars1.githubusercontent.com/u/296665?v=4" width="100px;" alt=""/><br /><sub><b>Steven Vandevelde</b></sub></a><br /><a href="https://github.com/dillonkearns/elm-pages/commits?author=icidasset" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Y0hy0h"><img src="https://avatars0.githubusercontent.com/u/11377826?v=4" width="100px;" alt=""/><br /><sub><b>Johannes Maas</b></sub></a><br /><a href="#userTesting-Y0hy0h" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/vViktorPL"><img src="https://avatars1.githubusercontent.com/u/2961541?v=4" width="100px;" alt=""/><br /><sub><b>Wiktor Toporek</b></sub></a><br /><a href="https://github.com/dillonkearns/elm-pages/commits?author=vViktorPL" title="Code">💻</a></td>
<td align="center"><a href="https://sunrisemovement.com"><img src="https://avatars1.githubusercontent.com/u/1508245?v=4" width="100px;" alt=""/><br /><sub><b>Luke Westby</b></sub></a><br /><a href="https://github.com/dillonkearns/elm-pages/commits?author=lukewestby" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!

22
docs/FAQ.md Normal file
View File

@ -0,0 +1,22 @@
## Can you pass flags in to your `elm-pages` app?
There's no way to pass flags in right now. I'm collecting use cases and trying to figure out what the most intuitive thing would be conceptual, given that the value of flags will be different during Pre-Rendering and Client-Side Rendering.
So for example, if you get the window dimensions from the flags and do responsive design based on that, then you'll see a flash after the client-side code takes over since it will run with a different value for flags. So that semantics of the flags are not quite intuitive there. You can achieve the same thing with a port, but the semantics are a little more obvious there because you now have to explicitly say how to handle the case where you don't have access to flags.
## How do you handle responsive layouts when you don't the browser dimensions at build time?
A lot of users are building their `elm-pages` views with `elm-ui`, so this is a common question because
`elm-ui` is designed to do responsive layouts by storing the browser dimensions in the Model and
doing conditionals based on that state.
With `elm-pages`, and static sites in general, we are building pre-rendered HTML so we can serve it up
really quickly through a CDN, rather than serving it up with a traditional server framework. That means
that to have responsive pages that don't have a page flash, we need to use media queries to make our pages responsive.
That way, the view is the same no matter what the dimensions are, so it will pre-render and look right on whatever
device the user is on because the media queries will take care of making it responsive.
Since `elm-ui` isn't currently built with media queries in mind, it isn't a first-class experience to use them with
`elm-ui`. One workaround you can use is to define some responsive classes that simply show or hide an element based on
a media query, and apply those classes. For example, you could show the mobile or desktop version of the navbar
by having a `mobile-responsive` and `desktop-responsive` class and rendering one element with each respsective class.
But the media query will only show one at a time based on the dimensions.

View File

@ -3,10 +3,12 @@
"name": "dillonkearns/elm-pages",
"summary": "A statically typed site generator.",
"license": "BSD-3-Clause",
"version": "4.0.0",
"version": "4.0.1",
"exposed-modules": [
"Head",
"Head.Seo",
"OptimizedDecoder",
"OptimizedDecoder.Pipeline",
"Pages.Document",
"Pages.ImagePath",
"Pages.PagePath",
@ -32,7 +34,6 @@
"elm-community/list-extra": "8.2.2 <= v < 9.0.0",
"elm-community/result-extra": "2.2.1 <= v < 3.0.0",
"lukewestby/elm-string-interpolate": "1.0.4 <= v < 2.0.0",
"mdgriffith/elm-markup": "3.0.1 <= v < 4.0.0",
"mgold/elm-nonempty-list": "4.0.2 <= v < 5.0.0",
"miniBill/elm-codec": "1.2.0 <= v < 2.0.0",
"noahzgordon/elm-color-extra": "1.0.2 <= v < 2.0.0",

View File

@ -4,7 +4,7 @@
"author": "Dillon Kearns",
"title": "Extensible Markdown Parsing in Pure Elm",
"description": "Introducing a new parser that extends your palette with no additional syntax",
"image": "/images/article-covers/extensible-markdown-parsing.jpg",
"image": "images/article-covers/extensible-markdown-parsing.jpg",
"published": "2019-10-08",
}
---

View File

@ -5,7 +5,7 @@
"author": "Dillon Kearns",
"title": "Generating Files with Pure Elm",
"description": "Introducing a new parser that extends your palette with no additional syntax",
"image": "/images/article-covers/generating-files.jpg",
"image": "images/article-covers/generating-files.jpg",
"published": "2020-01-28",
}
---

View File

@ -4,7 +4,7 @@
"author": "Dillon Kearns",
"title": "Introducing elm-pages 🚀 - a type-centric static site generator",
"description": "Elm is the perfect fit for a static site generator. Learn about some of the features and philosophy behind elm-pages.",
"image": "/images/article-covers/introducing-elm-pages.jpg",
"image": "images/article-covers/introducing-elm-pages.jpg",
"published": "2019-09-24",
}
---

View File

@ -3,8 +3,8 @@
"type": "blog",
"author": "Dillon Kearns",
"title": "A is for API - Introducing Static HTTP Requests",
"description": "",
"image": "/images/article-covers/static-http.jpg",
"description": "The new StaticHttp API lets you fetch data when your site is built. That lets you remove loading spinners, and even access environment variables.",
"image": "images/article-covers/static-http.jpg",
"published": "2019-12-10",
}
---

View File

@ -4,7 +4,7 @@
"author": "Dillon Kearns",
"title": "Types Over Conventions",
"description": "How elm-pages approaches configuration, using type-safe Elm.",
"image": "/images/article-covers/introducing-elm-pages.jpg",
"image": "images/article-covers/introducing-elm-pages.jpg",
"draft": true,
"published": "2019-09-21",
}

View File

@ -0,0 +1,7 @@
---
title: Core Concepts
type: doc
---
## StaticHttp
Gives you a way to pull in data during the build step. This data changes every time you run a build. You won't see a loading spinner or error with this data in your built production site. You might get a build error that you can fix.

View File

@ -31,7 +31,6 @@
"elm-explorations/markdown": "1.0.0",
"justinmimbs/date": "3.2.0",
"lukewestby/elm-string-interpolate": "1.0.4",
"mdgriffith/elm-markup": "3.0.1",
"mdgriffith/elm-ui": "1.1.5",
"miniBill/elm-codec": "1.2.0",
"noahzgordon/elm-color-extra": "1.0.2",

View File

@ -260,6 +260,7 @@
"chokidar": "^2.1.5",
"closure-webpack-plugin": "^2.0.1",
"copy-webpack-plugin": "^5.0.4",
"cross-spawn": "6.0.5",
"css-loader": "^3.2.0",
"elm": "^0.19.1-3",
"elm-hot-webpack-loader": "^1.1.2",
@ -267,18 +268,20 @@
"express": "^4.17.1",
"favicons-webpack-plugin": "^3.0.0",
"file-loader": "^4.2.0",
"find-elm-dependencies": "2.0.2",
"globby": "^10.0.1",
"google-closure-compiler": "^20190909.0.0",
"gray-matter": "^4.0.2",
"html-webpack-plugin": "^4.0.0-beta.11",
"imagemin-mozjpeg": "^8.0.0",
"imagemin-webpack-plugin": "^2.4.2",
"node-elm-compiler": "^5.0.4",
"lodash": "4.17.15",
"node-sass": "^4.12.0",
"prerender-spa-plugin": "^3.4.0",
"sass-loader": "^8.0.0",
"script-ext-html-webpack-plugin": "^2.1.4",
"style-loader": "^1.0.0",
"temp": "^0.9.0",
"webpack": "^4.41.5",
"webpack-dev-middleware": "^3.7.0",
"webpack-hot-middleware": "^2.25.0",

View File

@ -19,10 +19,11 @@ import Html exposing (Html)
import Html.Attributes as Attr
import Index
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Exploration as D
import Json.Encode
import MarkdownRenderer
import Metadata exposing (Metadata)
import MySitemap
import OptimizedDecoder as D
import Pages exposing (images, pages)
import Pages.Directory as Directory exposing (Directory)
import Pages.Document
@ -35,6 +36,7 @@ import Pages.StaticHttp as StaticHttp
import Palette
import Secrets
import Showcase
import StructuredData
manifest : Manifest.Config Pages.PathKey
@ -80,13 +82,16 @@ generateFiles :
, body : String
}
->
List
StaticHttp.Request
(List
(Result String
{ path : List String
, content : String
}
)
)
generateFiles siteMetadata =
StaticHttp.succeed
[ Feed.fileToGenerate { siteTagline = siteTagline, siteUrl = canonicalSiteUrl } siteMetadata |> Ok
, MySitemap.build { siteUrl = canonicalSiteUrl } siteMetadata |> Ok
]
@ -166,7 +171,7 @@ view siteMetadata page =
]
}
|> wrapBody stars page model
, head = head page.frontmatter
, head = head page.path page.frontmatter
}
)
(StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-pages")
@ -183,49 +188,11 @@ view siteMetadata page =
\model viewForPage ->
pageView stars model siteMetadata page viewForPage
|> wrapBody stars page model
, head = head page.frontmatter
, head = head page.path page.frontmatter
}
)
--let
-- viewFn =
-- case page.frontmatter of
-- Metadata.Page metadata ->
-- StaticHttp.map3
-- (\elmPagesStars elmPagesStarterStars netlifyStars ->
-- { view =
-- \model viewForPage ->
-- { title = metadata.title
-- , body =
-- "elm-pages ⭐️'s: "
-- ++ String.fromInt elmPagesStars
-- ++ "\n\nelm-pages-starter ⭐️'s: "
-- ++ String.fromInt elmPagesStarterStars
-- ++ "\n\nelm-markdown ⭐️'s: "
-- ++ String.fromInt netlifyStars
-- |> Element.text
-- |> wrapBody
-- }
-- , head = head page.frontmatter
-- }
-- )
-- (StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-pages")
-- (D.field "stargazers_count" D.int)
-- )
-- (StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-pages-starter")
-- (D.field "stargazers_count" D.int)
-- )
-- (StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-markdown")
-- (D.field "stargazers_count" D.int)
-- )
--
-- _ ->
-- StaticHttp.withData "https://api.github.com/repos/dillonkearns/elm-pages"
-- (Decode.field "stargazers_count" Decode.int)
pageView :
Int
-> Model
@ -506,8 +473,8 @@ commonHeadTags =
<https://html.spec.whatwg.org/multipage/semantics.html#standard-metadata-names>
<https://ogp.me/>
-}
head : Metadata -> List (Head.Tag Pages.PathKey)
head metadata =
head : PagePath Pages.PathKey -> Metadata -> List (Head.Tag Pages.PathKey)
head currentPath metadata =
commonHeadTags
++ (case metadata of
Metadata.Page meta ->
@ -543,7 +510,25 @@ head metadata =
|> Seo.website
Metadata.Article meta ->
Seo.summaryLarge
Head.structuredData
(StructuredData.article
{ title = meta.title
, description = meta.description
, author = StructuredData.person { name = meta.author.name }
, publisher = StructuredData.person { name = "Dillon Kearns" }
, url = canonicalSiteUrl ++ "/" ++ PagePath.toString currentPath
, imageUrl = canonicalSiteUrl ++ "/" ++ ImagePath.toString meta.image
, datePublished = Date.toIsoString meta.published
, mainEntityOfPage =
StructuredData.softwareSourceCode
{ codeRepositoryUrl = "https://github.com/dillonkearns/elm-pages"
, description = "A statically typed site generator for Elm."
, author = "Dillon Kearns"
, programmingLanguage = StructuredData.elmLang
}
}
)
:: (Seo.summaryLarge
{ canonicalUrlOverride = Nothing
, siteName = "elm-pages"
, image =
@ -563,6 +548,7 @@ head metadata =
, modifiedTime = Nothing
, expirationTime = Nothing
}
)
Metadata.Author meta ->
let

View File

@ -91,7 +91,7 @@ decoder =
|> Decode.map Article
_ ->
Decode.fail <| "Unexpected page type " ++ pageType
Decode.fail <| "Unexpected page \"type\" " ++ pageType
)
@ -111,9 +111,6 @@ imageDecoder =
findMatchingImage : String -> Maybe (ImagePath Pages.PathKey)
findMatchingImage imageAssetPath =
List.Extra.find
(\image -> ImagePath.toString image == imageAssetPath)
Pages.allImages
|> List.Extra.find
(\image ->
ImagePath.toString image
== imageAssetPath
)

View File

@ -4,7 +4,7 @@ import Element
import Element.Border
import Element.Font
import FontAwesome
import Json.Decode.Exploration as Decode
import OptimizedDecoder as Decode
import Pages.Secrets as Secrets
import Pages.StaticHttp as StaticHttp
import Palette

View File

@ -0,0 +1,3 @@
---
title: "Hello!"
---

View File

@ -27,7 +27,6 @@
"elm-explorations/markdown": "1.0.0",
"justinmimbs/date": "3.1.2",
"lukewestby/elm-string-interpolate": "1.0.4",
"mdgriffith/elm-markup": "3.0.1",
"mdgriffith/elm-ui": "1.1.5",
"mgold/elm-nonempty-list": "4.0.2",
"miniBill/elm-codec": "1.2.0",

View File

@ -2,7 +2,7 @@ import "elm-oembed";
import "./style.css";
// @ts-ignore
const { Elm } = require("./src/Main.elm");
const pagesInit = require("elm-pages");
const pagesInit = require("../../index.js");
pagesInit({
mainElmModule: Elm.Main

File diff suppressed because it is too large Load Diff

View File

@ -190,7 +190,7 @@ view siteMetadata page =
\model viewForPage ->
{ title = "Landing Page"
, body =
[ header starCount
[ header page starCount
, Element.text "Built at: "
, Element.text <| String.fromInt <| Time.posixToMillis Pages.builtAt
, pokemon
@ -239,8 +239,8 @@ articleImageView articleImage =
}
header : Int -> Element msg
header starCount =
header : { path : PagePath Pages.PathKey, frontmatter : Metadata } -> Int -> Element msg
header currentPage starCount =
Element.column [ Element.width Element.fill ]
[ Element.el
[ Element.height (Element.px 4)
@ -263,7 +263,12 @@ header starCount =
, Element.Border.color (Element.rgba255 40 80 40 0.4)
]
[ Element.link []
{ url = "/"
{ url =
if currentPage.path == pages.index then
PagePath.toString pages.otherPage
else
PagePath.toString pages.index
, label =
Element.row
[ Font.size 30

View File

@ -36,11 +36,10 @@ prerenderRcFormattedPath : String -> String
prerenderRcFormattedPath path =
path
|> dropExtension
|> chopForwardSlashes
|> String.split "/"
|> dropIndexFromLast
|> List.drop 1
|> String.join "/"
|> (\pathSoFar -> "/" ++ pathSoFar)
dropIndexFromLast : List String -> List String
@ -50,7 +49,7 @@ dropIndexFromLast path =
|> (\reversePath ->
case List.head reversePath of
Just "index" ->
reversePath |> List.drop 1
List.drop 1 reversePath
_ ->
reversePath
@ -58,12 +57,17 @@ dropIndexFromLast path =
|> List.reverse
chopForwardSlashes : String -> String
chopForwardSlashes =
String.split "/" >> List.filter ((/=) "") >> String.join "/"
pathFor : { entry | path : String } -> String
pathFor page =
page.path
|> dropExtension
|> chopForwardSlashes
|> String.split "/"
|> List.drop 1
|> dropIndexFromLast
|> List.map (\pathPart -> String.concat [ "\"", pathPart, "\"" ])
|> String.join ", "
@ -72,11 +76,11 @@ pathFor page =
dropExtension : String -> String
dropExtension path =
if path |> String.endsWith ".emu" then
path |> String.dropRight 4
if String.endsWith ".emu" path then
String.dropRight 4 path
else if path |> String.endsWith ".md" then
path |> String.dropRight 3
else if String.endsWith ".md" path then
String.dropRight 3 path
else
path
@ -91,20 +95,13 @@ import Dict exposing (Dict)
content : { markdown : List ( List String, { frontMatter : String, body : Maybe String } ), markup : List ( List String, String ) }
content =
{ markdown = markdown, markup = markup }
{ markdown = markdown }
markdown : List ( List String, { frontMatter : String, body : Maybe String } )
markdown =
[ {1}
]
markup : List ( List String, String )
markup =
[
{0}
]
"""
[ List.map generatePage content |> String.join "\n ,"
, List.map generateMarkdownPage markdownContent |> String.join "\n ,"
@ -219,11 +216,10 @@ init flags cliOptions =
generateFileContents : List MarkdownContent -> List ( String, String )
generateFileContents markdownFiles =
markdownFiles
|> List.map
generateFileContents =
List.map
(\file ->
( prerenderRcFormattedPath file.path |> String.dropLeft 1
( prerenderRcFormattedPath file.path
, file.body
)
)

View File

@ -2,6 +2,7 @@ const path = require("path");
const fs = require("fs");
const globby = require("globby");
const parseFrontmatter = require("./frontmatter.js");
const webpack = require('webpack')
function unpackFile(filePath) {
const { content, data } = parseFrontmatter(
@ -15,34 +16,50 @@ function unpackFile(filePath) {
return {
baseRoute,
content
content,
filePath
};
}
module.exports = class AddFilesPlugin {
constructor(data, filesToGenerate) {
this.pagesWithRequests = data;
this.filesToGenerate = filesToGenerate;
apply(/** @type {webpack.Compiler} */ compiler) {
(global.mode === "dev" ? compiler.hooks.emit : compiler.hooks.make).tapAsync("AddFilesPlugin", (compilation, callback) => {
const files = globby.sync("content").map(unpackFile);
let staticRequestData = {}
global.pagesWithRequests.then(payload => {
if (payload.type === 'error') {
compilation.errors.push(new Error(payload.message))
} else if (payload.errors && payload.errors.length > 0) {
compilation.errors.push(new Error(payload.errors[0]))
}
apply(compiler) {
compiler.hooks.emit.tap("AddFilesPlugin", compilation => {
const files = globby
.sync(["content/**/*.*", "!content/**/*.emu"], {})
.map(unpackFile);
else {
staticRequestData = payload.pages
}
})
.finally(() => {
files.forEach(file => {
// Couldn't find this documented in the webpack docs,
// but I found the example code for it here:
// https://github.com/jantimon/html-webpack-plugin/blob/35a154186501fba3ecddb819b6f632556d37a58f/index.js#L470-L478
let route = `/${file.baseRoute}`.replace(/\/$/, '');
if (route === '') {
route = '/';
}
const staticRequests = this.pagesWithRequests[route];
let route = file.baseRoute.replace(/\/$/, '');
const staticRequests = staticRequestData[route];
const filename = path.join(file.baseRoute, "content.json");
compilation.fileDependencies.add(filename);
if (compilation.contextDependencies) {
compilation.contextDependencies.add('content')
}
// compilation.fileDependencies.add(filename);
if (compilation.fileDependencies) {
compilation.fileDependencies.add(path.resolve(file.filePath));
}
const rawContents = JSON.stringify({
body: file.content,
staticData: staticRequests || {}
@ -54,7 +71,7 @@ module.exports = class AddFilesPlugin {
};
});
(this.filesToGenerate || []).forEach(file => {
(global.filesToGenerate || []).forEach(file => {
// Couldn't find this documented in the webpack docs,
// but I found the example code for it here:
// https://github.com/jantimon/html-webpack-plugin/blob/35a154186501fba3ecddb819b6f632556d37a58f/index.js#L470-L478
@ -64,6 +81,9 @@ module.exports = class AddFilesPlugin {
};
});
callback()
})
});
}

View File

@ -1,33 +1,36 @@
const { compileToString } = require("../node-elm-compiler/index.js");
const { compileToStringSync } = require("../node-elm-compiler/index.js");
XMLHttpRequest = require("xhr2");
module.exports = runElm;
function runElm(/** @type string */ mode, /** @type any */ callback) {
function runElm(/** @type string */ mode) {
return new Promise((resolve, reject) => {
const elmBaseDirectory = "./elm-stuff/elm-pages";
const mainElmFile = "../../src/Main.elm";
const startingDir = process.cwd();
process.chdir(elmBaseDirectory);
compileToString([mainElmFile], {}).then(function(data) {
(function() {
const data = compileToStringSync([mainElmFile], {});
process.chdir(startingDir);
(function () {
const warnOriginal = console.warn;
console.warn = function() {};
console.warn = function () { };
eval(data.toString());
const app = Elm.Main.init({
flags: { secrets: process.env, mode }
flags: { secrets: process.env, mode, staticHttpCache: global.staticHttpCache }
});
app.ports.toJsPort.subscribe(payload => {
process.chdir(startingDir);
if (payload.tag === "Success") {
callback(payload.args[0]);
global.staticHttpCache = payload.args[0].staticHttpCache;
resolve(payload.args[0])
} else {
console.log(payload.args[0]);
process.exit(1);
reject(payload.args[0])
}
delete Elm;
console.warn = warnOriginal;
});
})();
});
}

View File

@ -1,5 +1,4 @@
const webpack = require("webpack");
const middleware = require("webpack-dev-middleware");
const path = require("path");
const HTMLWebpackPlugin = require("html-webpack-plugin");
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin');
@ -14,14 +13,27 @@ const imageminMozjpeg = require("imagemin-mozjpeg");
const express = require("express");
const TerserPlugin = require('terser-webpack-plugin');
const readline = require("readline");
const webpackDevMiddleware = require("webpack-dev-middleware");
const PluginGenerateElmPagesBuild = require('./plugin-generate-elm-pages-build')
const hotReloadIndicatorStyle = `
<style>
@keyframes lds-default {
0%, 20%, 80%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.5);
}
}
</style>
`
module.exports = { start, run };
function start({ routes, debug, customPort, manifestConfig, routesWithRequests, filesToGenerate }) {
function start({ routes, debug, customPort, manifestConfig }) {
const config = webpackOptions(false, routes, {
debug,
manifestConfig,
routesWithRequests,
filesToGenerate
manifestConfig
});
const compiler = webpack(config);
@ -31,8 +43,7 @@ function start({ routes, debug, customPort, manifestConfig, routesWithRequests,
hot: true,
inline: true,
host: "localhost",
stats: "errors-only",
publicPath: "/"
stats: "errors-only"
};
const app = express();
@ -40,21 +51,33 @@ function start({ routes, debug, customPort, manifestConfig, routesWithRequests,
app.use('/images', express.static(path.resolve(process.cwd(), "./images")));
app.use(require("webpack-dev-middleware")(compiler, options));
app.use(webpackDevMiddleware(compiler, options));
app.use(require("webpack-hot-middleware")(compiler, {
log: console.log, path: '/__webpack_hmr'
}))
app.get('/elm-pages-dev-server-options', function (req, res) {
res.json({ elmDebugger: debug });
});
app.use("*", function (req, res, next) {
// don't know why this works, but it does
// see: https://github.com/jantimon/html-webpack-plugin/issues/145#issuecomment-170554832
const filename = path.join(compiler.outputPath, "index.html");
const route = req.originalUrl.replace(/(\w)\/$/, "$1").replace(/^\//, "");
const isPage = routes.includes(route);
compiler.outputFileSystem.readFile(filename, function (err, result) {
if (err) {
return next(err);
}
const contents = isPage
? replaceBaseAndLinks(result.toString(), route)
: result
res.set("content-type", "text/html");
res.send(result);
res.send(contents);
res.end();
});
});
@ -67,20 +90,18 @@ function start({ routes, debug, customPort, manifestConfig, routesWithRequests,
// app.use(express.static(__dirname + "/path-to-static-folder"));
}
function run({ routes, manifestConfig, routesWithRequests, filesToGenerate }, callback) {
function run({ routes, manifestConfig }) {
webpack(
webpackOptions(true, routes, {
debug: false,
manifestConfig,
routesWithRequests,
filesToGenerate
})
).run((err, stats) => {
if (err) {
console.error(err);
process.exit(1);
} else {
callback();
// done
}
console.log(
@ -121,12 +142,13 @@ function printProgress(progress, message) {
function webpackOptions(
production,
routes,
{ debug, manifestConfig, routesWithRequests, filesToGenerate }
{ debug, manifestConfig }
) {
const common = {
mode: production ? "production" : "development",
plugins: [
new AddFilesPlugin(routesWithRequests, filesToGenerate),
new PluginGenerateElmPagesBuild(),
new AddFilesPlugin(),
new CopyPlugin([
{
from: "static/**/*",
@ -160,16 +182,48 @@ function webpackOptions(
new HTMLWebpackPlugin({
inject: "head",
template: path.resolve(__dirname, "template.html")
templateContent: `<!DOCTYPE html>
<html lang="en">
<head>
<link rel="preload" href="content.json" as="fetch" crossorigin />
<base href="/" />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("service-worker.js");
});
} else {
console.log("No service worker registered.");
}
</script>
${production ? '' : hotReloadIndicatorStyle}
</head>
<body></body>
</html>`
}),
new ScriptExtHtmlWebpackPlugin({
preload: /\.js$/,
defaultAttribute: 'defer'
}),
new FaviconsWebpackPlugin({
logo: path.resolve(process.cwd(), `./${manifestConfig.sourceIcon}`),
logo: `./${manifestConfig.sourceIcon}`,
prefix: "assets/",
publicPath: "",
outputPath: "",
favicons: {
path: "/", // Path for overriding default icons path. `string`
manifestRelativePaths: true,
path: "", // Path for overriding default icons path. `string`
appName: manifestConfig.name, // Your application's name. `string`
appShortName: manifestConfig.short_name, // Your application's short_name. `string`. Optional. If not set, appName will be used
appDescription: manifestConfig.description, // Your application's description. `string`
@ -221,14 +275,12 @@ function webpackOptions(
/assets\//
],
swDest: "service-worker.js"
})
}),
// comment this out to do performance profiling
// (drag-and-drop `events.json` file into Chrome performance tab)
// , new webpack.debug.ProfilingPlugin()
// new webpack.debug.ProfilingPlugin()
],
output: {
publicPath: "/"
},
output: {},
resolve: {
modules: [
path.resolve(process.cwd(), `./node_modules`),
@ -315,10 +367,22 @@ function webpackOptions(
}),
new PrerenderSPAPlugin({
staticDir: path.join(process.cwd(), "dist"),
routes: routes,
routes: routes.map(r => `/${r}`),
renderer: new PrerenderSPAPlugin.PuppeteerRenderer({
renderAfterDocumentEvent: "prerender-trigger",
})
headless: true,
devtools: false,
}),
postProcess: renderedRoute => {
renderedRoute.html = replaceBaseAndLinks(
renderedRoute.html,
renderedRoute.route
)
return renderedRoute
}
})
],
module: {
@ -339,14 +403,14 @@ function webpackOptions(
} else {
return merge(common, {
entry: [
require.resolve("webpack-hot-middleware/client"),
hmrClientPath(),
"./index.js",
],
plugins: [
new webpack.NamedModulesPlugin(),
new webpack.HotModuleReplacementPlugin(),
// Prevents compilation errors causing the hot loader to lose state
new webpack.NoEmitOnErrorsPlugin(),
new webpack.HotModuleReplacementPlugin()
],
module: {
rules: [
@ -369,3 +433,63 @@ function webpackOptions(
});
}
}
function hmrClientPath() {
var ansiColors = {
reset: ['ffffff', 'transparent'], // [FOREGROUD_COLOR, BACKGROUND_COLOR]
black: '000',
red: 'c91b00',
green: '00c200',
yellow: 'c7c400',
blue: '0225c7',
magenta: 'c930c7',
cyan: '00c5c7',
lightgrey: 'f0f0f0',
darkgrey: '888'
};
var overlayStyles = {
// options from https://github.com/webpack-contrib/webpack-hot-middleware/blob/master/client-overlay.js
background: 'rgba(0,0,0,0.90)',
color: '#e8e8e8',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
fontFamily: 'Menlo, Consolas, monospace',
fontSize: '16px',
// position: 'fixed',
// zIndex: 9999,
// padding: '10px',
// left: 0,
// right: 0,
// top: 0,
// bottom: 0,
// overflow: 'auto',
// dir: 'ltr',
// textAlign: 'left',
};
return `${require.resolve("webpack-hot-middleware/client")}?ansiColors=${encodeURIComponent(JSON.stringify(ansiColors))}&overlayStyles=${encodeURIComponent(JSON.stringify(overlayStyles))}`;
}
function cleanRoute(route) {
return route.replace(/(^\/|\/$)/, "")
}
function pathToRoot(cleanedRoute) {
return cleanedRoute === ""
? cleanedRoute
: cleanedRoute
.split("/")
.map(_ => "..")
.join("/")
.replace(/\.$/, "./")
}
function replaceBaseAndLinks(html, route) {
const cleanedRoute = cleanRoute(route)
const href = cleanedRoute === '' ? './' : pathToRoot(cleanedRoute)
return (html || "").replace(`<base href="/"`, `<base href="${href}"`)
}

View File

@ -12,6 +12,7 @@ ${staticRoutes.routeRecord}
${staticRoutes.imageAssetsRecord}
allImages : List (ImagePath PathKey)
allImages =
[${staticRoutes.allImages.join("\n , ")}
@ -40,7 +41,7 @@ isValidRoute route =
`;
}
function elmPagesUiFile(staticRoutes, markdownContent, markupContent) {
function elmPagesUiFile(staticRoutes, markdownContent) {
return `port module Pages exposing ${exposingList}
import Color exposing (Color)
@ -49,7 +50,6 @@ import Head
import Html exposing (Html)
import Json.Decode
import Json.Encode
import Mark
import Pages.Platform
import Pages.Manifest exposing (DisplayMode, Orientation)
import Pages.Manifest.Category as Category exposing (Category)
@ -65,6 +65,7 @@ builtAt : Time.Posix
builtAt =
Time.millisToPosix ${Math.round((global.builtAt).getTime())}
type PathKey
= PathKey
@ -74,7 +75,6 @@ buildImage path =
ImagePath.build PathKey ("images" :: path)
buildPage : List String -> PagePath PathKey
buildPage path =
PagePath.build PathKey path
@ -92,22 +92,25 @@ directoryWithoutIndex path =
port toJsPort : Json.Encode.Value -> Cmd msg
port fromJsPort : (Json.Decode.Value -> msg) -> Sub msg
internals : Pages.Internal.Internal PathKey
internals =
{ applicationType = Pages.Internal.Browser
, toJsPort = toJsPort
, fromJsPort = fromJsPort identity
, content = content
, pathKey = PathKey
}
${staticRouteStuff(staticRoutes)}
${generateRawContent(markdownContent, markupContent, false)}
${generateRawContent(markdownContent, false)}
`;
}
function elmPagesCliFile(staticRoutes, markdownContent, markupContent) {
function elmPagesCliFile(staticRoutes, markdownContent) {
return `port module Pages exposing ${exposingList}
import Color exposing (Color)
@ -116,7 +119,6 @@ import Head
import Html exposing (Html)
import Json.Decode
import Json.Encode
import Mark
import Pages.Platform
import Pages.Manifest exposing (DisplayMode, Orientation)
import Pages.Manifest.Category as Category exposing (Category)
@ -132,6 +134,7 @@ builtAt : Time.Posix
builtAt =
Time.millisToPosix ${Math.round((global.builtAt).getTime())}
type PathKey
= PathKey
@ -159,10 +162,14 @@ directoryWithoutIndex path =
port toJsPort : Json.Encode.Value -> Cmd msg
port fromJsPort : (Json.Decode.Value -> msg) -> Sub msg
internals : Pages.Internal.Internal PathKey
internals =
{ applicationType = Pages.Internal.Cli
, toJsPort = toJsPort
, fromJsPort = fromJsPort identity
, content = content
, pathKey = PathKey
}
@ -170,7 +177,7 @@ internals =
${staticRouteStuff(staticRoutes)}
${generateRawContent(markdownContent, markupContent, true)}
${generateRawContent(markdownContent, true)}
`;
}
module.exports = { elmPagesUiFile, elmPagesCliFile };

View File

@ -5,66 +5,38 @@ const { version } = require("../../package.json");
const fs = require("fs");
const globby = require("globby");
const develop = require("./develop.js");
const chokidar = require("chokidar");
const doCliStuff = require("./generate-elm-stuff.js");
const { elmPagesUiFile } = require("./elm-file-constants.js");
const generateRecords = require("./generate-records.js");
const parseFrontmatter = require("./frontmatter.js");
const path = require("path");
const { ensureDirSync, deleteIfExists } = require('./file-helpers.js')
const generateRecords = require("./generate-records.js");
const doCliStuff = require("./generate-elm-stuff.js");
global.builtAt = new Date();
const contentGlobPath = "content/**/*.emu";
let watcher = null;
let devServerRunning = false;
global.staticHttpCache = {};
function unpackFile(path) {
return { path, contents: fs.readFileSync(path).toString() };
}
function unpackMarkup(path) {
const separated = parseFrontmatter(path, fs.readFileSync(path).toString());
return {
path,
metadata: separated.matter,
body: separated.content,
extension: "emu"
};
}
function parseMarkdown(path, fileContents) {
const { content, data } = parseFrontmatter(path, fileContents);
return {
path,
metadata: JSON.stringify(data),
body: content,
extension: "md"
body: content
};
}
function run() {
console.log("Running elm-pages...");
const content = globby.sync([contentGlobPath], {}).map(unpackMarkup);
const staticRoutes = generateRecords();
const markdownContent = globby
.sync(["content/**/*.*", "!content/**/*.emu"], {})
.sync(["content/**/*.*"], {})
.map(unpackFile)
.map(({ path, contents }) => {
return parseMarkdown(path, contents);
});
const images = globby
.sync("images/**/*", {})
.filter(imagePath => !fs.lstatSync(imagePath).isDirectory());
let app = Elm.Main.init({
flags: {
argv: process.argv,
versionMessage: version,
content,
markdownContent,
images
}
});
@ -78,89 +50,51 @@ function run() {
process.exit(1);
});
app.ports.writeFile.subscribe(contents => {
const routes = toRoutes(markdownContent.concat(content));
app.ports.writeFile.subscribe(cliOptions => {
const markdownContent = globby
.sync(["content/**/*.*"], {})
.map(unpackFile)
.map(({ path, contents }) => {
return parseMarkdown(path, contents);
});
const routes = toRoutes(markdownContent);
global.mode = cliOptions.watch ? "dev" : "prod"
const staticRoutes = generateRecords();
doCliStuff(
contents.watch ? "dev" : "prod",
global.mode,
staticRoutes,
markdownContent,
content,
function(payload) {
if (contents.watch) {
startWatchIfNeeded();
if (!devServerRunning) {
devServerRunning = true;
markdownContent
).then((payload) => {
if (cliOptions.watch) {
develop.start({
routes,
debug: contents.debug,
debug: cliOptions.debug,
customPort: cliOptions.customPort,
manifestConfig: payload.manifest,
});
} else {
develop.run({
routes,
debug: cliOptions.debug,
customPort: cliOptions.customPort,
manifestConfig: payload.manifest,
routesWithRequests: payload.pages,
filesToGenerate: payload.filesToGenerate,
customPort: contents.customPort
});
}
} else {
if (payload.errors && payload.errors.length > 0) {
printErrorsAndExit(payload.errors);
}
develop.run(
{
routes,
manifestConfig: payload.manifest,
routesWithRequests: payload.pages,
filesToGenerate: payload.filesToGenerate
},
() => {}
);
}
})
ensureDirSync("./gen");
// prevent compilation errors if migrating from previous elm-pages version
deleteIfExists("./gen/Pages/ContentCache.elm");
deleteIfExists("./gen/Pages/Platform.elm");
fs.writeFileSync(
"./gen/Pages.elm",
elmPagesUiFile(staticRoutes, markdownContent, content)
);
console.log("elm-pages DONE");
}
);
});
}
run();
function printErrorsAndExit(errors) {
console.error(
"Found errors. Exiting. Fix your content or parsers and re-run, or run in dev mode with `elm-pages develop`."
);
console.error(errors.join("\n\n"));
process.exit(1);
}
function startWatchIfNeeded() {
if (!watcher) {
console.log("Watching...");
watcher = chokidar
.watch(["content/**/*.*"], {
awaitWriteFinish: {
stabilityThreshold: 500
},
ignoreInitial: true
})
.on("all", function(event, filePath) {
console.log(`Rerunning for ${filePath}...`);
run();
console.log("Done!");
});
}
}
function toRoutes(entries) {
return entries.map(toRoute);
}
@ -169,7 +103,8 @@ function toRoute(entry) {
let fullPath = entry.path
.replace(/(index)?\.[^/.]+$/, "")
.split("/")
.filter(item => item !== "");
fullPath.splice(0, 1);
return `/${fullPath.join("/")}`;
.filter(item => item !== "")
.slice(1);
return fullPath.join("/");
}

View File

@ -1,23 +1,5 @@
const path = require("path");
const matter = require("gray-matter");
module.exports = function parseFrontmatter(filePath, fileContents) {
return path.extname(filePath) === ".emu"
? matter(fileContents, markupFrontmatterOptions)
: matter(fileContents);
};
const markupFrontmatterOptions = {
language: "markup",
engines: {
markup: {
parse: function(string) {
return string;
},
stringify: function(string) {
return string;
}
}
}
return matter(fileContents);
};

View File

@ -1,19 +1,19 @@
const fs = require("fs");
const runElm = require("./compile-elm.js");
const copyModifiedElmJson = require("./rewrite-elm-json.js");
const { elmPagesCliFile } = require("./elm-file-constants.js");
const { elmPagesCliFile, elmPagesUiFile } = require("./elm-file-constants.js");
const path = require("path");
const { ensureDirSync, deleteIfExists } = require('./file-helpers.js')
let wasEqualBefore = false
module.exports = function run(
mode,
staticRoutes,
markdownContent,
markupContent,
callback
markdownContent
) {
ensureDirSync("./elm-stuff");
ensureDirSync("./gen");
ensureDirSync("./elm-stuff/elm-pages");
// prevent compilation errors if migrating from previous elm-pages version
@ -21,15 +21,37 @@ module.exports = function run(
deleteIfExists("./elm-stuff/elm-pages/Pages/Platform.elm");
const uiFileContent = elmPagesUiFile(staticRoutes, markdownContent)
// TODO should just write it once, but webpack doesn't seem to pick up the changes
// so this wasEqualBefore code causes it to get written twice to make sure the changes come through for HMR
if (wasEqualBefore) {
fs.writeFileSync(
"./gen/Pages.elm",
uiFileContent
);
}
if (global.previousUiFileContent === uiFileContent) {
wasEqualBefore = false
} else {
wasEqualBefore = true
fs.writeFileSync(
"./gen/Pages.elm",
uiFileContent
);
}
global.previousUiFileContent = uiFileContent
// write `Pages.elm` with cli interface
fs.writeFileSync(
"./elm-stuff/elm-pages/Pages.elm",
elmPagesCliFile(staticRoutes, markdownContent, markupContent)
elmPagesCliFile(staticRoutes, markdownContent)
);
// write modified elm.json to elm-stuff/elm-pages/
copyModifiedElmJson();
// run Main.elm from elm-stuff/elm-pages with `runElm`
runElm(mode, callback);
return runElm(mode);
};

View File

@ -1,9 +1,9 @@
const path = require("path");
module.exports = function(markdown, markup, includeBody) {
module.exports = function (markdown, includeBody) {
return `content : List ( List String, { extension: String, frontMatter : String, body : Maybe String } )
content =
[ ${markdown.concat(markup).map(entry => toEntry(entry, includeBody))}
[ ${markdown.map(entry => toEntry(entry, includeBody))}
]`;
};

View File

@ -1,5 +1,4 @@
const path = require("path");
const matter = require("gray-matter");
const dir = "content/";
const glob = require("glob");
const fs = require("fs");
@ -138,14 +137,14 @@ function allImageAssetNames() {
});
}
function toPascalCase(str) {
var pascal = str.replace(/(\-\w)/g, function(m) {
var pascal = str.replace(/(\-\w)/g, function (m) {
return m[1].toUpperCase();
});
return pascal.charAt(0).toUpperCase() + pascal.slice(1);
}
function toCamelCase(str) {
var pascal = str.replace(/(\-\w)/g, function(m) {
var pascal = str.replace(/(\-\w)/g, function (m) {
return m[1].toUpperCase();
});
return pascal.charAt(0).toLowerCase() + pascal.slice(1);

View File

@ -0,0 +1,62 @@
const fs = require('fs')
const path = require('path')
const doCliStuff = require("./generate-elm-stuff.js");
const webpack = require('webpack')
const parseFrontmatter = require("./frontmatter.js");
const generateRecords = require("./generate-records.js");
const globby = require("globby");
module.exports = class PluginGenerateElmPagesBuild {
constructor() {
}
apply(/** @type {webpack.Compiler} */ compiler) {
compiler.hooks.beforeCompile.tap('PluginGenerateElmPagesBuild', (compilation) => {
const staticRoutes = generateRecords();
const markdownContent = globby
.sync(["content/**/*.*"], {})
.map(unpackFile)
.map(({ path, contents }) => {
return parseMarkdown(path, contents);
});
let resolvePageRequests;
let rejectPageRequests;
global.pagesWithRequests = new Promise(function (resolve, reject) {
resolvePageRequests = resolve;
rejectPageRequests = reject;
});
doCliStuff(
global.mode,
staticRoutes,
markdownContent
).then((payload) => {
// console.log('PROMISE RESOLVED doCliStuff');
resolvePageRequests(payload);
global.filesToGenerate = payload.filesToGenerate;
}).catch(function (errorPayload) {
resolvePageRequests({ type: 'error', message: errorPayload });
})
});
};
}
function unpackFile(path) {
return { path, contents: fs.readFileSync(path).toString() };
}
function parseMarkdown(path, fileContents) {
const { content, data } = parseFrontmatter(path, fileContents);
return {
path,
metadata: JSON.stringify(data),
body: content,
};
}

View File

@ -1,19 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="preload" href="./content.json" as="fetch" crossorigin />
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/service-worker.js");
});
} else {
console.log("No service worker registered.");
}
</script>
</head>
<body></body>
</html>

110
index.js
View File

@ -11,14 +11,17 @@ module.exports = function pagesInit(
prefetchedPages = [window.location.pathname];
initialLocationHash = document.location.hash.replace(/^#/, "");
return new Promise(function(resolve, reject) {
return new Promise(function (resolve, reject) {
document.addEventListener("DOMContentLoaded", _ => {
new MutationObserver(function() {
new MutationObserver(function () {
elmViewRendered = true;
if (headTagsAdded) {
document.dispatchEvent(new Event("prerender-trigger"));
}
}).observe(document.body, { attributes: true, childList: true, subtree: true});
}).observe(
document.body,
{ attributes: true, childList: true, subtree: true }
);
loadContentAndInitializeApp(mainElmModule).then(resolve, reject);
});
@ -26,32 +29,50 @@ module.exports = function pagesInit(
};
function loadContentAndInitializeApp(/** @type { init: any } */ mainElmModule) {
return httpGet(`${window.location.origin}${window.location.pathname}/content.json`).then(function(/** @type JSON */ contentJson) {
const isPrerendering = navigator.userAgent.indexOf("Headless") >= 0
const path = window.location.pathname.replace(/(\w)$/, "$1/")
return Promise.all([
getConfig(),
httpGet(`${window.location.origin}${path}content.json`)]).then(function (/** @type {[DevServerConfig?, JSON]} */[devServerConfig, contentJson]) {
console.log('devServerConfig', devServerConfig);
const app = mainElmModule.init({
flags: {
secrets: null,
isPrerendering: navigator.userAgent.indexOf("Headless") >= 0,
contentJson
baseUrl: isPrerendering
? window.location.origin
: document.baseURI,
isPrerendering: isPrerendering,
isDevServer: !!module.hot,
isElmDebugMode: devServerConfig ? devServerConfig.elmDebugger : false,
contentJson,
}
});
app.ports.toJsPort.subscribe((
/** @type { { head: HeadTag[], allRoutes: string[] } } */ fromElm
/** @type { { head: SeoTag[], allRoutes: string[] } } */ fromElm
) => {
appendTag({
type: 'head',
name: "meta",
attributes: [
["name", "generator"],
["content", `elm-pages v${elmPagesVersion}`]
]
});
window.allRoutes = fromElm.allRoutes;
window.allRoutes = fromElm.allRoutes.map(route => new URL(route, document.baseURI).href);
if (navigator.userAgent.indexOf("Headless") >= 0) {
fromElm.head.forEach(headTag => {
if (headTag.type === 'head') {
appendTag(headTag);
} else if (headTag.type === 'json-ld') {
appendJsonLdTag(headTag);
} else {
throw new Error(`Unknown tag type #{headTag}`)
}
});
headTagsAdded = true;
if (elmViewRendered) {
@ -62,8 +83,46 @@ function loadContentAndInitializeApp(/** @type { init: any } */ mainElmModule)
}
});
return app
if (module.hot) {
// found this trick in the next.js source code
// https://github.com/zeit/next.js/blob/886037b1bac4bdbfeb689b032c1612750fb593f7/packages/next/client/dev/error-overlay/eventsource.js
// https://github.com/zeit/next.js/blob/886037b1bac4bdbfeb689b032c1612750fb593f7/packages/next/client/dev/dev-build-watcher.js
// more details about this API at https://www.html5rocks.com/en/tutorials/eventsource/basics/
let source = new window.EventSource('/__webpack_hmr')
// source.addEventListener('open', () => { console.log('open!!!!!') })
source.addEventListener('message', (e) => {
// console.log('message!!!!!', e)
// console.log(e.data.action)
// console.log('ACTION', e.data.action);
// if (e.data && e.data.action)
if (event.data === '\uD83D\uDC93') {
// heartbeat
} else {
const obj = JSON.parse(event.data)
// console.log('obj.action', obj.action);
if (obj.action === 'building') {
app.ports.fromJsPort.send({ thingy: 'hmr-check' });
} else if (obj.action === 'built') {
// console.log('httpGet start');
let currentPath = window.location.pathname.replace(/(\w)$/, "$1/")
httpGet(`${window.location.origin}${currentPath}content.json`).then(function (/** @type JSON */ contentJson) {
// console.log('httpGet received');
app.ports.fromJsPort.send({ contentJson: contentJson });
});
}
}
})
}
return app
});
}
@ -123,7 +182,7 @@ function setupLinkPrefetchingHelp(
const links = document.querySelectorAll("a");
links.forEach(link => {
// console.log(link.pathname);
link.addEventListener("mouseenter", function(event) {
link.addEventListener("mouseenter", function (event) {
if (
event &&
event.target &&
@ -141,8 +200,8 @@ function prefetchIfNeeded(/** @type {HTMLAnchorElement} */ target) {
if (target.host === window.location.host) {
if (prefetchedPages.includes(target.pathname)) {
// console.log("Already preloaded", target.href);
} else if (!allRoutes.includes(target.pathname)) {
// console.log("Not a known route, skipping preload", target.pathname);
} else if (!allRoutes.includes(new URL(target.pathname, document.baseURI).href)) {
}
else {
prefetchedPages.push(target.pathname);
@ -157,7 +216,9 @@ function prefetchIfNeeded(/** @type {HTMLAnchorElement} */ target) {
}
}
/** @typedef {{ name: string; attributes: string[][]; }} HeadTag */
/** @typedef {HeadTag | JsonLdTag} SeoTag */
/** @typedef {{ name: string; attributes: string[][]; type: 'head' }} HeadTag */
function appendTag(/** @type {HeadTag} */ tagDetails) {
const meta = document.createElement(tagDetails.name);
tagDetails.attributes.forEach(([name, value]) => {
@ -166,10 +227,18 @@ function appendTag(/** @type {HeadTag} */ tagDetails) {
document.getElementsByTagName("head")[0].appendChild(meta);
}
/** @typedef {{ contents: Object; type: 'json-ld' }} JsonLdTag */
function appendJsonLdTag(/** @type {JsonLdTag} */ tagDetails) {
let jsonLdScript = document.createElement('script');
jsonLdScript.type = "application/ld+json";
jsonLdScript.innerHTML = JSON.stringify(tagDetails.contents);
document.getElementsByTagName("head")[0].appendChild(jsonLdScript);
}
function httpGet(/** @type string */ theUrl) {
return new Promise(function(resolve, reject) {
return new Promise(function (resolve, reject) {
const xmlHttp = new XMLHttpRequest();
xmlHttp.onreadystatechange = function() {
xmlHttp.onreadystatechange = function () {
if (xmlHttp.readyState == 4 && xmlHttp.status == 200)
resolve(JSON.parse(xmlHttp.responseText));
}
@ -178,3 +247,16 @@ function httpGet(/** @type string */ theUrl) {
xmlHttp.send(null);
})
}
/**
* @returns { Promise<DevServerConfig?>}
*/
function getConfig() {
if (module.hot) {
return httpGet(`/elm-pages-dev-server-options`)
} else {
return Promise.resolve(null)
}
}
/** @typedef { { elmDebugger : boolean } } DevServerConfig */

5122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "elm-pages",
"version": "1.2.11",
"version": "1.3.0",
"homepage": "http://elm-pages.com",
"description": "Type-safe static sites, written in pure elm with your own custom elm-markup syntax.",
"main": "index.js",
@ -22,7 +22,6 @@
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"babel-loader": "^8.0.6",
"chokidar": "^2.1.5",
"copy-webpack-plugin": "^5.0.4",
"cross-spawn": "6.0.5",
"css-loader": "^3.2.0",
@ -35,18 +34,19 @@
"find-elm-dependencies": "2.0.2",
"globby": "^10.0.1",
"gray-matter": "^4.0.2",
"html-webpack-plugin": "^4.0.0-beta.11",
"html-webpack-plugin": "^4.2.0",
"imagemin-mozjpeg": "^8.0.0",
"imagemin-webpack-plugin": "^2.4.2",
"lodash": "4.17.15",
"node-sass": "^4.12.0",
"prerender-spa-plugin": "^3.4.0",
"raw-loader": "^4.0.0",
"sass-loader": "^8.0.0",
"script-ext-html-webpack-plugin": "^2.1.4",
"style-loader": "^1.0.0",
"temp": "^0.9.0",
"terser-webpack-plugin": "^2.3.5",
"webpack": "^4.41.5",
"webpack": "4.42.1",
"webpack-dev-middleware": "^3.7.0",
"webpack-hot-middleware": "^2.25.0",
"webpack-merge": "^4.2.1",
@ -54,7 +54,6 @@
"xhr2": "^0.2.0"
},
"devDependencies": {
"@types/chokidar": "^2.1.3",
"@types/express": "^4.17.0",
"@types/node": "^12.7.7",
"@types/webpack": "^4.32.1",

View File

@ -1,6 +1,7 @@
module Head exposing
( Tag, metaName, metaProperty
, rssLink, sitemapLink
, structuredData
, AttributeValue
, currentPageFullUrl, fullImageUrl, fullPageUrl, raw
, toJson, canonicalLink
@ -19,6 +20,11 @@ writing a plugin package to extend `elm-pages`.
@docs rssLink, sitemapLink
## Structured Data
@docs structuredData
## `AttributeValue`s
@docs AttributeValue
@ -33,6 +39,7 @@ writing a plugin package to extend `elm-pages`.
import Json.Encode
import Pages.ImagePath as ImagePath exposing (ImagePath)
import Pages.Internal.String as String
import Pages.PagePath as PagePath exposing (PagePath)
@ -41,6 +48,7 @@ through the `head` function.
-}
type Tag pathKey
= Tag (Details pathKey)
| StructuredData Json.Encode.Value
type alias Details pathKey =
@ -49,6 +57,100 @@ type alias Details pathKey =
}
{-| You can learn more about structured data in [Google's intro to structured data](https://developers.google.com/search/docs/guides/intro-structured-data).
When you add a `structuredData` item to one of your pages in `elm-pages`, it will add `json-ld` data to your document that looks like this:
```html
<script type="application/ld+json">
{
"@context":"http://schema.org/",
"@type":"Article",
"headline":"Extensible Markdown Parsing in Pure Elm",
"description":"Introducing a new parser that extends your palette with no additional syntax",
"image":"https://elm-pages.com/images/article-covers/extensible-markdown-parsing.jpg",
"author":{
"@type":"Person",
"name":"Dillon Kearns"
},
"publisher":{
"@type":"Person",
"name":"Dillon Kearns"
},
"url":"https://elm-pages.com/blog/extensible-markdown-parsing-in-elm",
"datePublished":"2019-10-08",
"mainEntityOfPage":{
"@type":"SoftwareSourceCode",
"codeRepository":"https://github.com/dillonkearns/elm-pages",
"description":"A statically typed site generator for Elm.",
"author":"Dillon Kearns",
"programmingLanguage":{
"@type":"ComputerLanguage",
"url":"http://elm-lang.org/",
"name":"Elm",
"image":"http://elm-lang.org/",
"identifier":"http://elm-lang.org/"
}
}
}
</script>
```
To get that data, you would write this in your `elm-pages` head tags:
import Json.Encode as Encode
{-| <https://schema.org/Article>
-}
encodeArticle :
{ title : String
, description : String
, author : StructuredData { authorMemberOf | personOrOrganization : () } authorPossibleFields
, publisher : StructuredData { publisherMemberOf | personOrOrganization : () } publisherPossibleFields
, url : String
, imageUrl : String
, datePublished : String
, mainEntityOfPage : Encode.Value
}
-> Head.Tag pathKey
encodeArticle info =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "Article" )
, ( "headline", Encode.string info.title )
, ( "description", Encode.string info.description )
, ( "image", Encode.string info.imageUrl )
, ( "author", encode info.author )
, ( "publisher", encode info.publisher )
, ( "url", Encode.string info.url )
, ( "datePublished", Encode.string info.datePublished )
, ( "mainEntityOfPage", info.mainEntityOfPage )
]
|> Head.structuredData
Take a look at this [Google Search Gallery](https://developers.google.com/search/docs/guides/search-gallery)
to see some examples of how structured data can be used by search engines to give rich search results. It can help boost
your rankings, get better engagement for your content, and also make your content more accessible. For example,
voice assistant devices can make use of structured data. If you're hosting a conference and want to make the event
date and location easy for attendees to find, this can make that information more accessible.
For the current version of API, you'll need to make sure that the format is correct and contains the required and recommended
structure.
Check out <https://schema.org> for a comprehensive listing of possible data types and fields. And take a look at
Google's [Structured Data Testing Tool](https://search.google.com/structured-data/testing-tool)
too make sure that your structured data is valid and includes the recommended values.
In the future, `elm-pages` will likely support a typed API, but schema.org is a massive spec, and changes frequently.
And there are multiple sources of information on the possible and recommended structure. So it will take some time
for the right API design to evolve. In the meantime, this allows you to make use of this for SEO purposes.
-}
structuredData : Json.Encode.Value -> Tag pathKey
structuredData value =
StructuredData value
{-| Create a raw `AttributeValue` (as opposed to some kind of absolute URL).
-}
raw : String -> AttributeValue pathKey
@ -195,10 +297,19 @@ node name attributes =
code will run this for you to generate your `manifest.json` file automatically!
-}
toJson : String -> String -> Tag pathKey -> Json.Encode.Value
toJson canonicalSiteUrl currentPagePath (Tag tag) =
toJson canonicalSiteUrl currentPagePath tag =
case tag of
Tag headTag ->
Json.Encode.object
[ ( "name", Json.Encode.string tag.name )
, ( "attributes", Json.Encode.list (encodeProperty canonicalSiteUrl currentPagePath) tag.attributes )
[ ( "name", Json.Encode.string headTag.name )
, ( "attributes", Json.Encode.list (encodeProperty canonicalSiteUrl currentPagePath) headTag.attributes )
, ( "type", Json.Encode.string "head" )
]
StructuredData value ->
Json.Encode.object
[ ( "contents", value )
, ( "type", Json.Encode.string "json-ld" )
]
@ -217,8 +328,4 @@ encodeProperty canonicalSiteUrl currentPagePath ( name, value ) =
joinPaths : String -> String -> String
joinPaths base path =
if (base |> String.endsWith "/") && (path |> String.startsWith "/") then
base ++ String.dropLeft 1 path
else
base ++ path
String.chopEnd "/" base ++ "/" ++ String.chopStart "/" path

View File

@ -0,0 +1,18 @@
module Internal.OptimizedDecoder exposing (OptimizedDecoder(..), jd, jde)
import Json.Decode
import Json.Decode.Exploration
type OptimizedDecoder a
= OptimizedDecoder (Json.Decode.Decoder a) (Json.Decode.Exploration.Decoder a)
jd : OptimizedDecoder a -> Json.Decode.Decoder a
jd (OptimizedDecoder jd_ jde_) =
jd_
jde : OptimizedDecoder a -> Json.Decode.Exploration.Decoder a
jde (OptimizedDecoder jd_ jde_) =
jde_

769
src/OptimizedDecoder.elm Normal file
View File

@ -0,0 +1,769 @@
module OptimizedDecoder exposing
( Error, errorToString
, Decoder, string, bool, int, float, Value
, nullable, list, array, dict, keyValuePairs
, field, at, index
, maybe, oneOf
, lazy, value, null, succeed, fail, andThen
, map, map2, map3, map4, map5, map6, map7, map8, andMap
, decodeString, decodeValue, decoder
)
{-| This module allows you to build decoders that `elm-pages` can optimize for you in your `StaticHttp` requests.
It does this by stripping of unused fields during the CLI build step. When it runs in production, it will
just run a plain `elm/json` decoder, so you're fetching and decoding the stripped-down data, but without any
performance penalty.
For convenience, this library also includes a `Json.Decode.Exploration.Pipeline`
module which is largely a copy of [`NoRedInk/elm-decode-pipeline`][edp].
[edp]: http://package.elm-lang.org/packages/NoRedInk/elm-decode-pipeline/latest
## Dealing with warnings and errors
@docs Error, errorToString
# Primitives
@docs Decoder, string, bool, int, float, Value
# Data Structures
@docs nullable, list, array, dict, keyValuePairs
# Object Primitives
@docs field, at, index
# Inconsistent Structure
@docs maybe, oneOf
# Fancy Decoding
@docs lazy, value, null, succeed, fail, andThen
# Mapping
**Note:** If you run out of map functions, take a look at [the pipeline module][pipe]
which makes it easier to handle large objects.
[pipe]: http://package.elm-lang.org/packages/zwilias/json-decode-exploration/latest/Json-Decode-Exploration-Pipeline
@docs map, map2, map3, map4, map5, map6, map7, map8, andMap
# Directly Running Decoders
Usually you'll be passing your decoders to
@docs decodeString, decodeValue, decoder
-}
import Array exposing (Array)
import Dict exposing (Dict)
import Internal.OptimizedDecoder exposing (OptimizedDecoder(..))
import Json.Decode as JD
import Json.Decode.Exploration as JDE
{-| A decoder that will be optimized in your production bundle.
-}
type alias Decoder a =
OptimizedDecoder a
{-| A simple type alias for `Json.Decode.Value`.
-}
type alias Value =
JD.Value
{-| A simple type alias for `Json.Decode.Error`.
-}
type alias Error =
JD.Error
{-| A simple wrapper for `Json.Decode.errorToString`.
-}
errorToString : JD.Error -> String
errorToString =
JD.errorToString
{-| Usually you'll want to directly pass your `OptimizedDecoder` to `StaticHttp` or other `elm-pages` APIs.
But if you want to re-use your decoder somewhere else, it may be useful to turn it into a plain `elm/json` decoder.
-}
decoder : Decoder a -> JD.Decoder a
decoder (OptimizedDecoder jd jde) =
jd
{-| A simple wrapper for `Json.Decode.errorToString`.
This will directly call the raw `elm/json` decoder that is stored under the hood.
-}
decodeString : Decoder a -> String -> Result Error a
decodeString (OptimizedDecoder jd jde) =
JD.decodeString jd
{-| A simple wrapper for `Json.Decode.errorToString`.
This will directly call the raw `elm/json` decoder that is stored under the hood.
-}
decodeValue : Decoder a -> Value -> Result Error a
decodeValue (OptimizedDecoder jd jde) =
JD.decodeValue jd
{-| A decoder that will ignore the actual JSON and succeed with the provided
value. Note that this may still fail when dealing with an invalid JSON string.
If a value in the JSON ends up being ignored because of this, this will cause a
warning.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" null """
|> decodeString (value |> andThen (\_ -> succeed "hello world"))
--> Success "hello world"
""" null """
|> decodeString (succeed "hello world")
--> WithWarnings
--> (Nonempty (Here <| UnusedValue Encode.null) [])
--> "hello world"
""" foo """
|> decodeString (succeed "hello world")
--> BadJson
-}
succeed : a -> Decoder a
succeed a =
OptimizedDecoder (JD.succeed a) (JDE.succeed a)
{-| Ignore the json and fail with a provided message.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" "hello" """
|> decodeString (fail "failure")
--> Errors (Nonempty (Here <| Failure "failure" (Just <| Encode.string "hello")) [])
-}
fail : String -> Decoder a
fail message =
OptimizedDecoder (JD.fail message) (JDE.fail message)
{-| Decode a string.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" "hello world" """
|> decodeString string
--> Success "hello world"
""" 123 """
|> decodeString string
--> Errors (Nonempty (Here <| Expected TString (Encode.int 123)) [])
-}
string : Decoder String
string =
OptimizedDecoder JD.string JDE.string
{-| Extract a piece without actually decoding it.
If a structure is decoded as a `value`, everything _in_ the structure will be
considered as having been used and will not appear in `UnusedValue` warnings.
import Json.Encode as Encode
""" [ 123, "world" ] """
|> decodeString value
--> Success (Encode.list identity [ Encode.int 123, Encode.string "world" ])
-}
value : Decoder Value
value =
OptimizedDecoder JD.value JDE.value
{-| Decode a number into a `Float`.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" 12.34 """
|> decodeString float
--> Success 12.34
""" 12 """
|> decodeString float
--> Success 12
""" null """
|> decodeString float
--> Errors (Nonempty (Here <| Expected TNumber Encode.null) [])
-}
float : Decoder Float
float =
OptimizedDecoder JD.float JDE.float
{-| Decode a number into an `Int`.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" 123 """
|> decodeString int
--> Success 123
""" 0.1 """
|> decodeString int
--> Errors <|
--> Nonempty
--> (Here <| Expected TInt (Encode.float 0.1))
--> []
-}
int : Decoder Int
int =
OptimizedDecoder JD.int JDE.int
{-| Decode a boolean value.
""" [ true, false ] """
|> decodeString (list bool)
--> Success [ True, False ]
-}
bool : Decoder Bool
bool =
OptimizedDecoder JD.bool JDE.bool
{-| Decode a `null` and succeed with some value.
""" null """
|> decodeString (null "it was null")
--> Success "it was null"
Note that `undefined` and `null` are not the same thing. This cannot be used to
verify that a field is _missing_, only that it is explicitly set to `null`.
""" { "foo": null } """
|> decodeString (field "foo" (null ()))
--> Success ()
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" { } """
|> decodeString (field "foo" (null ()))
--> Errors <|
--> Nonempty
--> (Here <| Expected (TObjectField "foo") (Encode.object []))
--> []
-}
null : a -> Decoder a
null val =
OptimizedDecoder (JD.null val) (JDE.null val)
{-| Decode a list of values, decoding each entry with the provided decoder.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" [ "foo", "bar" ] """
|> decodeString (list string)
--> Success [ "foo", "bar" ]
""" [ "foo", null ] """
|> decodeString (list string)
--> Errors <|
--> Nonempty
--> (AtIndex 1 <|
--> Nonempty (Here <| Expected TString Encode.null) []
--> )
--> []
-}
list : Decoder a -> Decoder (List a)
list (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.list jd) (JDE.list jde)
{-| _Convenience function._ Decode a JSON array into an Elm `Array`.
import Array
""" [ 1, 2, 3 ] """
|> decodeString (array int)
--> Success <| Array.fromList [ 1, 2, 3 ]
-}
array : Decoder a -> Decoder (Array a)
array (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.array jd) (JDE.array jde)
{-| _Convenience function._ Decode a JSON object into an Elm `Dict String`.
import Dict
""" { "foo": "bar", "bar": "hi there" } """
|> decodeString (dict string)
--> Success <| Dict.fromList
--> [ ( "bar", "hi there" )
--> , ( "foo", "bar" )
--> ]
-}
dict : Decoder v -> Decoder (Dict String v)
dict (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.dict jd) (JDE.dict jde)
{-| Decode a specific index using a specified `Decoder`.
import List.Nonempty exposing (Nonempty(..))
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" [ "hello", 123 ] """
|> decodeString (map2 Tuple.pair (index 0 string) (index 1 int))
--> Success ( "hello", 123 )
""" [ "hello", "there" ] """
|> decodeString (index 1 string)
--> WithWarnings (Nonempty (AtIndex 0 (Nonempty (Here (UnusedValue (Encode.string "hello"))) [])) [])
--> "there"
-}
index : Int -> Decoder a -> Decoder a
index idx (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.index idx jd) (JDE.index idx jde)
{-| Decode a JSON object into a list of key-value pairs. The decoder you provide
will be used to decode the values.
""" { "foo": "bar", "hello": "world" } """
|> decodeString (keyValuePairs string)
--> Success [ ( "foo", "bar" ), ( "hello", "world" ) ]
-}
keyValuePairs : Decoder a -> Decoder (List ( String, a ))
keyValuePairs (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.keyValuePairs jd) (JDE.keyValuePairs jde)
{-| Decode the content of a field using a provided decoder.
import List.Nonempty as Nonempty
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" { "foo": "bar" } """
|> decodeString (field "foo" string)
--> Success "bar"
""" [ { "foo": "bar" }, { "foo": "baz", "hello": "world" } ] """
|> decodeString (list (field "foo" string))
--> WithWarnings expectedWarnings [ "bar", "baz" ]
expectedWarnings : Warnings
expectedWarnings =
UnusedField "hello"
|> Here
|> Nonempty.fromElement
|> AtIndex 1
|> Nonempty.fromElement
-}
field : String -> Decoder a -> Decoder a
field fieldName (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.field fieldName jd) (JDE.field fieldName jde)
{-| Decodes a value at a certain path, using a provided decoder. Essentially,
writing `at [ "a", "b", "c" ] string` is sugar over writing
`field "a" (field "b" (field "c" string))`}.
""" { "a": { "b": { "c": "hi there" } } } """
|> decodeString (at [ "a", "b", "c" ] string)
--> Success "hi there"
-}
at : List String -> Decoder a -> Decoder a
at fields (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.at fields jd) (JDE.at fields jde)
-- Choosing
{-| Tries a bunch of decoders. The first one to not fail will be the one used.
If all fail, the errors are collected into a `BadOneOf`.
import List.Nonempty as Nonempty
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" [ 12, "whatever" ] """
|> decodeString (list <| oneOf [ map String.fromInt int, string ])
--> Success [ "12", "whatever" ]
""" null """
|> decodeString (oneOf [ string, map String.fromInt int ])
--> Errors <| Nonempty.fromElement <| Here <| BadOneOf
--> [ Nonempty.fromElement <| Here <| Expected TString Encode.null
--> , Nonempty.fromElement <| Here <| Expected TInt Encode.null
--> ]
-}
oneOf : List (Decoder a) -> Decoder a
oneOf decoders =
let
jds =
List.map
(\(OptimizedDecoder jd jde) ->
jd
)
decoders
jdes =
List.map
(\(OptimizedDecoder jd jde) ->
jde
)
decoders
in
OptimizedDecoder (JD.oneOf jds) (JDE.oneOf jdes)
{-| Decodes successfully and wraps with a `Just`, handling failure by succeeding
with `Nothing`.
import List.Nonempty as Nonempty
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
""" [ "foo", 12 ] """
|> decodeString (list <| maybe string)
--> WithWarnings expectedWarnings [ Just "foo", Nothing ]
expectedWarnings : Warnings
expectedWarnings =
UnusedValue (Encode.int 12)
|> Here
|> Nonempty.fromElement
|> AtIndex 1
|> Nonempty.fromElement
-}
maybe : Decoder a -> Decoder (Maybe a)
maybe (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.maybe jd) (JDE.maybe jde)
{-| Decodes successfully and wraps with a `Just`. If the values is `null`
succeeds with `Nothing`.
""" [ { "foo": "bar" }, { "foo": null } ] """
|> decodeString (list <| field "foo" <| nullable string)
--> Success [ Just "bar", Nothing ]
-}
nullable : Decoder a -> Decoder (Maybe a)
nullable (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.nullable jd) (JDE.nullable jde)
--
{-| Required when using (mutually) recursive decoders.
-}
lazy : (() -> Decoder a) -> Decoder a
lazy toDecoder =
let
jd : JD.Decoder a
jd =
(\() ->
case toDecoder () of
OptimizedDecoder jd_ jde_ ->
jd_
)
|> JD.lazy
jde : JDE.Decoder a
jde =
(\() ->
case toDecoder () of
OptimizedDecoder jd_ jde_ ->
jde_
)
|> JDE.lazy
in
OptimizedDecoder
jd
jde
{-| Useful for checking a value in the JSON matches the value you expect it to
have. If it does, succeeds with the second decoder. If it doesn't it fails.
This can be used to decode union types:
type Pet = Cat | Dog | Rabbit
petDecoder : Decoder Pet
petDecoder =
oneOf
[ check string "cat" <| succeed Cat
, check string "dog" <| succeed Dog
, check string "rabbit" <| succeed Rabbit
]
""" [ "dog", "rabbit", "cat" ] """
|> decodeString (list petDecoder)
--> Success [ Dog, Rabbit, Cat ]
-}
check : Decoder a -> a -> Decoder b -> Decoder b
check checkDecoder expectedVal actualDecoder =
checkDecoder
|> andThen
(\actual ->
if actual == expectedVal then
actualDecoder
else
fail "Verification failed"
)
-- Mapping and chaining
{-| Useful for transforming decoders.
""" "foo" """
|> decodeString (map String.toUpper string)
--> Success "FOO"
-}
map : (a -> b) -> Decoder a -> Decoder b
map f (OptimizedDecoder jd jde) =
OptimizedDecoder (JD.map f jd) (JDE.map f jde)
{-| Chain decoders where one decoder depends on the value of another decoder.
-}
andThen : (a -> Decoder b) -> Decoder a -> Decoder b
andThen toDecoderB (OptimizedDecoder jd jde) =
OptimizedDecoder
(JD.andThen (toDecoderB >> Internal.OptimizedDecoder.jd) jd)
(JDE.andThen (toDecoderB >> Internal.OptimizedDecoder.jde) jde)
{-| Combine 2 decoders.
-}
map2 : (a -> b -> c) -> Decoder a -> Decoder b -> Decoder c
map2 f (OptimizedDecoder jdA jdeA) (OptimizedDecoder jdB jdeB) =
OptimizedDecoder
(JD.map2 f jdA jdB)
(JDE.map2 f jdeA jdeB)
{-| Decode an argument and provide it to a function in a decoder.
decoder : Decoder String
decoder =
succeed (String.repeat)
|> andMap (field "count" int)
|> andMap (field "val" string)
""" { "val": "hi", "count": 3 } """
|> decodeString decoder
--> Success "hihihi"
-}
andMap : Decoder a -> Decoder (a -> b) -> Decoder b
andMap =
map2 (|>)
{-| Combine 3 decoders.
-}
map3 :
(a -> b -> c -> d)
-> Decoder a
-> Decoder b
-> Decoder c
-> Decoder d
map3 f decoderA decoderB decoderC =
map f decoderA
|> andMap decoderB
|> andMap decoderC
{-| Combine 4 decoders.
-}
map4 :
(a -> b -> c -> d -> e)
-> Decoder a
-> Decoder b
-> Decoder c
-> Decoder d
-> Decoder e
map4 f decoderA decoderB decoderC decoderD =
map f decoderA
|> andMap decoderB
|> andMap decoderC
|> andMap decoderD
{-| Combine 5 decoders.
-}
map5 :
(a -> b -> c -> d -> e -> f)
-> Decoder a
-> Decoder b
-> Decoder c
-> Decoder d
-> Decoder e
-> Decoder f
map5 f decoderA decoderB decoderC decoderD decoderE =
map f decoderA
|> andMap decoderB
|> andMap decoderC
|> andMap decoderD
|> andMap decoderE
{-| Combine 6 decoders.
-}
map6 :
(a -> b -> c -> d -> e -> f -> g)
-> Decoder a
-> Decoder b
-> Decoder c
-> Decoder d
-> Decoder e
-> Decoder f
-> Decoder g
map6 f decoderA decoderB decoderC decoderD decoderE decoderF =
map f decoderA
|> andMap decoderB
|> andMap decoderC
|> andMap decoderD
|> andMap decoderE
|> andMap decoderF
{-| Combine 7 decoders.
-}
map7 :
(a -> b -> c -> d -> e -> f -> g -> h)
-> Decoder a
-> Decoder b
-> Decoder c
-> Decoder d
-> Decoder e
-> Decoder f
-> Decoder g
-> Decoder h
map7 f decoderA decoderB decoderC decoderD decoderE decoderF decoderG =
map f decoderA
|> andMap decoderB
|> andMap decoderC
|> andMap decoderD
|> andMap decoderE
|> andMap decoderF
|> andMap decoderG
{-| Combine 8 decoders.
-}
map8 :
(a -> b -> c -> d -> e -> f -> g -> h -> i)
-> Decoder a
-> Decoder b
-> Decoder c
-> Decoder d
-> Decoder e
-> Decoder f
-> Decoder g
-> Decoder h
-> Decoder i
map8 f decoderA decoderB decoderC decoderD decoderE decoderF decoderG decoderH =
map f decoderA
|> andMap decoderB
|> andMap decoderC
|> andMap decoderD
|> andMap decoderE
|> andMap decoderF
|> andMap decoderG
|> andMap decoderH

View File

@ -0,0 +1,333 @@
module OptimizedDecoder.Pipeline exposing
( required, requiredAt, optional, optionalAt, hardcoded, custom
, decode, resolve
)
{-|
# Json.Decode.Pipeline
Use the `(|>)` operator to build JSON decoders.
## Decoding fields
@docs required, requiredAt, optional, optionalAt, hardcoded, custom
## Beginning and ending pipelines
@docs decode, resolve
### Verified docs
The examples all expect imports set up like this:
import Json.Decode.Exploration exposing (..)
import Json.Decode.Exploration.Pipeline exposing (..)
import Json.Decode.Exploration.Located exposing (Located(..))
import Json.Encode as Encode
import List.Nonempty as Nonempty
For automated verification of these examples, this import is also required.
Please ignore it.
import DocVerificationHelpers exposing (User)
-}
import OptimizedDecoder as Decode exposing (Decoder)
{-| Decode a required field.
import Json.Decode.Exploration exposing (..)
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
decode User
|> required "id" int
|> required "name" string
|> required "email" string
""" {"id": 123, "email": "sam@example.com", "name": "Sam"} """
|> decodeString userDecoder
--> Success { id = 123, name = "Sam", email = "sam@example.com" }
-}
required : String -> Decoder a -> Decoder (a -> b) -> Decoder b
required key valDecoder decoder =
decoder |> Decode.andMap (Decode.field key valDecoder)
{-| Decode a required nested field.
import Json.Decode.Exploration exposing (..)
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
decode User
|> required "id" int
|> requiredAt [ "profile", "name" ] string
|> required "email" string
"""
{
"id": 123,
"email": "sam@example.com",
"profile": { "name": "Sam" }
}
"""
|> decodeString userDecoder
--> Success { id = 123, name = "Sam", email = "sam@example.com" }
-}
requiredAt : List String -> Decoder a -> Decoder (a -> b) -> Decoder b
requiredAt path valDecoder decoder =
decoder |> Decode.andMap (Decode.at path valDecoder)
{-| Decode a field that may be missing or have a null value. If the field is
missing, then it decodes as the `fallback` value. If the field is present,
then `valDecoder` is used to decode its value. If `valDecoder` fails on a
`null` value, then the `fallback` is used as if the field were missing
entirely.
import Json.Decode.Exploration exposing (..)
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
decode User
|> required "id" int
|> optional "name" string "blah"
|> required "email" string
""" { "id": 123, "email": "sam@example.com" } """
|> decodeString userDecoder
--> Success { id = 123, name = "blah", email = "sam@example.com" }
Because `valDecoder` is given an opportunity to decode `null` values before
resorting to the `fallback`, you can distinguish between missing and `null`
values if you need to:
userDecoder2 =
decode User
|> required "id" int
|> optional "name" (oneOf [ string, null "NULL" ]) "MISSING"
|> required "email" string
Note also that this behaves _slightly_ different than the stock pipeline
package.
In the stock pipeline package, running the following decoder with an array as
the input would _succeed_.
fooDecoder =
decode identity
|> optional "foo" (maybe string) Nothing
In this package, such a decoder will error out instead, saying that it expected
the input to be an object. The _key_ `"foo"` is optional, but it really does
have to be an object before we even consider trying your decoder or returning
the fallback.
-}
optional : String -> Decoder a -> a -> Decoder (a -> b) -> Decoder b
optional key valDecoder fallback decoder =
-- source: https://github.com/NoRedInk/elm-json-decode-pipeline/blob/d9c10a2b388176569fe3e88ef0e2b6fc19d9beeb/src/Json/Decode/Pipeline.elm#L113
custom (optionalDecoder (Decode.field key Decode.value) valDecoder fallback) decoder
{-| Decode an optional nested field.
-}
optionalAt : List String -> Decoder a -> a -> Decoder (a -> b) -> Decoder b
optionalAt path valDecoder fallback decoder =
custom (optionalDecoder (Decode.at path Decode.value) valDecoder fallback) decoder
-- source: https://github.com/NoRedInk/elm-json-decode-pipeline/blob/d9c10a2b388176569fe3e88ef0e2b6fc19d9beeb/src/Json/Decode/Pipeline.elm#L116-L148
optionalDecoder : Decode.Decoder Decode.Value -> Decoder a -> a -> Decoder a
optionalDecoder pathDecoder valDecoder fallback =
let
nullOr decoder =
Decode.oneOf [ decoder, Decode.null fallback ]
handleResult input =
case Decode.decodeValue pathDecoder input of
Ok rawValue ->
-- The field was present, so now let's try to decode that value.
-- (If it was present but fails to decode, this should and will fail!)
case Decode.decodeValue (nullOr valDecoder) rawValue of
Ok finalResult ->
Decode.succeed finalResult
Err finalErr ->
-- TODO is there some way to preserve the structure
-- of the original error instead of using toString here?
Decode.fail (Decode.errorToString finalErr)
Err _ ->
-- The field was not present, so use the fallback.
Decode.succeed fallback
in
Decode.value
|> Decode.andThen handleResult
{-| Rather than decoding anything, use a fixed value for the next step in the
pipeline. `harcoded` does not look at the JSON at all.
import Json.Decode.Exploration exposing (..)
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
decode User
|> required "id" int
|> hardcoded "Alex"
|> required "email" string
""" { "id": 123, "email": "sam@example.com" } """
|> decodeString userDecoder
--> Success { id = 123, name = "Alex", email = "sam@example.com" }
-}
hardcoded : a -> Decoder (a -> b) -> Decoder b
hardcoded =
Decode.andMap << Decode.succeed
{-| Run the given decoder and feed its result into the pipeline at this point.
Consider this example.
import Json.Decode.Exploration exposing (..)
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
decode User
|> required "id" int
|> custom (at [ "profile", "name" ] string)
|> required "email" string
"""
{
"id": 123,
"email": "sam@example.com",
"profile": {"name": "Sam"}
}
"""
|> decodeString userDecoder
--> Success { id = 123, name = "Sam", email = "sam@example.com" }
-}
custom : Decoder a -> Decoder (a -> b) -> Decoder b
custom =
Decode.andMap
{-| Convert a `Decoder (Result x a)` into a `Decoder a`. Useful when you want
to perform some custom processing just before completing the decoding operation.
import Json.Decode.Exploration exposing (..)
type alias User =
{ id : Int
, name : String
, email : String
}
userDecoder : Decoder User
userDecoder =
let
-- toDecoder gets run *after* all the
-- (|> required ...) steps are done.
toDecoder : Int -> String -> String -> Int -> Decoder User
toDecoder id name email version =
if version >= 2 then
succeed (User id name email)
else
fail "This JSON is from a deprecated source. Please upgrade!"
in
decode toDecoder
|> required "id" int
|> required "name" string
|> required "email" string
|> required "version" int
-- version is part of toDecoder,
-- but it is not a part of User
|> resolve
"""
{
"id": 123,
"name": "Sam",
"email": "sam@example.com",
"version": 3
}
"""
|> decodeString userDecoder
--> Success { id = 123, name = "Sam", email = "sam@example.com" }
-}
resolve : Decoder (Decoder a) -> Decoder a
resolve =
Decode.andThen identity
{-| Begin a decoding pipeline. This is a synonym for [Json.Decode.succeed](http://package.elm-lang.org/packages/elm-lang/core/latest/Json-Decode#succeed),
intended to make things read more clearly.
type alias User =
{ id : Int
, email : String
, name : String
}
userDecoder : Decoder User
userDecoder =
decode User
|> required "id" int
|> required "email" string
|> optional "name" string ""
-}
decode : a -> Decoder a
decode =
Decode.succeed

View File

@ -21,15 +21,12 @@ import Html exposing (Html)
import Html.Attributes as Attr
import Http
import Json.Decode as Decode
import Mark
import Mark.Error
import Pages.Document as Document exposing (Document)
import Pages.Internal.String as String
import Pages.PagePath as PagePath exposing (PagePath)
import Result.Extra
import Task exposing (Task)
import TerminalText as Terminal
import Url exposing (Url)
import Url.Builder
type alias Content =
@ -88,9 +85,7 @@ pagesWithErrors cache =
cache
|> Result.map
(\okCache ->
okCache
|> Dict.toList
|> List.filterMap
List.filterMap
(\( path, value ) ->
case value of
Parsed metadata { body } ->
@ -104,6 +99,7 @@ pagesWithErrors cache =
_ ->
Nothing
)
(Dict.toList okCache)
)
|> Result.withDefault []
@ -114,22 +110,17 @@ init :
-> Maybe { contentJson : ContentJson String, initialUrl : Url }
-> ContentCache metadata view
init document content maybeInitialPageContent =
parseMetadata maybeInitialPageContent document content
content
|> parseMetadata maybeInitialPageContent document
|> List.map
(\tuple ->
Tuple.mapSecond
(\result ->
result
|> Result.mapError
(\error ->
-- ( Tuple.first tuple, error )
createErrors (Tuple.first tuple) error
)
)
tuple
|> Tuple.first
|> createErrors
|> Result.mapError
|> (\f -> Tuple.mapSecond f tuple)
)
|> combineTupleResults
-- |> Result.mapError Dict.fromList
|> Result.map Dict.fromList
@ -142,7 +133,7 @@ createBuildError path decodeError =
{ title = "Metadata Decode Error"
, message =
[ Terminal.text "I ran into a problem when parsing the metadata for the page with this path: "
, Terminal.text ("/" ++ (path |> String.join "/"))
, Terminal.text (String.join "/" path)
, Terminal.text "\n\n"
, Terminal.text decodeError
]
@ -156,8 +147,7 @@ parseMetadata :
-> List ( List String, { extension : String, frontMatter : String, body : Maybe String } )
-> List ( List String, Result String (Entry metadata view) )
parseMetadata maybeInitialPageContent document content =
content
|> List.map
List.map
(\( path, { frontMatter, extension, body } ) ->
let
maybeDocumentEntry =
@ -170,8 +160,7 @@ parseMetadata maybeInitialPageContent document content =
|> Result.map
(\metadata ->
let
renderer =
\value ->
renderer value =
parseContent extension value document
in
case maybeInitialPageContent of
@ -194,6 +183,7 @@ parseMetadata maybeInitialPageContent document content =
Err ("Could not find extension '" ++ extension ++ "'")
|> Tuple.pair path
)
content
normalizePath : String -> String
@ -206,16 +196,15 @@ normalizePath pathString =
String.endsWith "/" pathString
in
if pathString == "" then
"/"
pathString
else
String.concat
[ if hasPrefix then
""
String.dropLeft 1 pathString
else
"/"
, pathString
pathString
, if hasSuffix then
""
@ -257,7 +246,7 @@ createHtmlError : List String -> String -> Html msg
createHtmlError path error =
Html.div []
[ Html.h2 []
[ Html.text ("/" ++ (path |> String.join "/"))
[ Html.text (String.join "/" path)
]
, Html.p [] [ Html.text "I couldn't parse the frontmatter in this page. I ran into this error with your JSON decoder:" ]
, Html.pre [] [ Html.text error ]
@ -269,7 +258,6 @@ routes record =
record
|> List.map Tuple.first
|> List.map (String.join "/")
|> List.map (\route -> "/" ++ route)
routesForCache : ContentCache metadata view -> List String
@ -291,16 +279,6 @@ type alias Page metadata view pathKey =
}
renderErrors : ( List String, List Mark.Error.Error ) -> Html msg
renderErrors ( path, errors ) =
Html.div []
[ Html.text (path |> String.join "/")
, errors
|> List.map (Mark.Error.toHtml Mark.Error.Light)
|> Html.div []
]
combineTupleResults :
List ( List String, Result error success )
-> Result (List error) (List ( List String, success ))
@ -349,37 +327,40 @@ parse it before returning it and store the parsed version in the Cache
-}
lazyLoad :
Document metadata view
-> Url
-> { currentUrl : Url, baseUrl : Url }
-> ContentCache metadata view
-> Task Http.Error (ContentCache metadata view)
lazyLoad document url cacheResult =
lazyLoad document urls cacheResult =
case cacheResult of
Err _ ->
Task.succeed cacheResult
Ok cache ->
case Dict.get (pathForUrl url) cache of
case Dict.get (pathForUrl urls) cache of
Just entry ->
case entry of
NeedContent extension _ ->
httpTask url
urls.currentUrl
|> httpTask
|> Task.map
(\downloadedContent ->
update cacheResult
update
cacheResult
(\value ->
parseContent extension value document
)
url
urls
downloadedContent
)
Unparsed extension metadata content ->
update cacheResult
content
|> update
cacheResult
(\thing ->
parseContent extension thing document
)
url
content
urls
|> Task.succeed
Parsed _ _ ->
@ -395,12 +376,13 @@ httpTask url =
{ method = "GET"
, headers = []
, url =
Url.Builder.absolute
((url.path |> String.split "/" |> List.filter (not << String.isEmpty))
++ [ "content.json"
]
)
[]
url.path
|> String.chopForwardSlashes
|> String.split "/"
|> List.filter ((/=) "")
|> (\l -> l ++ [ "content.json" ])
|> String.join "/"
|> String.append "/"
, body = Http.emptyBody
, resolver =
Http.stringResolver
@ -443,13 +425,14 @@ contentJsonDecoder =
update :
ContentCache metadata view
-> (String -> Result ParseError view)
-> Url
-> { currentUrl : Url, baseUrl : Url }
-> ContentJson String
-> ContentCache metadata view
update cacheResult renderer url rawContent =
update cacheResult renderer urls rawContent =
case cacheResult of
Ok cache ->
Dict.update (pathForUrl url)
Dict.update
(pathForUrl urls)
(\entry ->
case entry of
Just (Parsed metadata view) ->
@ -482,27 +465,29 @@ update cacheResult renderer url rawContent =
Err error
pathForUrl : Url -> Path
pathForUrl url =
url.path
|> dropTrailingSlash
pathForUrl : { currentUrl : Url, baseUrl : Url } -> Path
pathForUrl { currentUrl, baseUrl } =
currentUrl.path
|> String.dropLeft (String.length baseUrl.path)
|> String.chopForwardSlashes
|> String.split "/"
|> List.drop 1
|> List.filter ((/=) "")
lookup :
pathKey
-> ContentCache metadata view
-> Url
-> { currentUrl : Url, baseUrl : Url }
-> Maybe ( PagePath pathKey, Entry metadata view )
lookup pathKey content url =
lookup pathKey content urls =
case content of
Ok dict ->
let
path =
pathForUrl url
pathForUrl urls
in
Dict.get path dict
dict
|> Dict.get path
|> Maybe.map
(\entry ->
( PagePath.build pathKey path, entry )
@ -515,10 +500,11 @@ lookup pathKey content url =
lookupMetadata :
pathKey
-> ContentCache metadata view
-> Url
-> { currentUrl : Url, baseUrl : Url }
-> Maybe ( PagePath pathKey, metadata )
lookupMetadata pathKey content url =
lookup pathKey content url
lookupMetadata pathKey content urls =
urls
|> lookup pathKey content
|> Maybe.map
(\( pagePath, entry ) ->
case entry of
@ -531,11 +517,3 @@ lookupMetadata pathKey content url =
Parsed metadata _ ->
( pagePath, metadata )
)
dropTrailingSlash path =
if path |> String.endsWith "/" then
String.dropRight 1 path
else
path

View File

@ -110,7 +110,7 @@ includes (Directory key allPagePaths directoryPath) pagePath =
Pages.pages.blog.directory
-- blogDirectory |> Directory.indexPath |> PagePath.toString
-- => "/blog"
-- => "blog"
See `Directory.includes` for an example of this in action.
@ -121,9 +121,8 @@ indexPath (Directory key allPagePaths directoryPath) =
toString : List String -> String
toString rawPath =
"/"
++ (rawPath |> String.join "/")
toString =
String.join "/"
{-| Used by the generated `Pages.elm` module. There's no need to use this

View File

@ -1,6 +1,6 @@
module Pages.Document exposing
( Document, DocumentHandler
, parser, markupParser
, parser
, fromList, get
)
@ -80,7 +80,7 @@ Hello!!!
)
@docs Document, DocumentHandler
@docs parser, markupParser
@docs parser
## Functions for use by generated code
@ -92,8 +92,6 @@ Hello!!!
import Dict exposing (Dict)
import Html exposing (Html)
import Json.Decode
import Mark
import Mark.Error
{-| Represents all of the `DocumentHandler`s. You register a handler for each
@ -157,54 +155,3 @@ parser { extension, body, metadata } =
|> Result.mapError Json.Decode.errorToString
}
)
{-| Register an [`elm-markup`](https://github.com/mdgriffith/elm-markup/)
parser for your `.emu` files.
-}
markupParser :
Mark.Document metadata
-> Mark.Document view
-> ( String, DocumentHandler metadata view )
markupParser metadataParser markBodyParser =
( "emu"
, DocumentHandler
{ contentParser = renderMarkup markBodyParser
, frontmatterParser =
\frontMatter ->
Mark.compile metadataParser
frontMatter
|> (\outcome ->
case outcome of
Mark.Success parsedMetadata ->
Ok parsedMetadata
Mark.Failure failure ->
Err "Failure"
Mark.Almost failure ->
Err "Almost failure"
)
}
)
renderMarkup : Mark.Document view -> String -> Result String view
renderMarkup markBodyParser markupBody =
Mark.compile
markBodyParser
(markupBody |> String.trimLeft)
|> (\outcome ->
case outcome of
Mark.Success renderedView ->
Ok renderedView
Mark.Failure failures ->
failures
|> List.map Mark.Error.toString
|> String.join "\n"
|> Err
Mark.Almost failure ->
Err "TODO almost failure"
)

View File

@ -25,7 +25,7 @@ This gives you a record, based on all the files in your local
Pages.pages.index
-- ImagePath.toString homePath
-- => "/"
-- => ""
or
@ -37,7 +37,7 @@ or
Pages.images.profilePhotos.dillon
-- ImagePath.toString helloWorldPostPath
-- => "/images/profile-photos/dillon.jpg"
-- => "images/profile-photos/dillon.jpg"
@docs ImagePath, toString, external
@ -65,7 +65,7 @@ type ImagePath key
| External String
{-| Gives you the image's absolute URL as a String. This is useful for constructing `<img>` tags:
{-| Gives you the image's relative URL as a String. This is useful for constructing `<img>` tags:
import Html exposing (Html, img)
import Html.Attributes exposing (src)
@ -87,8 +87,7 @@ toString : ImagePath key -> String
toString path =
case path of
Internal rawPath ->
"/"
++ (rawPath |> String.join "/")
String.join "/" rawPath
External url ->
url

View File

@ -13,11 +13,12 @@ that is in the generated `Pages` module (see <Pages.Platform>).
-}
import Json.Decode
import Json.Encode
import Pages.Internal.Platform
{-| Internal detial to track whether to run the CLI step or the runtime step in the browser.
{-| Internal detail to track whether to run the CLI step or the runtime step in the browser.
-}
type ApplicationType
= Browser
@ -31,4 +32,5 @@ type alias Internal pathKey =
, content : Pages.Internal.Platform.Content
, pathKey : pathKey
, toJsPort : Json.Encode.Value -> Cmd Never
, fromJsPort : Sub Json.Decode.Value
}

View File

@ -0,0 +1,6 @@
module Pages.Internal.ApplicationType exposing (ApplicationType(..))
type ApplicationType
= Browser
| Cli

View File

@ -0,0 +1,108 @@
module Pages.Internal.HotReloadLoadingIndicator exposing (..)
import Html exposing (Html)
import Html.Attributes exposing (..)
circle : List (Html.Attribute msg) -> Html msg
circle attrs =
Html.div
(style "animation" "lds-default 1.2s linear infinite"
:: style "background" "#000"
:: style "position" "absolute"
:: style "width" "6px"
:: style "height" "6px"
:: style "border-radius" "50%"
:: attrs
)
[]
view : Bool -> Bool -> Html msg
view isDebugMode display =
Html.div
[ id "__elm-pages-loading"
, class "lds-default"
, style "position" "fixed"
, style "bottom" "10px"
, style "right"
(if isDebugMode then
"110px"
else
"10px"
)
, style "width" "80px"
, style "height" "80px"
, style "background-color" "white"
, style "box-shadow" "0 8px 15px 0 rgba(0, 0, 0, 0.25), 0 2px 10px 0 rgba(0, 0, 0, 0.12)"
, style "display"
(case display of
True ->
"block"
False ->
"none"
)
]
[ circle
[ style "animation-delay" "0s"
, style "top" "37px"
, style "left" "66px"
]
, circle
[ style "animation-delay" "-0.1s"
, style "top" "22px"
, style "left" "62px"
]
, circle
[ style "animation-delay" "-0.2s"
, style "top" "11px"
, style "left" "52px"
]
, circle
[ style "animation-delay" "-0.3s"
, style "top" "7px"
, style "left" "37px"
]
, circle
[ style "animation-delay" "-0.4s"
, style "top" "11px"
, style "left" "22px"
]
, circle
[ style "animation-delay" "-0.5s"
, style "top" "22px"
, style "left" "11px"
]
, circle
[ style "animation-delay" "-0.6s"
, style "top" "37px"
, style "left" "7px"
]
, circle
[ style "animation-delay" "-0.7s"
, style "top" "52px"
, style "left" "11px"
]
, circle
[ style "animation-delay" "-0.8s"
, style "top" "62px"
, style "left" "22px"
]
, circle
[ style "animation-delay" "-0.9s"
, style "top" "66px"
, style "left" "37px"
]
, circle
[ style "animation-delay" "-1s"
, style "top" "62px"
, style "left" "52px"
]
, circle
[ style "animation-delay" "-1.1s"
, style "top" "52px"
, style "left" "62px"
]
]

View File

@ -1,4 +1,4 @@
module Pages.Internal.Platform exposing (Content, Flags, Model, Msg, Page, Parser, Program, application, cliApplication)
module Pages.Internal.Platform exposing (Content, Flags, Model, Msg, Page, Program, application, cliApplication)
import Browser
import Browser.Dom as Dom
@ -6,15 +6,17 @@ import Browser.Navigation
import Dict exposing (Dict)
import Head
import Html exposing (Html)
import Html.Attributes
import Html.Attributes exposing (style)
import Html.Lazy
import Http
import Json.Decode as Decode
import Json.Encode
import List.Extra
import Mark
import Pages.ContentCache as ContentCache exposing (ContentCache)
import Pages.Document
import Pages.Internal.ApplicationType as ApplicationType
import Pages.Internal.HotReloadLoadingIndicator as HotReloadLoadingIndicator
import Pages.Internal.Platform.Cli
import Pages.Internal.String as String
import Pages.Manifest as Manifest
import Pages.PagePath as PagePath exposing (PagePath)
import Pages.StaticHttp as StaticHttp
@ -24,14 +26,6 @@ import Task exposing (Task)
import Url exposing (Url)
dropTrailingSlash path =
if path |> String.endsWith "/" then
String.dropRight 1 path
else
path
type alias Page metadata view pathKey =
{ metadata : metadata
, path : PagePath pathKey
@ -81,12 +75,13 @@ mainView pathKey pageView model =
}
urlToPagePath : pathKey -> Url -> PagePath pathKey
urlToPagePath pathKey url =
urlToPagePath : pathKey -> Url -> Url -> PagePath pathKey
urlToPagePath pathKey url baseUrl =
url.path
|> dropTrailingSlash
|> String.dropLeft (String.length baseUrl.path)
|> String.chopForwardSlashes
|> String.split "/"
|> List.drop 1
|> List.filter ((/=) "")
|> PagePath.build pathKey
@ -108,21 +103,27 @@ pageViewOrError :
-> ContentCache metadata view
-> { title : String, body : Html userMsg }
pageViewOrError pathKey viewFn model cache =
case ContentCache.lookup pathKey cache model.url of
let
urls =
{ currentUrl = model.url
, baseUrl = model.baseUrl
}
in
case ContentCache.lookup pathKey cache urls of
Just ( pagePath, entry ) ->
case entry of
ContentCache.Parsed metadata viewResult ->
let
viewFnResult =
viewFn
{ path = pagePath, frontmatter = metadata }
|> viewFn
(cache
|> Result.map (ContentCache.extractMetadata pathKey)
|> Result.withDefault []
-- TODO handle error better
)
{ path = pagePath, frontmatter = metadata }
|> (\request ->
StaticHttpRequest.resolve request viewResult.staticData
StaticHttpRequest.resolve ApplicationType.Browser request viewResult.staticData
)
in
case viewResult.body of
@ -146,6 +147,12 @@ pageViewOrError pathKey viewFn model cache =
[ Html.text "I'm missing some StaticHttp data for this page:"
, Html.pre [] [ Html.text missingKey ]
]
StaticHttpRequest.UserCalledStaticHttpFail message ->
Html.div []
[ Html.text "I ran into a call to `Pages.StaticHttp.fail` with message:"
, Html.pre [] [ Html.text message ]
]
}
Err error ->
@ -198,10 +205,28 @@ view pathKey content viewFn model =
, body =
[ onViewChangeElement model.url
, body |> Html.map UserMsg |> Html.map AppMsg
, Html.Lazy.lazy2 loadingView model.phase model.hmrStatus
]
}
loadingView : Phase -> HmrStatus -> Html msg
loadingView phase hmrStatus =
case phase of
DevClient isDebugMode ->
(case hmrStatus of
HmrLoading ->
True
_ ->
False
)
|> HotReloadLoadingIndicator.view isDebugMode
_ ->
Html.text ""
onViewChangeElement currentUrl =
-- this is a hidden tag
-- it is used from the JS-side to reliably
@ -224,6 +249,13 @@ type alias ContentJson =
}
contentJsonDecoder : Decode.Decoder ContentJson
contentJsonDecoder =
Decode.map2 ContentJson
(Decode.field "body" Decode.string)
(Decode.field "staticData" (Decode.dict Decode.string))
init :
pathKey
-> String
@ -263,36 +295,55 @@ init :
init pathKey canonicalSiteUrl document toJsPort viewFn content initUserModel flags url key =
let
contentCache =
ContentCache.init document content (Maybe.map (\cj -> { contentJson = cj, initialUrl = url }) contentJson)
ContentCache.init
document
content
(Maybe.map (\cj -> { contentJson = cj, initialUrl = url }) contentJson)
contentJson =
flags
|> Decode.decodeValue (Decode.field "contentJson" contentJsonDecoder)
|> Result.toMaybe
contentJsonDecoder : Decode.Decoder ContentJson
contentJsonDecoder =
Decode.map2 ContentJson
(Decode.field "body" Decode.string)
(Decode.field "staticData" (Decode.dict Decode.string))
baseUrl =
flags
|> Decode.decodeValue (Decode.field "baseUrl" Decode.string)
|> Result.toMaybe
|> Maybe.andThen Url.fromString
|> Maybe.withDefault url
urls =
{ currentUrl = url
, baseUrl = baseUrl
}
in
case contentCache of
Ok okCache ->
let
phase =
case Decode.decodeValue (Decode.field "isPrerendering" Decode.bool) flags of
Ok True ->
case
Decode.decodeValue
(Decode.map3 (\a b c -> ( a, b, c ))
(Decode.field "isPrerendering" Decode.bool)
(Decode.field "isDevServer" Decode.bool)
(Decode.field "isElmDebugMode" Decode.bool)
)
flags
of
Ok ( True, _, _ ) ->
Prerender
Ok False ->
Client
Ok ( False, True, isElmDebugMode ) ->
DevClient isElmDebugMode
Ok ( False, False, _ ) ->
ProdClient
Err _ ->
Client
DevClient False
( userModel, userCmd ) =
initUserModel
(maybePagePath
maybePagePath
|> Maybe.map
(\pagePath ->
{ path = pagePath
@ -300,14 +351,16 @@ init pathKey canonicalSiteUrl document toJsPort viewFn content initUserModel fla
, fragment = url.fragment
}
)
)
|> initUserModel
cmd =
case ( maybePagePath, maybeMetadata ) of
( Just pagePath, Just frontmatter ) ->
[ userCmd |> Cmd.map UserMsg |> Just
[ userCmd
|> Cmd.map UserMsg
|> Just
, contentCache
|> ContentCache.lazyLoad document url
|> ContentCache.lazyLoad document urls
|> Task.attempt UpdateCache
|> Just
]
@ -318,7 +371,7 @@ init pathKey canonicalSiteUrl document toJsPort viewFn content initUserModel fla
Cmd.none
( maybePagePath, maybeMetadata ) =
case ContentCache.lookupMetadata pathKey (Ok okCache) url of
case ContentCache.lookupMetadata pathKey (Ok okCache) urls of
Just ( pagePath, metadata ) ->
( Just pagePath, Just metadata )
@ -327,9 +380,11 @@ init pathKey canonicalSiteUrl document toJsPort viewFn content initUserModel fla
in
( { key = key
, url = url
, baseUrl = baseUrl
, userModel = userModel
, contentCache = contentCache
, phase = phase
, hmrStatus = HmrLoaded
}
, cmd
)
@ -341,9 +396,11 @@ init pathKey canonicalSiteUrl document toJsPort viewFn content initUserModel fla
in
( { key = key
, url = url
, baseUrl = baseUrl
, userModel = userModel
, contentCache = contentCache
, phase = Client
, phase = DevClient False
, hmrStatus = HmrLoaded
}
, Cmd.batch
[ userCmd |> Cmd.map UserMsg
@ -371,7 +428,10 @@ type AppMsg userMsg metadata view
| UserMsg userMsg
| UpdateCache (Result Http.Error (ContentCache metadata view))
| UpdateCacheAndUrl Url (Result Http.Error (ContentCache metadata view))
| UpdateCacheForHotReload (Result Http.Error (ContentCache metadata view))
| PageScrollComplete
| HotReloadComplete ContentJson
| StartingHotReload
type Model userModel userMsg metadata view
@ -381,20 +441,24 @@ type Model userModel userMsg metadata view
type alias ModelDetails userModel metadata view =
{ key : Browser.Navigation.Key
, url : Url.Url
, url : Url
, baseUrl : Url
, contentCache : ContentCache metadata view
, userModel : userModel
, phase : Phase
, hmrStatus : HmrStatus
}
type Phase
= Prerender
| Client
| DevClient Bool
| ProdClient
update :
List String
Content
-> List String
-> String
->
(List ( PagePath pathKey, metadata )
@ -422,7 +486,7 @@ update :
-> Msg userMsg metadata view
-> ModelDetails userModel metadata view
-> ( ModelDetails userModel metadata view, Cmd (AppMsg userMsg metadata view) )
update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort document userUpdate msg model =
update content allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort document userUpdate msg model =
case msg of
AppMsg appMsg ->
case appMsg of
@ -431,10 +495,7 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
Browser.Internal url ->
let
navigatingToSamePage =
url.path
== model.url.path
&& url
/= model.url
(url.path == model.url.path) && (url /= model.url)
in
if navigatingToSamePage then
-- this is a workaround for an issue with anchor fragment navigation
@ -450,10 +511,12 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
UrlChanged url ->
let
navigatingToSamePage =
url.path
== model.url.path
&& url
/= model.url
(url.path == model.url.path) && (url /= model.url)
urls =
{ currentUrl = url
, baseUrl = model.baseUrl
}
in
( model
, if navigatingToSamePage then
@ -466,7 +529,7 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
else
model.contentCache
|> ContentCache.lazyLoad document url
|> ContentCache.lazyLoad document urls
|> Task.attempt (UpdateCacheAndUrl url)
)
@ -483,8 +546,13 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
-- to keep track of the last url change
Ok updatedCache ->
let
urls =
{ currentUrl = model.url
, baseUrl = model.baseUrl
}
maybeCmd =
case ContentCache.lookup pathKey updatedCache model.url of
case ContentCache.lookup pathKey updatedCache urls of
Just ( pagePath, entry ) ->
case entry of
ContentCache.Parsed frontmatter viewResult ->
@ -511,7 +579,7 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
)
{ path = pagePath, frontmatter = frontmatter }
|> (\request ->
StaticHttpRequest.resolve request staticDataThing
StaticHttpRequest.resolve ApplicationType.Browser request staticDataThing
)
in
( { model | contentCache = updatedCache }
@ -533,7 +601,7 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
( userModel, userCmd ) =
userUpdate
(onPageChangeMsg
{ path = url |> urlToPagePath pathKey
{ path = urlToPagePath pathKey url model.baseUrl
, query = url.query
, fragment = url.fragment
}
@ -555,18 +623,42 @@ update allRoutes canonicalSiteUrl viewFunction pathKey onPageChangeMsg toJsPort
-- TODO handle error
( { model | url = url }, Cmd.none )
UpdateCacheForHotReload cacheUpdateResult ->
case cacheUpdateResult of
Ok updatedCache ->
( { model | contentCache = updatedCache }, Cmd.none )
Err _ ->
-- TODO handle error
( model, Cmd.none )
PageScrollComplete ->
( model, Cmd.none )
HotReloadComplete contentJson ->
( { model
| contentCache = ContentCache.init document content (Just { contentJson = contentJson, initialUrl = model.url })
, hmrStatus = HmrLoaded
}
, Cmd.none
-- ContentCache.init document content (Maybe.map (\cj -> { contentJson = contentJson, initialUrl = model.url }) Nothing)
--|> ContentCache.lazyLoad document
-- { currentUrl = model.url
-- , baseUrl = model.baseUrl
-- }
--|> Task.attempt UpdateCacheForHotReload
)
StartingHotReload ->
( { model | hmrStatus = HmrLoading }, Cmd.none )
CliMsg _ ->
( model, Cmd.none )
type alias Parser metadata view =
Dict String String
-> List String
-> List ( List String, metadata )
-> Mark.Document view
type HmrStatus
= HmrLoading
| HmrLoaded
application :
@ -593,6 +685,7 @@ application :
, document : Pages.Document.Document metadata view
, content : Content
, toJsPort : Json.Encode.Value -> Cmd Never
, fromJsPort : Sub Decode.Value
, manifest : Manifest.Config pathKey
, generateFiles :
List
@ -601,12 +694,14 @@ application :
, body : String
}
->
List
StaticHttp.Request
(List
(Result String
{ path : List String
, content : String
}
)
)
, canonicalSiteUrl : String
, pathKey : pathKey
, onPageChange :
@ -645,7 +740,7 @@ application config =
Prerender ->
noOpUpdate
Client ->
_ ->
config.update
noOpUpdate =
@ -656,9 +751,8 @@ application config =
config.content
|> List.map Tuple.first
|> List.map (String.join "/")
|> List.map (\route -> "/" ++ route)
in
update allRoutes config.canonicalSiteUrl config.view config.pathKey config.onPageChange config.toJsPort config.document userUpdate msg model
update config.content allRoutes config.canonicalSiteUrl config.view config.pathKey config.onPageChange config.toJsPort config.document userUpdate msg model
|> Tuple.mapFirst Model
|> Tuple.mapSecond (Cmd.map AppMsg)
@ -668,9 +762,27 @@ application config =
\outerModel ->
case outerModel of
Model model ->
config.subscriptions model.userModel
Sub.batch
[ config.subscriptions model.userModel
|> Sub.map UserMsg
|> Sub.map AppMsg
, config.fromJsPort
|> Sub.map
(\decodeValue ->
case decodeValue |> Decode.decodeValue (Decode.field "thingy" Decode.string) of
Ok "hmr-check" ->
AppMsg StartingHotReload
_ ->
case decodeValue |> Decode.decodeValue (Decode.field "contentJson" contentJsonDecoder) of
Ok contentJson ->
AppMsg (HotReloadComplete contentJson)
Err error ->
-- TODO should be no message here
AppMsg StartingHotReload
)
]
CliModel _ ->
Sub.none
@ -703,6 +815,7 @@ cliApplication :
, document : Pages.Document.Document metadata view
, content : Content
, toJsPort : Json.Encode.Value -> Cmd Never
, fromJsPort : Sub Decode.Value
, manifest : Manifest.Config pathKey
, generateFiles :
List
@ -711,12 +824,14 @@ cliApplication :
, body : String
}
->
List
StaticHttp.Request
(List
(Result String
{ path : List String
, content : String
}
)
)
, canonicalSiteUrl : String
, pathKey : pathKey
, onPageChange :

View File

@ -26,6 +26,7 @@ import Pages.ContentCache as ContentCache exposing (ContentCache)
import Pages.Document
import Pages.Http
import Pages.ImagePath as ImagePath
import Pages.Internal.ApplicationType as ApplicationType exposing (ApplicationType)
import Pages.Internal.StaticHttpBody as StaticHttpBody
import Pages.Manifest as Manifest
import Pages.PagePath as PagePath exposing (PagePath)
@ -47,6 +48,7 @@ type alias ToJsSuccessPayload pathKey =
{ pages : Dict String (Dict String String)
, manifest : Manifest.Config pathKey
, filesToGenerate : List FileToGenerate
, staticHttpCache : Dict String String
, errors : List String
}
@ -65,8 +67,8 @@ toJsCodec =
Errors errorList ->
errorsTag errorList
Success { pages, manifest, filesToGenerate, errors } ->
success (ToJsSuccessPayload pages manifest filesToGenerate errors)
Success { pages, manifest, filesToGenerate, errors, staticHttpCache } ->
success (ToJsSuccessPayload pages manifest filesToGenerate staticHttpCache errors)
)
|> Codec.variant1 "Errors" Errors Codec.string
|> Codec.variant1 "Success"
@ -115,6 +117,9 @@ successCodec =
)
(Decode.succeed [])
)
|> Codec.field "staticHttpCache"
.staticHttpCache
(Codec.dict Codec.string)
|> Codec.field "errors" .errors (Codec.list Codec.string)
|> Codec.buildObject
@ -178,6 +183,7 @@ type alias Config pathKey userMsg userModel metadata view =
, document : Pages.Document.Document metadata view
, content : Content
, toJsPort : Json.Encode.Value -> Cmd Never
, fromJsPort : Sub Decode.Value
, manifest : Manifest.Config pathKey
, generateFiles :
List
@ -186,12 +192,14 @@ type alias Config pathKey userMsg userModel metadata view =
, body : String
}
->
List
StaticHttp.Request
(List
(Result String
{ path : List String
, content : String
}
)
)
, canonicalSiteUrl : String
, pathKey : pathKey
, onPageChange :
@ -275,10 +283,10 @@ perform cliMsgConstructor toJsPort effect =
|> Cmd.batch
FetchHttp ({ unmasked, masked } as requests) ->
--let
-- let
-- _ =
-- Debug.log "Fetching" masked.url
--in
-- in
Http.request
{ method = unmasked.method
, url = unmasked.url
@ -316,37 +324,44 @@ init :
init toModel contentCache siteMetadata config flags =
case
Decode.decodeValue
(Decode.map2 Tuple.pair
(Decode.map3 (\a b c -> ( a, b, c ))
(Decode.field "secrets" SecretsDict.decoder)
(Decode.field "mode" modeDecoder)
(Decode.field "staticHttpCache"
(Decode.dict
(Decode.string
|> Decode.map Just
)
)
)
)
flags
of
Ok ( secrets, mode ) ->
Ok ( secrets, mode, staticHttpCache ) ->
case contentCache of
Ok _ ->
case contentCache |> ContentCache.pagesWithErrors of
case ContentCache.pagesWithErrors contentCache of
[] ->
let
requests =
siteMetadata
|> Result.andThen
Result.andThen
(\metadata ->
staticResponseForPage metadata config.view
)
siteMetadata
staticResponses : StaticResponses
staticResponses =
case requests of
Ok okRequests ->
staticResponsesInit okRequests
staticResponsesInit staticHttpCache siteMetadata config okRequests
Err errors ->
-- TODO need to handle errors better?
staticResponsesInit []
staticResponsesInit staticHttpCache siteMetadata config []
( updatedRawResponses, effect ) =
sendStaticResponsesIfDone config siteMetadata mode secrets Dict.empty [] staticResponses
sendStaticResponsesIfDone config siteMetadata mode secrets staticHttpCache [] staticResponses
in
( Model staticResponses secrets [] updatedRawResponses mode |> toModel
, effect
@ -355,21 +370,21 @@ init toModel contentCache siteMetadata config flags =
pageErrors ->
let
requests =
siteMetadata
|> Result.andThen
Result.andThen
(\metadata ->
staticResponseForPage metadata config.view
)
siteMetadata
staticResponses : StaticResponses
staticResponses =
case requests of
Ok okRequests ->
staticResponsesInit okRequests
staticResponsesInit staticHttpCache siteMetadata config okRequests
Err errors ->
-- TODO need to handle errors better?
staticResponsesInit []
staticResponsesInit staticHttpCache siteMetadata config []
in
updateAndSendPortIfDone
config
@ -378,7 +393,7 @@ init toModel contentCache siteMetadata config flags =
staticResponses
secrets
pageErrors
Dict.empty
staticHttpCache
mode
)
toModel
@ -390,7 +405,7 @@ init toModel contentCache siteMetadata config flags =
(Model Dict.empty
secrets
(metadataParserErrors |> List.map Tuple.second)
Dict.empty
staticHttpCache
mode
)
toModel
@ -449,7 +464,7 @@ update siteMetadata config msg model =
case msg of
GotStaticHttpResponse { request, response } ->
let
--_ =
-- _ =
-- Debug.log "Got response" request.masked.url
--
updatedModel =
@ -457,16 +472,16 @@ update siteMetadata config msg model =
Ok okResponse ->
staticResponsesUpdate
{ request = request
, response =
response |> Result.mapError (\_ -> ())
, response = Result.mapError (\_ -> ()) response
}
model
Err error ->
{ model
| errors =
List.append
model.errors
++ [ { title = "Static HTTP Error"
[ { title = "Static HTTP Error"
, message =
[ Terminal.text "I got an error making an HTTP request to this URL: "
@ -500,8 +515,7 @@ update siteMetadata config msg model =
|> staticResponsesUpdate
-- TODO for hash pass in RequestDetails here
{ request = request
, response =
response |> Result.mapError (\_ -> ())
, response = Result.mapError (\_ -> ()) response
}
( updatedAllRawResponses, effect ) =
@ -523,10 +537,9 @@ performStaticHttpRequests allRawResponses secrets staticRequests =
staticRequests
|> List.map
(\( pagePath, request ) ->
StaticHttpRequest.resolveUrls request
(allRawResponses
allRawResponses
|> dictCompact
)
|> StaticHttpRequest.resolveUrls ApplicationType.Cli request
|> Tuple.second
)
|> List.concat
@ -535,10 +548,13 @@ performStaticHttpRequests allRawResponses secrets staticRequests =
-- |> Set.toList
|> List.map
(\urlBuilder ->
Secrets.lookup secrets urlBuilder
urlBuilder
|> Secrets.lookup secrets
|> Result.map
(\unmasked ->
{ unmasked = unmasked, masked = Secrets.maskedLookup urlBuilder }
{ unmasked = unmasked
, masked = Secrets.maskedLookup urlBuilder
}
)
)
|> combineMultipleErrors
@ -570,15 +586,84 @@ combineMultipleErrors results =
results
staticResponsesInit : List ( PagePath pathKey, StaticHttp.Request value ) -> StaticResponses
staticResponsesInit list =
cliDictKey : String
cliDictKey =
"////elm-pages-CLI////"
staticResponsesInit : Dict String (Maybe String) -> Result (List BuildError) (List ( PagePath pathKey, metadata )) -> Config pathKey userMsg userModel metadata view -> List ( PagePath pathKey, StaticHttp.Request value ) -> StaticResponses
staticResponsesInit staticHttpCache siteMetadataResult config list =
let
generateFilesRequest : StaticHttp.Request (List (Result String { path : List String, content : String }))
generateFilesRequest =
config.generateFiles siteMetadataWithContent
generateFilesStaticRequest =
( -- we don't want to include the CLI-only StaticHttp responses in the production bundle
-- since that data is only needed to run these functions during the build step
-- in the future, this could be refactored to have a type to represent this more clearly
cliDictKey
, NotFetched (generateFilesRequest |> StaticHttp.map (\_ -> ())) Dict.empty
)
siteMetadataWithContent =
siteMetadataResult
|> Result.withDefault []
|> List.map
(\( pagePath, metadata ) ->
let
contentForPage =
config.content
|> List.filterMap
(\( path, { body } ) ->
let
pagePathToGenerate =
PagePath.toString pagePath
currentContentPath =
"/" ++ (path |> String.join "/")
in
if pagePathToGenerate == currentContentPath then
Just body
else
Nothing
)
|> List.head
|> Maybe.andThen identity
in
{ path = pagePath
, frontmatter = metadata
, body = contentForPage |> Maybe.withDefault ""
}
)
in
list
|> List.map
(\( path, staticRequest ) ->
let
entry =
NotFetched (staticRequest |> StaticHttp.map (\_ -> ())) Dict.empty
updatedEntry =
staticHttpCache
|> dictCompact
|> Dict.toList
|> List.foldl
(\( hashedRequest, response ) entrySoFar ->
entrySoFar
|> addEntry
staticHttpCache
hashedRequest
(Ok response)
)
entry
in
( PagePath.toString path
, NotFetched (staticRequest |> StaticHttp.map (\_ -> ())) Dict.empty
, updatedEntry
)
)
|> List.append [ generateFilesStaticRequest ]
|> Dict.fromList
@ -586,44 +671,80 @@ staticResponsesUpdate : { request : { masked : RequestDetails, unmasked : Reques
staticResponsesUpdate newEntry model =
let
updatedAllResponses =
model.allRawResponses
-- @@@@@@@@@ TODO handle errors here, change Dict to have `Result` instead of `Maybe`
|> Dict.insert (HashRequest.hash newEntry.request.masked) (Just (newEntry.response |> Result.withDefault "TODO"))
Dict.insert
(HashRequest.hash newEntry.request.masked)
(Just <| Result.withDefault "TODO" newEntry.response)
model.allRawResponses
in
{ model
| allRawResponses = updatedAllResponses
, staticResponses =
model.staticResponses
|> Dict.map
Dict.map
(\pageUrl entry ->
case entry of
NotFetched request rawResponses ->
let
realUrls =
StaticHttpRequest.resolveUrls request
(updatedAllResponses |> dictCompact)
updatedAllResponses
|> dictCompact
|> StaticHttpRequest.resolveUrls ApplicationType.Cli request
|> Tuple.second
|> List.map Secrets.maskedLookup
|> List.map HashRequest.hash
includesUrl =
List.member (HashRequest.hash newEntry.request.masked)
List.member
(HashRequest.hash newEntry.request.masked)
realUrls
in
if includesUrl then
let
updatedRawResponses =
Dict.insert
(HashRequest.hash newEntry.request.masked)
newEntry.response
rawResponses
|> Dict.insert (HashRequest.hash newEntry.request.masked) newEntry.response
in
NotFetched request updatedRawResponses
else
entry
)
model.staticResponses
}
addEntry : Dict String (Maybe String) -> String -> Result () String -> StaticHttpResult -> StaticHttpResult
addEntry globalRawResponses hashedRequest rawResponse ((NotFetched request rawResponses) as entry) =
let
realUrls =
globalRawResponses
|> dictCompact
|> StaticHttpRequest.resolveUrls ApplicationType.Cli request
|> Tuple.second
|> List.map Secrets.maskedLookup
|> List.map HashRequest.hash
includesUrl =
List.member
hashedRequest
realUrls
in
if includesUrl then
let
updatedRawResponses =
Dict.insert
hashedRequest
rawResponse
rawResponses
in
NotFetched request updatedRawResponses
else
entry
isJust : Maybe a -> Bool
isJust maybeValue =
case maybeValue of
@ -654,20 +775,21 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
let
usableRawResponses : Dict String String
usableRawResponses =
rawResponses
|> Dict.Extra.filterMap
Dict.Extra.filterMap
(\key value ->
value
|> Result.map Just
|> Result.withDefault Nothing
)
rawResponses
hasPermanentError =
StaticHttpRequest.permanentError request usableRawResponses
usableRawResponses
|> StaticHttpRequest.permanentError ApplicationType.Cli request
|> isJust
hasPermanentHttpError =
not <| List.isEmpty errors
not (List.isEmpty errors)
--|> List.any
-- (\error ->
@ -679,7 +801,9 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
-- False
-- )
( allUrlsKnown, knownUrlsToFetch ) =
StaticHttpRequest.resolveUrls request
StaticHttpRequest.resolveUrls
ApplicationType.Cli
request
(rawResponses |> Dict.map (\key value -> value |> Result.withDefault ""))
fetchedAllKnownUrls =
@ -715,7 +839,9 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
)
maybePermanentError =
StaticHttpRequest.permanentError request
StaticHttpRequest.permanentError
ApplicationType.Cli
request
usableRawResponses
decoderErrors =
@ -795,7 +921,7 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
updatedAllRawResponses =
Dict.empty
generatedFiles =
metadataForGenerateFiles =
siteMetadata
|> Result.withDefault []
|> List.map
@ -810,7 +936,7 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
PagePath.toString pagePath
currentContentPath =
"/" ++ (path |> String.join "/")
String.join "/" path
in
if pagePathToGenerate == currentContentPath then
Just body
@ -826,8 +952,20 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
, body = contentForPage |> Maybe.withDefault ""
}
)
|> config.generateFiles
--generatedFiles : StaticHttp.Request (List (Result String { path : List String, content : String }))
--generatedFiles : List (Result String { path : List String, content : String })
generatedFiles =
mythingy2
|> Result.withDefault []
mythingy2 : Result StaticHttpRequest.Error (List (Result String { path : List String, content : String }))
mythingy2 =
StaticHttpRequest.resolve ApplicationType.Cli
(config.generateFiles metadataForGenerateFiles)
(allRawResponses |> Dict.Extra.filterMap (\key value -> value))
generatedOkayFiles : List { path : List String, content : String }
generatedOkayFiles =
generatedFiles
|> List.filterMap
@ -840,6 +978,7 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
Nothing
)
generatedFileErrors : List { title : String, message : List Terminal.Text, fatal : Bool }
generatedFileErrors =
generatedFiles
|> List.filterMap
@ -868,11 +1007,19 @@ sendStaticResponsesIfDone config siteMetadata mode secrets allRawResponses error
(encodeStaticResponses mode staticResponses)
config.manifest
generatedOkayFiles
allRawResponses
allErrors
)
toJsPayload encodedStatic manifest generated allErrors =
toJsPayload :
Dict String (Dict String String)
-> Manifest.Config pathKey
-> List FileToGenerate
-> Dict String (Maybe String)
-> List { title : String, message : List Terminal.Text, fatal : Bool }
-> Effect pathKey
toJsPayload encodedStatic manifest generated allRawResponses allErrors =
SendJsData <|
if allErrors |> List.filter .fatal |> List.isEmpty then
Success
@ -880,6 +1027,15 @@ toJsPayload encodedStatic manifest generated allErrors =
encodedStatic
manifest
generated
(allRawResponses
|> Dict.toList
|> List.filterMap
(\( key, maybeValue ) ->
maybeValue
|> Maybe.map (\value -> ( key, value ))
)
|> Dict.fromList
)
(List.map BuildError.errorToString allErrors)
)
@ -890,24 +1046,27 @@ toJsPayload encodedStatic manifest generated allErrors =
encodeStaticResponses : Mode -> StaticResponses -> Dict String (Dict String String)
encodeStaticResponses mode staticResponses =
staticResponses
|> Dict.filter
(\key value ->
key /= cliDictKey
)
|> Dict.map
(\path result ->
case result of
NotFetched request rawResponsesDict ->
let
relevantResponses =
rawResponsesDict
|> Dict.map
(\key value ->
value
Dict.map
(\_ ->
-- TODO avoid running this code at all if there are errors here
|> Result.withDefault ""
Result.withDefault ""
)
rawResponsesDict
strippedResponses : Dict String String
strippedResponses =
-- TODO should this return an Err and handle that here?
StaticHttpRequest.strippedResponses request relevantResponses
StaticHttpRequest.strippedResponses ApplicationType.Cli request relevantResponses
in
case mode of
Dev ->

View File

@ -0,0 +1,43 @@
module Pages.Internal.String exposing (..)
{-| Remove a piece from the beginning of a string until it's not there anymore.
>>> chopStart "{" "{{{<-"
"<-"
-}
chopStart : String -> String -> String
chopStart needle string =
if String.startsWith needle string then
string
|> String.dropLeft (String.length needle)
|> chopStart needle
else
string
{-| Remove a piece from the end of a string until it's not there anymore.
>>> chopEnd "}" "->}}}"
"->"
-}
chopEnd : String -> String -> String
chopEnd needle string =
if String.endsWith needle string then
string
|> String.dropRight (String.length needle)
|> chopEnd needle
else
string
{-| Removes `/` characters from both ends of a string.
-}
chopForwardSlashes : String -> String
chopForwardSlashes =
chopStart "/" >> chopEnd "/"

View File

@ -259,7 +259,7 @@ toJson config =
)
, ( "serviceworker"
, Encode.object
[ ( "src", Encode.string "/service-worker.js" )
[ ( "src", Encode.string "../service-worker.js" )
, ( "scope", Encode.string "/" )
, ( "type", Encode.string "" )
, ( "update_via_cache", Encode.string "none" )

View File

@ -43,7 +43,7 @@ This gives you a record, based on your local `content` directory, that lets you
Pages.pages.index
-- PagePath.toString homePath
-- => "/"
-- => ""
or
@ -55,7 +55,7 @@ or
Pages.pages.blog.helloWorld
-- PagePath.toString helloWorldPostPath
-- => "/blog/hello-world"
-- => "blog/hello-world"
Note that in the `hello-world` example it changes from the kebab casing of the actual
URL to camelCasing for the record key.
@ -92,7 +92,7 @@ type PagePath key
| External String
{-| Gives you the page's absolute URL as a String. This is useful for constructing links:
{-| Gives you the page's relative URL as a String. This is useful for constructing links:
import Html exposing (Html, a)
import Html.Attributes exposing (href)
@ -114,8 +114,7 @@ toString : PagePath key -> String
toString path =
case path of
Internal rawPath ->
"/"
++ (rawPath |> String.join "/")
String.join "/" rawPath
External url ->
url

View File

@ -90,12 +90,14 @@ application :
, body : String
}
->
List
StaticHttp.Request
(List
(Result String
{ path : List String
, content : String
}
)
)
, onPageChange :
{ path : PagePath pathKey
, query : Maybe String
@ -123,6 +125,7 @@ application config =
, content = config.internals.content
, generateFiles = config.generateFiles
, toJsPort = config.internals.toJsPort
, fromJsPort = config.internals.fromJsPort
, manifest = config.manifest
, canonicalSiteUrl = config.canonicalSiteUrl
, onPageChange = config.onPageChange

View File

@ -1,7 +1,7 @@
module Pages.StaticHttp exposing
( Request, RequestDetails
, get, request
, map, succeed
, map, succeed, fail
, Body, emptyBody, stringBody, jsonBody
, andThen, resolve, combine
, map2, map3, map4, map5, map6, map7, map8, map9
@ -15,7 +15,7 @@ The key differences are:
- `StaticHttp.Request`s are performed once at build time (`Http.Request`s are performed at runtime, at whenever point you perform them)
- `StaticHttp.Request`s strip out unused JSON data from the data your decoder doesn't touch to minimize the JSON payload
- `StaticHttp.Request`s can use [`Pages.Secrets`](Pages.Secrets) to securely use credentials from your environemnt variables which are completely masked in the production assets.
- `StaticHttp.Request`s can use [`Pages.Secrets`](Pages.Secrets) to securely use credentials from your environment variables which are completely masked in the production assets.
- `StaticHttp.Request`s have a built-in `StaticHttp.andThen` that allows you to perform follow-up requests without using tasks
@ -40,7 +40,7 @@ in [this article introducing StaticHttp requests and some concepts around it](ht
@docs Request, RequestDetails
@docs get, request
@docs map, succeed
@docs map, succeed, fail
## Building a StaticHttp Request Body
@ -76,9 +76,12 @@ your decoders. This can significantly reduce download sizes for your StaticHttp
import Dict exposing (Dict)
import Dict.Extra
import Internal.OptimizedDecoder
import Json.Decode
import Json.Decode.Exploration as Decode exposing (Decoder)
import Json.Decode.Exploration
import Json.Encode as Encode
import OptimizedDecoder as Decode exposing (Decoder)
import Pages.Internal.ApplicationType as ApplicationType exposing (ApplicationType)
import Pages.Internal.StaticHttpBody as Body
import Pages.Secrets
import Pages.StaticHttp.Request as HashRequest
@ -155,8 +158,8 @@ map fn requestInfo =
Request ( urls, lookupFn ) ->
Request
( urls
, \rawResponses ->
lookupFn rawResponses
, \appType rawResponses ->
lookupFn appType rawResponses
|> Result.map (\( partiallyStripped, nextRequest ) -> ( partiallyStripped, map fn nextRequest ))
)
@ -167,10 +170,8 @@ map fn requestInfo =
{-| Helper to remove an inner layer of Request wrapping.
-}
resolve : Request (List (Request value)) -> Request (List value)
resolve topRequest =
topRequest
|> andThen
(\continuationRequests -> combine continuationRequests)
resolve =
andThen combine
{-| Turn a list of `StaticHttp.Request`s into a single one.
@ -208,9 +209,8 @@ resolve topRequest =
-}
combine : List (Request value) -> Request (List value)
combine requests =
requests
|> List.foldl (map2 (::)) (succeed [])
combine =
List.foldl (map2 (::)) (succeed [])
{-| Like map, but it takes in two `Request`s.
@ -241,24 +241,24 @@ map2 fn request1 request2 =
case ( request1, request2 ) of
( Request ( urls1, lookupFn1 ), Request ( urls2, lookupFn2 ) ) ->
let
value : Dict String String -> Result Pages.StaticHttpRequest.Error ( Dict String String, Request c )
value rawResponses =
value : ApplicationType -> Dict String String -> Result Pages.StaticHttpRequest.Error ( Dict String String, Request c )
value appType rawResponses =
let
value1 =
lookupFn1 rawResponses
lookupFn1 appType rawResponses
|> Result.map Tuple.second
value2 =
lookupFn2 rawResponses
lookupFn2 appType rawResponses
|> Result.map Tuple.second
dict1 =
lookupFn1 rawResponses
lookupFn1 appType rawResponses
|> Result.map Tuple.first
|> Result.withDefault Dict.empty
dict2 =
lookupFn2 rawResponses
lookupFn2 appType rawResponses
|> Result.map Tuple.first
|> Result.withDefault Dict.empty
in
@ -277,14 +277,14 @@ map2 fn request1 request2 =
( Request ( urls1, lookupFn1 ), Done value2 ) ->
Request
( urls1
, \rawResponses ->
, \appType rawResponses ->
let
value1 =
lookupFn1 rawResponses
lookupFn1 appType rawResponses
|> Result.map Tuple.second
dict1 =
lookupFn1 rawResponses
lookupFn1 appType rawResponses
|> Result.map Tuple.first
|> Result.withDefault Dict.empty
in
@ -299,14 +299,14 @@ map2 fn request1 request2 =
( Done value2, Request ( urls1, lookupFn1 ) ) ->
Request
( urls1
, \rawResponses ->
, \appType rawResponses ->
let
value1 =
lookupFn1 rawResponses
lookupFn1 appType rawResponses
|> Result.map Tuple.second
dict1 =
lookupFn1 rawResponses
lookupFn1 appType rawResponses
|> Result.map Tuple.first
|> Result.withDefault Dict.empty
in
@ -339,14 +339,14 @@ combineReducedDicts dict1 dict2 =
)
lookup : Pages.StaticHttpRequest.Request value -> Dict String String -> Result Pages.StaticHttpRequest.Error ( Dict String String, value )
lookup requestInfo rawResponses =
lookup : ApplicationType -> Pages.StaticHttpRequest.Request value -> Dict String String -> Result Pages.StaticHttpRequest.Error ( Dict String String, value )
lookup appType requestInfo rawResponses =
case requestInfo of
Request ( urls, lookupFn ) ->
lookupFn rawResponses
lookupFn appType rawResponses
|> Result.andThen
(\( strippedResponses, nextRequest ) ->
lookup
lookup appType
(addUrls urls nextRequest)
strippedResponses
)
@ -396,8 +396,8 @@ andThen : (a -> Request b) -> Request a -> Request b
andThen fn requestInfo =
Request
( lookupUrls requestInfo
, \rawResponses ->
lookup
, \appType rawResponses ->
lookup appType
requestInfo
rawResponses
|> (\result ->
@ -439,11 +439,22 @@ succeed : a -> Request a
succeed value =
Request
( []
, \rawResponses ->
, \appType rawResponses ->
Ok ( rawResponses, Done value )
)
{-| TODO
-}
fail : String -> Request a
fail errorMessage =
Request
( []
, \appType rawResponses ->
Err (Pages.StaticHttpRequest.UserCalledStaticHttpFail errorMessage)
)
{-| A simplified helper around [`StaticHttp.request`](#request), which builds up a StaticHttp GET request.
import Json.Decode as Decode exposing (Decoder)
@ -462,8 +473,7 @@ get :
-> Request a
get url decoder =
request
(url
|> Secrets.map
(Secrets.map
(\okUrl ->
{ url = okUrl
, method = "GET"
@ -471,6 +481,7 @@ get url decoder =
, body = emptyBody
}
)
url
)
decoder
@ -578,7 +589,9 @@ unoptimizedRequest requestWithSecrets expect =
ExpectJson decoder ->
Request
( [ requestWithSecrets ]
, \rawResponseDict ->
, \appType rawResponseDict ->
case appType of
ApplicationType.Cli ->
rawResponseDict
|> Dict.get (Secrets.maskedLookup requestWithSecrets |> HashRequest.hash)
|> (\maybeResponse ->
@ -586,7 +599,6 @@ unoptimizedRequest requestWithSecrets expect =
Just rawResponse ->
Ok
( rawResponseDict
-- |> Dict.update url (\maybeValue -> Just """{"fake": 123}""")
, rawResponse
)
@ -600,31 +612,28 @@ unoptimizedRequest requestWithSecrets expect =
(\( strippedResponses, rawResponse ) ->
let
reduced =
Decode.stripString decoder rawResponse
Json.Decode.Exploration.stripString (Internal.OptimizedDecoder.jde decoder) rawResponse
|> Result.withDefault "TODO"
in
rawResponse
|> Decode.decodeString decoder
-- |> Result.mapError Json.Decode.Exploration.errorsToString
|> Json.Decode.Exploration.decodeString (decoder |> Internal.OptimizedDecoder.jde)
|> (\decodeResult ->
case decodeResult of
Decode.BadJson ->
Json.Decode.Exploration.BadJson ->
Pages.StaticHttpRequest.DecoderError "Payload sent back invalid JSON" |> Err
Decode.Errors errors ->
Json.Decode.Exploration.Errors errors ->
errors
|> Decode.errorsToString
|> Json.Decode.Exploration.errorsToString
|> Pages.StaticHttpRequest.DecoderError
|> Err
Decode.WithWarnings warnings a ->
-- Pages.StaticHttpRequest.DecoderError "" |> Err
Json.Decode.Exploration.WithWarnings warnings a ->
Ok a
Decode.Success a ->
Json.Decode.Exploration.Success a ->
Ok a
)
-- |> Result.mapError Pages.StaticHttpRequest.DecoderError
|> Result.map Done
|> Result.map
(\finalRequest ->
@ -636,12 +645,48 @@ unoptimizedRequest requestWithSecrets expect =
)
)
)
ApplicationType.Browser ->
rawResponseDict
|> Dict.get (Secrets.maskedLookup requestWithSecrets |> HashRequest.hash)
|> (\maybeResponse ->
case maybeResponse of
Just rawResponse ->
Ok
( rawResponseDict
, rawResponse
)
Nothing ->
Secrets.maskedLookup requestWithSecrets
|> requestToString
|> Pages.StaticHttpRequest.MissingHttpResponse
|> Err
)
|> Result.andThen
(\( strippedResponses, rawResponse ) ->
rawResponse
|> Json.Decode.decodeString (decoder |> Internal.OptimizedDecoder.jd)
|> (\decodeResult ->
case decodeResult of
Err _ ->
Pages.StaticHttpRequest.DecoderError "Payload sent back invalid JSON" |> Err
Ok a ->
Ok a
)
|> Result.map Done
|> Result.map
(\finalRequest ->
( strippedResponses, finalRequest )
)
)
)
ExpectUnoptimizedJson decoder ->
Request
( [ requestWithSecrets ]
, \rawResponseDict ->
, \appType rawResponseDict ->
rawResponseDict
|> Dict.get (Secrets.maskedLookup requestWithSecrets |> HashRequest.hash)
|> (\maybeResponse ->
@ -667,7 +712,10 @@ unoptimizedRequest requestWithSecrets expect =
|> (\decodeResult ->
case decodeResult of
Err error ->
Pages.StaticHttpRequest.DecoderError "Payload sent back invalid JSON" |> Err
error
|> Decode.errorToString
|> Pages.StaticHttpRequest.DecoderError
|> Err
Ok a ->
Ok a
@ -688,7 +736,7 @@ unoptimizedRequest requestWithSecrets expect =
ExpectString mapStringFn ->
Request
( [ requestWithSecrets ]
, \rawResponseDict ->
, \appType rawResponseDict ->
rawResponseDict
|> Dict.get (Secrets.maskedLookup requestWithSecrets |> HashRequest.hash)
|> (\maybeResponse ->

View File

@ -2,26 +2,27 @@ module Pages.StaticHttpRequest exposing (Error(..), Request(..), permanentError,
import BuildError exposing (BuildError)
import Dict exposing (Dict)
import Pages.Internal.ApplicationType as ApplicationType exposing (ApplicationType)
import Pages.StaticHttp.Request
import Secrets
import TerminalText as Terminal
type Request value
= Request ( List (Secrets.Value Pages.StaticHttp.Request.Request), Dict String String -> Result Error ( Dict String String, Request value ) )
= Request ( List (Secrets.Value Pages.StaticHttp.Request.Request), ApplicationType -> Dict String String -> Result Error ( Dict String String, Request value ) )
| Done value
strippedResponses : Request value -> Dict String String -> Dict String String
strippedResponses request rawResponses =
strippedResponses : ApplicationType -> Request value -> Dict String String -> Dict String String
strippedResponses appType request rawResponses =
case request of
Request ( list, lookupFn ) ->
case lookupFn rawResponses of
case lookupFn appType rawResponses of
Err error ->
rawResponses
Ok ( partiallyStrippedResponses, followupRequest ) ->
strippedResponses followupRequest partiallyStrippedResponses
strippedResponses appType followupRequest partiallyStrippedResponses
Done value ->
rawResponses
@ -30,6 +31,7 @@ strippedResponses request rawResponses =
type Error
= MissingHttpResponse String
| DecoderError String
| UserCalledStaticHttpFail String
urls : Request value -> List (Secrets.Value Pages.StaticHttp.Request.Request)
@ -65,14 +67,24 @@ toBuildError path error =
, fatal = True
}
UserCalledStaticHttpFail decodeErrorMessage ->
{ title = "Called Static Http Fail"
, message =
[ Terminal.text path
, Terminal.text "\n\n"
, Terminal.text <| "I ran into a call to `Pages.StaticHttp.fail` with message: " ++ decodeErrorMessage
]
, fatal = True
}
permanentError : Request value -> Dict String String -> Maybe Error
permanentError request rawResponses =
permanentError : ApplicationType -> Request value -> Dict String String -> Maybe Error
permanentError appType request rawResponses =
case request of
Request ( urlList, lookupFn ) ->
case lookupFn rawResponses of
case lookupFn appType rawResponses of
Ok ( partiallyStrippedResponses, nextRequest ) ->
permanentError nextRequest rawResponses
permanentError appType nextRequest rawResponses
Err error ->
case error of
@ -82,17 +94,20 @@ permanentError request rawResponses =
DecoderError _ ->
Just error
UserCalledStaticHttpFail string ->
Just error
Done value ->
Nothing
resolve : Request value -> Dict String String -> Result Error value
resolve request rawResponses =
resolve : ApplicationType -> Request value -> Dict String String -> Result Error value
resolve appType request rawResponses =
case request of
Request ( urlList, lookupFn ) ->
case lookupFn rawResponses of
case lookupFn appType rawResponses of
Ok ( partiallyStrippedResponses, nextRequest ) ->
resolve nextRequest rawResponses
resolve appType nextRequest rawResponses
Err error ->
Err error
@ -101,13 +116,13 @@ resolve request rawResponses =
Ok value
resolveUrls : Request value -> Dict String String -> ( Bool, List (Secrets.Value Pages.StaticHttp.Request.Request) )
resolveUrls request rawResponses =
resolveUrls : ApplicationType -> Request value -> Dict String String -> ( Bool, List (Secrets.Value Pages.StaticHttp.Request.Request) )
resolveUrls appType request rawResponses =
case request of
Request ( urlList, lookupFn ) ->
case lookupFn rawResponses of
case lookupFn appType rawResponses of
Ok ( partiallyStrippedResponses, nextRequest ) ->
resolveUrls nextRequest rawResponses
resolveUrls appType nextRequest rawResponses
|> Tuple.mapSecond ((++) urlList)
Err error ->

234
src/StructuredData.elm Normal file
View File

@ -0,0 +1,234 @@
module StructuredData exposing (..)
import Json.Encode as Encode
{-| <https://schema.org/SoftwareSourceCode>
-}
softwareSourceCode :
{ codeRepositoryUrl : String
, description : String
, author : String
, programmingLanguage : Encode.Value
}
-> Encode.Value
softwareSourceCode info =
Encode.object
[ ( "@type", Encode.string "SoftwareSourceCode" )
, ( "codeRepository", Encode.string info.codeRepositoryUrl )
, ( "description", Encode.string info.description )
, ( "author", Encode.string info.author )
, ( "programmingLanguage", info.programmingLanguage )
]
{-| <https://schema.org/ComputerLanguage>
-}
computerLanguage : { url : String, name : String, imageUrl : String, identifier : String } -> Encode.Value
computerLanguage info =
Encode.object
[ ( "@type", Encode.string "ComputerLanguage" )
, ( "url", Encode.string info.url )
, ( "name", Encode.string info.name )
, ( "image", Encode.string info.imageUrl )
, ( "identifier", Encode.string info.identifier )
]
elmLang : Encode.Value
elmLang =
computerLanguage
{ url = "http://elm-lang.org/"
, name = "Elm"
, imageUrl = "http://elm-lang.org/"
, identifier = "http://elm-lang.org/"
}
{-| <https://schema.org/Article>
-}
article :
{ title : String
, description : String
, author : StructuredData { authorMemberOf | personOrOrganization : () } authorPossibleFields
, publisher : StructuredData { publisherMemberOf | personOrOrganization : () } publisherPossibleFields
, url : String
, imageUrl : String
, datePublished : String
, mainEntityOfPage : Encode.Value
}
-> Encode.Value
article info =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "Article" )
, ( "headline", Encode.string info.title )
, ( "description", Encode.string info.description )
, ( "image", Encode.string info.imageUrl )
, ( "author", encode info.author )
, ( "publisher", encode info.publisher )
, ( "url", Encode.string info.url )
, ( "datePublished", Encode.string info.datePublished )
, ( "mainEntityOfPage", info.mainEntityOfPage )
]
type StructuredData memberOf possibleFields
= StructuredData String (List ( String, Encode.Value ))
{-| <https://schema.org/Person>
-}
person :
{ name : String
}
->
StructuredData { personOrOrganization : () }
{ additionalName : ()
, address : ()
, affiliation : ()
}
person info =
StructuredData "Person" [ ( "name", Encode.string info.name ) ]
additionalName : String -> StructuredData memberOf { possibleFields | additionalName : () } -> StructuredData memberOf possibleFields
additionalName value (StructuredData typeName fields) =
StructuredData typeName (( "additionalName", Encode.string value ) :: fields)
{-| <https://schema.org/Article>
-}
article_ :
{ title : String
, description : String
, author : String
, publisher : StructuredData { personOrOrganization : () } possibleFieldsPublisher
, url : String
, imageUrl : String
, datePublished : String
, mainEntityOfPage : Encode.Value
}
-> Encode.Value
article_ info =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "Article" )
, ( "headline", Encode.string info.title )
, ( "description", Encode.string info.description )
, ( "image", Encode.string info.imageUrl )
, ( "author", Encode.string info.author )
, ( "publisher", encode info.publisher )
, ( "url", Encode.string info.url )
, ( "datePublished", Encode.string info.datePublished )
, ( "mainEntityOfPage", info.mainEntityOfPage )
]
encode : StructuredData memberOf possibleFieldsPublisher -> Encode.Value
encode (StructuredData typeName fields) =
Encode.object
(( "@type", Encode.string typeName ) :: fields)
--example : StructuredData { personOrOrganization : () } { address : (), affiliation : () }
example =
person { name = "Dillon Kearns" }
|> additionalName "Cornelius"
--organization :
-- {}
-- -> StructuredData { personOrOrganization : () }
--organization info =
-- StructuredData "Organization" []
--needsPersonOrOrg : StructuredData {}
--needsPersonOrOrg =
-- StructuredData "" []
{-|
```json
{
"@context": "http://schema.org/",
"@type": "PodcastSeries",
"image": "https://www.relay.fm/inquisitive_artwork.png",
"url": "http://www.relay.fm/inquisitive",
"name": "Inquisitive",
"description": "Inquisitive is a show for the naturally curious. Each week, Myke Hurley takes a look at what makes creative people successful and what steps they have taken to get there.",
"webFeed": "http://www.relay.fm//inquisitive/feed",
"author": {
"@type": "Person",
"name": "Myke Hurley"
}
}
}
```
-}
series : Encode.Value
series =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "PodcastSeries" )
, ( "image", Encode.string "TODO" )
, ( "url", Encode.string "http://elm-radio.com/episode/getting-started-with-elm-pages" )
, ( "name", Encode.string "Elm Radio" )
, ( "description", Encode.string "TODO" )
, ( "webFeed", Encode.string "https://elm-radio.com/feed.xml" )
]
{-|
```json
{
"@context": "http://schema.org/",
"@type": "PodcastEpisode",
"url": "http://elm-radio.com/episode/getting-started-with-elm-pages",
"name": "001: Getting Started with elm-pages",
"datePublished": "2015-02-18",
"timeRequired": "PT37M",
"description": "In the first episode of Behind the App, a special series of Inquisitive, we take a look at the beginnings of iOS app development, by focusing on the introduction of the iPhone and the App Store.",
"associatedMedia": {
"@type": "MediaObject",
"contentUrl": "https://cdn.simplecast.com/audio/6a206b/6a206baa-9c8e-4c25-9037-2b674204ba84/ca009f6e-1710-4518-b869-ca34cb0b7d17/001-getting-started-elm-pages_tc.mp3 "
},
"partOfSeries": {
"@type": "PodcastSeries",
"name": "Elm Radio",
"url": "https://elm-radio.com"
}
}
```
-}
episode : Encode.Value
episode =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "PodcastEpisode" )
, ( "url", Encode.string "http://elm-radio.com/episode/getting-started-with-elm-pages" )
, ( "name", Encode.string "Getting Started with elm-pages" )
, ( "datePublished", Encode.string "2015-02-18" )
, ( "timeRequired", Encode.string "PT37M" )
, ( "description", Encode.string "TODO" )
, ( "associatedMedia"
, Encode.object
[ ( "@type", Encode.string "MediaObject" )
, ( "contentUrl", Encode.string "https://cdn.simplecast.com/audio/6a206b/6a206baa-9c8e-4c25-9037-2b674204ba84/ca009f6e-1710-4518-b869-ca34cb0b7d17/001-getting-started-elm-pages_tc.mp3" )
]
)
, ( "partOfSeries"
, Encode.object
[ ( "@type", Encode.string "PodcastSeries" )
, ( "name", Encode.string "Elm Radio" )
, ( "url", Encode.string "https://elm-radio.com" )
]
)
]

View File

@ -68,10 +68,10 @@ colorToString color =
"[34m"
Green ->
"[32;1m"
"[32m"
Yellow ->
"[33;1m"
"[33m"
Cyan ->
"[36m"

View File

@ -5,7 +5,9 @@ import Dict exposing (Dict)
import Expect
import Html
import Json.Decode as JD
import Json.Decode.Exploration as Decode exposing (Decoder)
import Json.Decode.Exploration
import Json.Encode as Encode
import OptimizedDecoder as Decode exposing (Decoder)
import Pages.ContentCache as ContentCache
import Pages.Document as Document
import Pages.Http
@ -42,7 +44,7 @@ all =
"https://api.github.com/repos/dillonkearns/elm-pages"
"""{ "stargazer_count": 86 }"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":86}"""
)
@ -69,7 +71,7 @@ all =
"NEXT-REQUEST"
"""null"""
|> expectSuccess
[ ( "/elm-pages"
[ ( "elm-pages"
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """null"""
)
@ -162,7 +164,7 @@ all =
"url10"
"""{"image": "image10.jpg"}"""
|> expectSuccess
[ ( "/elm-pages"
[ ( "elm-pages"
, [ ( get "https://pokeapi.co/api/v2/pokemon/"
, """[{"url":"url1"},{"url":"url2"},{"url":"url3"},{"url":"url4"},{"url":"url5"},{"url":"url6"},{"url":"url7"},{"url":"url8"},{"url":"url9"},{"url":"url10"}]"""
)
@ -218,13 +220,13 @@ all =
"https://api.github.com/repos/dillonkearns/elm-pages-starter"
"""{ "stargazer_count": 22 }"""
|> expectSuccess
[ ( "/elm-pages"
[ ( "elm-pages"
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":86}"""
)
]
)
, ( "/elm-pages-starter"
, ( "elm-pages-starter"
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages-starter"
, """{"stargazer_count":22}"""
)
@ -243,7 +245,7 @@ all =
"https://api.github.com/repos/dillonkearns/elm-pages"
"""{ "stargazer_count": 86, "unused_field": 123 }"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":86}"""
)
@ -272,7 +274,7 @@ all =
"https://api.github.com/repos/dillonkearns/elm-pages"
"""{ "stargazer_count": 86, "unused_field": 123 }"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{ "stargazer_count": 86, "unused_field": 123 }"""
)
@ -299,7 +301,7 @@ all =
"https://example.com/file.txt"
"This is a raw text file."
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "https://example.com/file.txt"
, "This is a raw text file."
)
@ -339,7 +341,7 @@ all =
(expectErrorsPort
"""-- STATIC HTTP DECODING ERROR ----------------------------------------------------- elm-pages
/
String was not uppercased"""
)
@ -363,7 +365,7 @@ String was not uppercased"""
"https://api.github.com/repos/dillonkearns/elm-pages"
"""{ "stargazer_count": 86, "unused_field": 123 }"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( { method = "POST"
, url = "https://api.github.com/repos/dillonkearns/elm-pages"
, headers = []
@ -394,7 +396,7 @@ String was not uppercased"""
"https://api.github.com/repos/dillonkearns/elm-pages-starter"
"""{ "stargazer_count": 50, "unused_field": 456 }"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":100}"""
)
@ -422,7 +424,7 @@ String was not uppercased"""
"https://api.github.com/repos/dillonkearns/elm-pages-starter"
"""{ "stargazer_count": 50, "unused_field": 456 }"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":100}"""
)
@ -439,7 +441,7 @@ String was not uppercased"""
, StaticHttp.succeed ()
)
]
|> expectSuccess [ ( "/", [] ) ]
|> expectSuccess [ ( "", [] ) ]
, test "the port sends out when there are duplicate http requests for the same page" <|
\() ->
start
@ -454,7 +456,7 @@ String was not uppercased"""
"http://example.com"
"""null"""
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( get "http://example.com"
, """null"""
)
@ -478,7 +480,7 @@ String was not uppercased"""
(expectErrorsPort
"""-- STATIC HTTP DECODING ERROR ----------------------------------------------------- elm-pages
/elm-pages
elm-pages
I encountered some errors while decoding this JSON:
@ -591,7 +593,7 @@ Body: """)
}
)
|> expectSuccess
[ ( "/"
[ ( ""
, [ ( { method = "GET"
, url = "https://api.github.com/repos/dillonkearns/elm-pages?apiKey=<API_KEY>"
, headers =
@ -604,11 +606,68 @@ Body: """)
]
)
]
, describe "staticHttpCache"
[ test "it doesn't perform http requests that are provided in the http cache flag" <|
\() ->
startWithHttpCache
[ ( { url = "https://api.github.com/repos/dillonkearns/elm-pages"
, method = "GET"
, headers = []
, body = StaticHttpBody.EmptyBody
}
, """{"stargazer_count":86}"""
)
]
[ ( []
, StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-pages") starDecoder
)
]
|> expectSuccess
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":86}"""
)
]
)
]
, test "it ignores unused cache" <|
\() ->
startWithHttpCache
[ ( { url = "https://this-is-never-used.example.com/"
, method = "GET"
, headers = []
, body = StaticHttpBody.EmptyBody
}
, """{"stargazer_count":86}"""
)
]
[ ( []
, StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-pages") starDecoder
)
]
|> ProgramTest.simulateHttpOk
"GET"
"https://api.github.com/repos/dillonkearns/elm-pages"
"""{ "stargazer_count": 86 }"""
|> expectSuccess
[ ( ""
, [ ( get "https://api.github.com/repos/dillonkearns/elm-pages"
, """{"stargazer_count":86}"""
)
]
)
]
]
]
start : List ( List String, StaticHttp.Request a ) -> ProgramTest Main.Model Main.Msg (Main.Effect PathKey)
start pages =
startWithHttpCache [] pages
startWithHttpCache : List ( Request.Request, String ) -> List ( List String, StaticHttp.Request a ) -> ProgramTest Main.Model Main.Msg (Main.Effect PathKey)
startWithHttpCache staticHttpCache pages =
let
document =
Document.fromList
@ -637,8 +696,9 @@ start pages =
config =
{ toJsPort = toJsPort
, fromJsPort = fromJsPort
, manifest = manifest
, generateFiles = \_ -> []
, generateFiles = \_ -> StaticHttp.succeed []
, init = \_ -> ( (), Cmd.none )
, update = \_ _ -> ( (), Cmd.none )
, view =
@ -650,7 +710,6 @@ start pages =
|> Dict.get
(page.path
|> PagePath.toString
|> String.dropLeft 1
|> String.split "/"
|> List.filter (\pathPart -> pathPart /= "")
)
@ -670,6 +729,30 @@ start pages =
, pathKey = PathKey
, onPageChange = \_ -> ()
}
encodedFlags =
--{"secrets":
-- {"API_KEY": "ABCD1234","BEARER": "XYZ789"}, "mode": "prod", "staticHttpCache": {}
-- }
Encode.object
[ ( "secrets"
, [ ( "API_KEY", "ABCD1234" )
, ( "BEARER", "XYZ789" )
]
|> Dict.fromList
|> Encode.dict identity Encode.string
)
, ( "mode", Encode.string "prod" )
, ( "staticHttpCache", encodedStaticHttpCache )
]
encodedStaticHttpCache =
staticHttpCache
|> List.map
(\( request, httpResponseString ) ->
( Request.hash request, Encode.string httpResponseString )
)
|> Encode.object
in
{-
(Model -> model)
@ -685,9 +768,7 @@ start pages =
, view = \_ -> { title = "", body = [] }
}
|> ProgramTest.withSimulatedEffects simulateEffects
|> ProgramTest.start (flags """{"secrets":
{"API_KEY": "ABCD1234","BEARER": "XYZ789"}, "mode": "prod"
}""")
|> ProgramTest.start (flags (Encode.encode 0 encodedFlags))
flags : String -> JD.Value
@ -781,6 +862,10 @@ toJsPort foo =
Cmd.none
fromJsPort =
Sub.none
type PathKey
= PathKey
@ -806,7 +891,7 @@ starDecoder =
thingy =
[ ( "/"
[ ( ""
, [ ( { method = "GET"
, url = "https://api.github.com/repos/dillonkearns/elm-pages"
, headers = []
@ -832,10 +917,12 @@ expectSuccess expectedRequests previous =
|> ProgramTest.expectOutgoingPortValues
"toJsPort"
(Codec.decoder Main.toJsCodec)
(Expect.equal
[ Main.Success
{ pages =
expectedRequests
(\value ->
case value of
[ Main.Success portPayload ] ->
portPayload.pages
|> Expect.equal
(expectedRequests
|> List.map
(\( url, requests ) ->
( url
@ -848,11 +935,13 @@ expectSuccess expectedRequests previous =
)
)
|> Dict.fromList
, manifest = manifest
, filesToGenerate = []
, errors = []
}
]
)
[ _ ] ->
Expect.fail "Expected success port."
_ ->
Expect.fail ("Expected ports to be called once, but instead there were " ++ String.fromInt (List.length value) ++ " calls.")
)

View File

@ -2,7 +2,9 @@ module StaticHttpUnitTests exposing (all)
import Dict exposing (Dict)
import Expect
import Json.Decode.Exploration as Decode
import Json.Decode.Exploration
import OptimizedDecoder as Decode
import Pages.Internal.ApplicationType as ApplicationType
import Pages.StaticHttp as StaticHttp
import Pages.StaticHttp.Request as Request
import Pages.StaticHttpRequest as StaticHttpRequest
@ -42,11 +44,11 @@ all =
StaticHttp.get (Secrets.succeed "first") (Decode.succeed "NEXT")
|> StaticHttp.andThen
(\continueUrl ->
-- StaticHttp.get continueUrl (Decode.succeed ())
getWithoutSecrets "NEXT" (Decode.succeed ())
)
|> (\request ->
StaticHttpRequest.resolveUrls request
StaticHttpRequest.resolveUrls ApplicationType.Cli
request
(requestsDict
[ ( get "first", "null" )
, ( get "NEXT", "null" )
@ -63,7 +65,8 @@ all =
getWithoutSecrets "NEXT" (Decode.succeed ())
)
|> (\request ->
StaticHttpRequest.resolveUrls request
StaticHttpRequest.resolveUrls ApplicationType.Cli
request
(requestsDict
[ ( get "NEXT", "null" )
]
@ -81,7 +84,8 @@ all =
)
|> StaticHttp.map (\_ -> ())
|> (\request ->
StaticHttpRequest.resolveUrls request
StaticHttpRequest.resolveUrls ApplicationType.Cli
request
(requestsDict
[ ( get "first", "null" )
, ( get "NEXT", "null" )
@ -98,7 +102,8 @@ all =
getWithoutSecrets "NEXT" (Decode.succeed ())
)
|> (\request ->
StaticHttpRequest.resolveUrls request
StaticHttpRequest.resolveUrls ApplicationType.Cli
request
(requestsDict
[ ( get "first", "null" )
]
@ -119,7 +124,8 @@ all =
)
)
|> (\request ->
StaticHttpRequest.resolveUrls request
StaticHttpRequest.resolveUrls ApplicationType.Cli
request
(requestsDict
[ ( get "first", "1" )
]