Add starter template and implement SSR with Feliz.viewengine

This commit is contained in:
Ismael Abu-jadur Garcia
2025-11-26 09:53:11 +00:00
parent a052762ce6
commit be00e6a27a
99 changed files with 14378 additions and 1068 deletions

88
.build/Build.fs Normal file
View File

@@ -0,0 +1,88 @@
open Fake.Core
open Fake.IO
open Farmer
open Farmer.Builders
open Helpers
initializeContext ()
let serverPath = Path.getFullName "src/Server"
let clientPath = Path.getFullName "src/Client"
let testPath = Path.getFullName "test"
let libPath = None
let distPath = Path.getFullName "dist"
let packPath = Path.getFullName "packages"
let versionFile = Path.getFullName ".version"
let vite = "node ../../node_modules/vite/bin/vite.js -c ../../vite.config.js"
Target.create "Clean" (fun _ -> Shell.cleanDir distPath)
Target.create "InstallClient" (fun _ ->
run npm "install" "."
run dotnet "tool restore" ".")
Target.create "Tailwind" (fun _ -> run npm "run tailwind:build" ".")
Target.create "Bundle" (fun _ ->
[ "server", dotnet $"publish -c Release -o {distPath}" serverPath
"client", dotnet $"fable -o build/client --run {vite} build --outDir {distPath}/public" clientPath ]
|> runParallel)
Target.create "BundleDebug" (fun _ ->
[ "server", dotnet $"publish -c Debug -o {distPath}" serverPath
"client", dotnet $"fable -o build/client --run {vite} build --outDir {distPath}/public" clientPath ]
|> runParallel)
Target.create "Pack" (fun _ ->
match libPath with
| Some p -> run dotnet $"pack -c Release -o \"{packPath}\"" p
| None -> ())
Target.create "Run" (fun _ ->
[ "server", dotnet "watch run" serverPath
"client", dotnet "fable watch -o build/client --run vite -c ../../vite.config.js" clientPath ]
|> runParallel)
Target.create "Server" (fun _ -> run dotnet "watch run" serverPath)
Target.create "ServerWatch" (fun _ -> run dotnet "watch build" serverPath)
Target.create "Client" (fun _ ->
run dotnet $"fable watch -o build/client --run vite -c ../../vite.config.js" clientPath)
Target.create "Format" (fun _ -> run dotnet "fantomas . -r" "src")
Target.create "Test" (fun _ ->
if System.IO.Directory.Exists testPath then
[ "server", dotnet "run" (testPath + "/Server")
"client", dotnet "fable -o build/client --run vite build" (testPath + "/Client") ]
|> runParallel
else
())
open Fake.Core.TargetOperators
let dependencies =
[ "Clean" ==> "InstallClient" ==> "Tailwind" ==> "Bundle"
"Clean" ==> "BundleDebug"
"Clean" ==> "Test"
"Clean" ==> "InstallClient" ==> "Tailwind" ==> "Run"
"Clean" ==> "Pack"
"Server"
"ServerWatch"
"Client"
]
[<EntryPoint>]
let main args = runOrDefault args

102
.build/Helpers.fs Normal file
View File

@@ -0,0 +1,102 @@
module Helpers
open Fake.Core
let initializeContext () =
let execContext = Context.FakeExecutionContext.Create false "build.fsx" []
Context.setExecutionContext (Context.RuntimeContext.Fake execContext)
module Proc =
module Parallel =
open System
let locker = obj ()
let colors =
[| ConsoleColor.Blue
ConsoleColor.Yellow
ConsoleColor.Magenta
ConsoleColor.Cyan
ConsoleColor.DarkBlue
ConsoleColor.DarkYellow
ConsoleColor.DarkMagenta
ConsoleColor.DarkCyan |]
let print color (colored: string) (line: string) =
lock locker (fun () ->
let currentColor = Console.ForegroundColor
Console.ForegroundColor <- color
Console.Write colored
Console.ForegroundColor <- currentColor
Console.WriteLine line)
let onStdout index name (line: string) =
let color = colors.[index % colors.Length]
if isNull line then
print color $"{name}: --- END ---" ""
else if String.isNotNullOrEmpty line then
print color $"{name}: " line
let onStderr name (line: string) =
let color = ConsoleColor.Red
if isNull line |> not then
print color $"{name}: " line
let redirect (index, (name, createProcess)) =
createProcess
|> CreateProcess.redirectOutputIfNotRedirected
|> CreateProcess.withOutputEvents (onStdout index name) (onStderr name)
let printStarting indexed =
for (index, (name, c: CreateProcess<_>)) in indexed do
let color = colors.[index % colors.Length]
let wd = c.WorkingDirectory |> Option.defaultValue ""
let exe = c.Command.Executable
let args = c.Command.Arguments.ToStartInfo
print color $"{name}: {wd}> {exe} {args}" ""
let run cs =
cs
|> Seq.toArray
|> Array.indexed
|> fun x ->
printStarting x
x
|> Array.map redirect
|> Array.Parallel.map Proc.run
let createProcess exe arg dir =
CreateProcess.fromRawCommandLine exe arg
|> CreateProcess.withWorkingDirectory dir
|> CreateProcess.ensureExitCode
let dotnet = createProcess "dotnet"
let npm =
let npmPath =
match ProcessUtils.tryFindFileOnPath "npm" with
| Some path -> path
| None ->
"npm was not found in path. Please install it and make sure it's available from your path. "
+ "See https://safe-stack.github.io/docs/quickstart/#install-pre-requisites for more info"
|> failwith
createProcess npmPath
let run proc arg dir = proc arg dir |> Proc.run |> ignore
let runParallel processes =
processes |> Proc.Parallel.run |> ignore
let runOrDefault args =
try
match args with
| [| target |] -> Target.runOrDefault target
| _ -> Target.runOrDefault "Run"
0
with e ->
printfn "%A" e
1

34
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,34 @@
{
"version": 1,
"isRoot": true,
"tools": {
"fable": {
"version": "4.27.0",
"commands": [
"fable"
],
"rollForward": false
},
"fantomas-tool": {
"version": "4.7.9",
"commands": [
"fantomas"
],
"rollForward": false
},
"fantomas": {
"version": "7.0.3",
"commands": [
"fantomas"
],
"rollForward": false
},
"fornax": {
"version": "0.16.0",
"commands": [
"fornax"
],
"rollForward": false
}
}
}

View File

@@ -0,0 +1,8 @@
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: pubsub
spec:
type: pubsub.in-memory
version: v1
metadata: []

View File

@@ -0,0 +1,10 @@
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.in-memory
version: v1
metadata:
- name: actorStateStore
value: "true"

9
.dapr/config.yaml Normal file
View File

@@ -0,0 +1,9 @@
apiVersion: dapr.io/v1alpha1
kind: Configuration
metadata:
name: daprConfig
spec:
tracing:
samplingRate: "1"
zipkin:
endpointAddress: http://localhost:9411/api/v2/spans

23
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/sdk:9.0
# Add keys and sources lists
RUN apt-get update && apt-get install -y ca-certificates gnupg
RUN mkdir -p /etc/apt/keyrings
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
ENV NODE_MAJOR=22
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
# Install node, 7zip, yarn, git, process tools
RUN apt-get update \
&& apt-get install -y nodejs p7zip-full git procps ssh-client
# Clean up
RUN apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.dotnet/tools:${PATH}"
# Copy endpoint specific user settings into container to specify
# .NET Core should be used as the runtime.
COPY settings.vscode.json /root/.vscode-remote/data/Machine/settings.json

View File

@@ -0,0 +1,11 @@
{
"name": "SAFE",
"dockerFile": "Dockerfile",
"appPort": [8080, 8085],
"extensions": [
"ionide.ionide-fsharp",
"ms-dotnettools.csharp",
"editorconfig.editorconfig",
"msjsdiag.debugger-for-chrome"
]
}

View File

@@ -0,0 +1,3 @@
{
"FSharp.fsacRuntime":"netcore"
}

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use_nix

23
.gitignore vendored
View File

@@ -73,4 +73,25 @@ bin
/drush.phar
/drush
/drupal.phar
/drupal
/drupal
.fable/
.fake/
.vs/
.idea/
.ionide/
obj/
bin/
packages/
node_modules/
src/Client/public/js/
release.cmd
release.sh
*.orig
*.DotSettings.user
deploy/
dist/
build/
*.db
build.fsx.lock
_*.yaml

8
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,8 @@
variables:
PROJECT_NAME: OceanWeb
SKIP_TESTS: "true"
include:
- project: oceanbox/gitlab-ci
ref: v3
file: DotnetDeployment.gitlab-ci.yml

41
.releaserc.yaml Normal file
View File

@@ -0,0 +1,41 @@
branches:
- main
- master
- name: develop
prerelease: true
plugins:
- '@semantic-release/commit-analyzer'
- '@semantic-release/release-notes-generator'
- - '@semantic-release/changelog'
- changelogFile: RELEASE_NOTES.md
changelogTitle: "# Changelog"
- - 'semantic-release-dotnet'
- paths: [ "src/*.fsproj", "src/*/*.fsproj" ]
- - '@semantic-release/exec'
- generateNotesCmd: "echo ${nextRelease.version} > .version"
- - '@semantic-release/git'
- message: "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
assets: [ "RELEASE_NOTES.md", ".version", "src/*.fsproj", "src/*/*.fsproj" ]
- - '@semantic-release/gitlab'
- assets: []
analyzeCommits:
- path: "@semantic-release/commit-analyzer"
releaseRules:
- type: "fix"
release: "patch"
- type: "patch"
release: "patch"
- type: "feat"
release: "minor"
- type: "feature"
release: "minor"
- type: "minor"
release: "minor"
- type: "breaking"
release: "major"
- type: "major"
release: "major"

1
.tiltignore Normal file
View File

@@ -0,0 +1 @@
tilt/base/_manifest.yaml

20
Build.fsproj Normal file
View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include=".build/Helpers.fs" />
<Compile Include=".build/Build.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fake.Core.Target" Version="6.1.4" />
<PackageReference Include="Fake.DotNet.Cli" Version="6.1.4" />
<PackageReference Include="Fake.IO.FileSystem" Version="6.1.4" />
<PackageReference Include="Farmer" Version="1.9.25" />
<PackageReference Update="FSharp.Core" Version="9.0.300" />
<PackageReference Include="Feliz.ViewEngine" Version="0.27.0" />
<PackageReference Include="FSharp.Data" Version="6.6.0" />
<PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" />
</ItemGroup>
</Project>

7
Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0
ENV SERVER_CONTENT_ROOT /app/public
COPY dist/ /app
WORKDIR /app
CMD /app/Server

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Serit Tromsø AS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,19 +1,37 @@
# Drupal + Fornax
# README
Small decoupled setup that pulls content from Drupal through JSONAPI and
converts it into a static site using Fornax and F#.
This template scaffolds a web application with the following compnents configured:
## What it decoupled
- Fetches content from the Drupal site exposed through JSONAPI
- Deserializes Drupal into F# types
- Uses Fornax loaders + generators to render the content into static HTML
#### Server
## Tech stack (currently)
* Saturn
* Giraffe
* OpenIdConnect, JWT and plain authentication
* Fable.Remoting
* Fable.SignalR
* Dapr
* Prometheus metric
* Tilt
- Drupal 11 + JSONAPI
- F#
- Fornax
#### Client
## Must knows
Drupal JSONAPI Extras has been patched with a custom patch to make sure that we
can use _default_include to include nested relationships.
* Fable.Lit
* Fable.Remoting
* Fable.SignalR
## Using Dapr
This template inludes support for Dapr Actors. The custom `use_multiauth` and `use_oidc` authentication pipelines configure the user (principal) groups and roles (claims) via a `UserActor`, which can easily be migrated to an external Dapr service if need be.
Install the Dapr CLI and set up Dapr for use with actors:
```sh
dapr init -s
cp .dapr/components/* ~/.dapr/componets
```
Run the development server(s) under Dapr:
```sh
dapr run --app-id webapp --app-port 8085 -- dotnet run
```

1
RELEASE_NOTES.md Normal file
View File

@@ -0,0 +1 @@
# Changelog

93
Tiltfile Normal file
View File

@@ -0,0 +1,93 @@
name='OceanWeb'
namespace='default'
chart='oceanbox/{name}'.format(name=name)
repository='registry.gitlab.com/oceanbox/{name}'.format(name=name)
env=os.getenv('USER')
cluster='{env}-vcluster'.format(env=env)
allow_k8s_contexts(cluster)
load('ext://restart_process', 'docker_build_with_restart')
load('ext://namespace', 'namespace_inject')
clientPort=os.getenv('CLIENT_PORT')
if not clientPort:
clientPort=8080
os.putenv('CLIENT_PORT', '{0}'.format(clientPort))
else:
clientPort=int(clientPort)
serverPort=os.getenv('SERVER_PROXY_PORT')
if not serverPort:
serverPort=8095
os.putenv('SERVER_PROXY_PORT', '{0}'.format(serverPort))
else: serverPort=int(serverPort)
docker_build_with_restart(
'{repository}:latest'.format(repository=repository),
entrypoint='dotnet /app/Server.dll',
context='.',
dockerfile='Dockerfile',
only=['./dist'],
live_update=[sync('./dist', '/app')]
)
manifest=helm(
'../manifests/charts/{name}'.format(name=name),
name='{env}'.format(env=env),
namespace=namespace,
values=[ './tilt/values.yaml' ],
set=[ 'image.repository={repository}'.format(repository=repository) ]
)
local('cat > _manifest.yaml',
dir='./tilt/base',
stdin=manifest,
echo_off=False, quiet=True)
kustomizations=str(kustomize('./tilt/tilt')).replace('<x>',"{env}".format(env=env))
k8s_yaml(namespace_inject(blob(kustomizations), namespace))
local_resource(
'create-bundle',
cmd='dotnet run bundledebug',
trigger_mode=TRIGGER_MODE_MANUAL
)
local_resource(
'build-server',
cmd='dotnet publish -o ./dist src/Server',
deps=[
'./src/Server',
'./src/Shared'
],
ignore=[
'src/Server/bin',
'src/Server/obj',
'src/Shared/bin',
'src/Shared/obj',
],
resource_deps=['create-bundle'],
auto_init=True,
labels=['server']
)
local_resource(
'run-client',
serve_cmd='dotnet fable watch -o build/client --run vite -c ../../vite.config.js',
serve_dir='./src/Client',
links=['https://{name}.local.oceanbox.io:{port}'.format(name=name, port=clientPort)],
resource_deps=['build-server'],
auto_init=True,
trigger_mode=TRIGGER_MODE_MANUAL,
labels=['client']
)
k8s_resource(
'{env}-{name}'.format(env=env, name=name),
port_forwards=['{port}:8085'.format(port=serverPort)],
labels=['server']
)
# vim:ft=python

1132
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,124 +1,128 @@
{
"name": "drupal/recommended-project",
"description": "Project template for Drupal projects with a relocated document root",
"type": "project",
"license": "GPL-2.0-or-later",
"homepage": "https://www.drupal.org/project/drupal",
"support": {
"docs": "https://www.drupal.org/docs/user_guide/en/index.html",
"chat": "https://www.drupal.org/node/314178"
},
"repositories": [
{
"type": "composer",
"url": "https://packages.drupal.org/8"
}
],
"require": {
"composer/installers": "^2.3",
"cweagans/composer-patches": "^2.0",
"drupal/admin_toolbar": "^3.6",
"drupal/core-composer-scaffold": "^11.2",
"drupal/core-project-message": "^11.2",
"drupal/core-recipe-unpack": "^11.2",
"drupal/core-recommended": "^11.2",
"drupal/devel": "^5.4",
"drupal/field_group": "^4.0",
"drupal/jsonapi_extras": "^3.27",
"drupal/jsonapi_include": "^2.0",
"drupal/jsonapi_resources": "^1.3",
"drupal/paragraphs": "^1.19",
"drupal/pathauto": "^1.14",
"drupal/rest_absolute_urls": "^2.0"
},
"conflict": {
"drupal/drupal": "*"
},
"minimum-stability": "stable",
"prefer-stable": true,
"config": {
"allow-plugins": {
"composer/installers": true,
"cweagans/composer-patches": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"drupal/core-composer-scaffold": true,
"drupal/core-project-message": true,
"drupal/core-recipe-unpack": true,
"php-http/discovery": true,
"php-tuf/composer-integration": true,
"phpstan/extension-installer": true
},
"sort-packages": true
},
"extra": {
"patches": {
"drupal/jsonapi_include": {
"Issue #3490592: Incompatile with jsonapi_default fix": "https://git.drupalcode.org/project/jsonapi_include/-/merge_requests/28.diff"
},
"drupal/jsonapi_extras": {
"Issue #3373151: Auto include default includes of related entities": "patches/jsonapi_extras_default_includes.patch"
}
},
"drupal-scaffold": {
"locations": {
"web-root": "web/"
}
},
"installer-paths": {
"web/core": [
"type:drupal-core"
],
"web/libraries/{$name}": [
"type:drupal-library"
],
"web/modules/contrib/{$name}": [
"type:drupal-module"
],
"web/profiles/contrib/{$name}": [
"type:drupal-profile"
],
"web/themes/contrib/{$name}": [
"type:drupal-theme"
],
"drush/Commands/contrib/{$name}": [
"type:drupal-drush"
],
"web/modules/custom/{$name}": [
"type:drupal-custom-module"
],
"web/profiles/custom/{$name}": [
"type:drupal-custom-profile"
],
"web/themes/custom/{$name}": [
"type:drupal-custom-theme"
],
"recipes/{$name}": [
"type:drupal-recipe"
]
},
"drupal-core-project-message": {
"include-keys": [
"homepage",
"support"
],
"post-create-project-cmd-message": [
"<bg=blue;fg=white> </>",
"<bg=blue;fg=white> Congratulations, youve installed the Drupal codebase </>",
"<bg=blue;fg=white> from the drupal/recommended-project template! </>",
"<bg=blue;fg=white> </>",
"",
"<bg=yellow;fg=black>Next steps</>:",
" * Install the site: https://www.drupal.org/docs/installing-drupal",
" * Read the user guide: https://www.drupal.org/docs/user_guide/en/index.html",
" * Get support: https://www.drupal.org/support",
" * Get involved with the Drupal community:",
" https://www.drupal.org/getting-involved",
" * Remove the plugin that prints this message:",
" composer remove drupal/core-project-message"
]
}
},
"require-dev": {
"drush/drush": "^13.6"
"name": "drupal/recommended-project",
"description": "Project template for Drupal projects with a relocated document root",
"type": "project",
"license": "GPL-2.0-or-later",
"homepage": "https://www.drupal.org/project/drupal",
"support": {
"docs": "https://www.drupal.org/docs/user_guide/en/index.html",
"chat": "https://www.drupal.org/node/314178"
},
"repositories": [
{
"type": "composer",
"url": "https://packages.drupal.org/8"
}
],
"require": {
"composer/installers": "^2.3",
"cweagans/composer-patches": "^2.0",
"drupal/admin_toolbar": "^3.6",
"drupal/core-composer-scaffold": "^11.2",
"drupal/core-project-message": "^11.2",
"drupal/core-recipe-unpack": "^11.2",
"drupal/core-recommended": "^11.2",
"drupal/decoupled_router": "^2.0",
"drupal/devel": "^5.4",
"drupal/field_group": "^4.0",
"drupal/jsonapi_extras": "^3.27",
"drupal/jsonapi_include": "^2.0",
"drupal/jsonapi_resources": "^1.3",
"drupal/jsonapi_site": "^1.0",
"drupal/paragraphs": "^1.19",
"drupal/pathauto": "^1.14",
"drupal/rest_absolute_urls": "^2.0",
"mnsami/composer-custom-directory-installer": "^2.0"
},
"conflict": {
"drupal/drupal": "*"
},
"minimum-stability": "stable",
"prefer-stable": true,
"config": {
"allow-plugins": {
"composer/installers": true,
"cweagans/composer-patches": true,
"dealerdirect/phpcodesniffer-composer-installer": true,
"drupal/core-composer-scaffold": true,
"drupal/core-project-message": true,
"drupal/core-recipe-unpack": true,
"mnsami/composer-custom-directory-installer": true,
"php-http/discovery": true,
"php-tuf/composer-integration": true,
"phpstan/extension-installer": true
},
"sort-packages": true
},
"extra": {
"patches": {
"drupal/jsonapi_include": {
"Issue #3490592: Incompatile with jsonapi_default fix": "https://git.drupalcode.org/project/jsonapi_include/-/merge_requests/28.diff"
},
"drupal/jsonapi_extras": {
"Issue #3373151: Auto include default includes of related entities": "patches/jsonapi_extras_default_includes.patch"
}
},
"drupal-scaffold": {
"locations": {
"web-root": "web/"
}
},
"installer-paths": {
"web/core": [
"type:drupal-core"
],
"web/libraries/{$name}": [
"type:drupal-library"
],
"web/modules/contrib/{$name}": [
"type:drupal-module"
],
"web/profiles/contrib/{$name}": [
"type:drupal-profile"
],
"web/themes/contrib/{$name}": [
"type:drupal-theme"
],
"drush/Commands/contrib/{$name}": [
"type:drupal-drush"
],
"web/modules/custom/{$name}": [
"type:drupal-custom-module"
],
"web/profiles/custom/{$name}": [
"type:drupal-custom-profile"
],
"web/themes/custom/{$name}": [
"type:drupal-custom-theme"
],
"recipes/{$name}": [
"type:drupal-recipe"
]
},
"drupal-core-project-message": {
"include-keys": [
"homepage",
"support"
],
"post-create-project-cmd-message": [
"<bg=blue;fg=white> </>",
"<bg=blue;fg=white> Congratulations, youve installed the Drupal codebase </>",
"<bg=blue;fg=white> from the drupal/recommended-project template! </>",
"<bg=blue;fg=white> </>",
"",
"<bg=yellow;fg=black>Next steps</>:",
" * Install the site: https://www.drupal.org/docs/installing-drupal",
" * Read the user guide: https://www.drupal.org/docs/user_guide/en/index.html",
" * Get support: https://www.drupal.org/support",
" * Get involved with the Drupal community:",
" https://www.drupal.org/getting-involved",
" * Remove the plugin that prints this message:",
" composer remove drupal/core-project-message"
]
}
},
"require-dev": {
"drush/drush": "^13.6"
}
}

226
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3a0bf64ce33c0bbf042694707e132bf6",
"content-hash": "2f8c9e92ce5289579c0ab5ad04f25ec1",
"packages": [
{
"name": "asm89/stack-cors",
@@ -1458,6 +1458,66 @@
"issues": "https://www.drupal.org/project/issues/ctools"
}
},
{
"name": "drupal/decoupled_router",
"version": "2.0.6",
"source": {
"type": "git",
"url": "https://git.drupalcode.org/project/decoupled_router.git",
"reference": "2.0.6"
},
"dist": {
"type": "zip",
"url": "https://ftp.drupal.org/files/projects/decoupled_router-2.0.6.zip",
"reference": "2.0.6",
"shasum": "93a725c93cac4f5c03c729c3984d926c4c52014e"
},
"require": {
"drupal/core": "^10.1 || ^11",
"php": ">=8.1"
},
"conflict": {
"drupal/redirect": "<1.12"
},
"require-dev": {
"drupal/redirect": "dev-1.x"
},
"type": "drupal-module",
"extra": {
"drupal": {
"version": "2.0.6",
"datestamp": "1757356868",
"security-coverage": {
"status": "covered",
"message": "Covered by Drupal's security advisory policy"
}
}
},
"notification-url": "https://packages.drupal.org/8/downloads",
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Mateu Aguiló Bosch",
"homepage": "https://www.drupal.org/user/103796",
"email": "mateu.aguilo.bosch@gmail.com"
},
{
"name": "e0ipso",
"homepage": "https://www.drupal.org/user/550110"
},
{
"name": "mglaman",
"homepage": "https://www.drupal.org/user/2416470"
}
],
"description": "Provides an endpoint that will help you resolve path aliases and redirects for entity related routes.",
"homepage": "https://www.drupal.org/project/decoupled_router",
"support": {
"source": "https://git.drupalcode.org/project/decoupled_router"
}
},
{
"name": "drupal/devel",
"version": "5.4.0",
@@ -1843,6 +1903,112 @@
"source": "https://git.drupalcode.org/project/jsonapi_resources"
}
},
{
"name": "drupal/jsonapi_site",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://git.drupalcode.org/project/jsonapi_site.git",
"reference": "1.0.2"
},
"dist": {
"type": "zip",
"url": "https://ftp.drupal.org/files/projects/jsonapi_site-1.0.2.zip",
"reference": "1.0.2",
"shasum": "d7d05c2aadb8206c4e6a4dab67f3d061abb23ef8"
},
"require": {
"drupal/core": "^9 || ^10 || ^11",
"drupal/key_auth": "*"
},
"type": "drupal-module",
"extra": {
"drupal": {
"version": "1.0.2",
"datestamp": "1714930689",
"security-coverage": {
"status": "covered",
"message": "Covered by Drupal's security advisory policy"
}
}
},
"notification-url": "https://packages.drupal.org/8/downloads",
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "andrew answer",
"homepage": "https://www.drupal.org/user/239127"
}
],
"description": "This module let's you read basic site and theme settings via JSON:API.",
"homepage": "https://www.drupal.org/project/jsonapi_site",
"support": {
"source": "https://git.drupalcode.org/project/jsonapi_site"
}
},
{
"name": "drupal/key_auth",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://git.drupalcode.org/project/key_auth.git",
"reference": "2.2.0"
},
"dist": {
"type": "zip",
"url": "https://ftp.drupal.org/files/projects/key_auth-2.2.0.zip",
"reference": "2.2.0",
"shasum": "b76e247d974d69199c55f551850bb2b0feb15a36"
},
"require": {
"drupal/core": "^9.5 || ^10 || ^11"
},
"type": "drupal-module",
"extra": {
"drupal": {
"version": "2.2.0",
"datestamp": "1722170641",
"security-coverage": {
"status": "covered",
"message": "Covered by Drupal's security advisory policy"
}
}
},
"notification-url": "https://packages.drupal.org/8/downloads",
"license": [
"GPL-2.0-or-later"
],
"authors": [
{
"name": "Rajeshreeputra Pravin Dhondiba Gaikwad",
"homepage": "https://www.drupal.org/u/Rajeshreeputra",
"role": "Maintainer"
},
{
"name": "Mike Stefanello",
"homepage": "https://www.drupal.org/u/mstef",
"role": "Maintainer"
},
{
"name": "Dan Bryant",
"homepage": "https://www.drupal.org/u/perfectcube",
"role": "Maintainer"
},
{
"name": "Ganesh Suryawanshi",
"homepage": "https://www.drupal.org/u/ganeshsurya11",
"role": "Maintainer"
}
],
"description": "Provides simple key-based authentication on a per-user basis similar to basic_auth but without requiring usernames or passwords.",
"homepage": "https://www.drupal.org/project/key_auth",
"support": {
"source": "https://git.drupalcode.org/project/key_auth",
"issues": "https://www.drupal.org/project/issues/key_auth"
}
},
{
"name": "drupal/paragraphs",
"version": "1.19.0",
@@ -2819,6 +2985,62 @@
},
"time": "2025-10-10T12:53:17+00:00"
},
{
"name": "mnsami/composer-custom-directory-installer",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/mnsami/composer-custom-directory-installer.git",
"reference": "85f66323978d0b1cb0e6acc7f69b3e7b912f82d9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mnsami/composer-custom-directory-installer/zipball/85f66323978d0b1cb0e6acc7f69b3e7b912f82d9",
"reference": "85f66323978d0b1cb0e6acc7f69b3e7b912f82d9",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0 || ^2.0",
"php": ">=5.3"
},
"type": "composer-plugin",
"extra": {
"class": [
"Composer\\CustomDirectoryInstaller\\LibraryPlugin",
"Composer\\CustomDirectoryInstaller\\PearPlugin",
"Composer\\CustomDirectoryInstaller\\PluginPlugin"
],
"branch-alias": {
"dev-master": "1.1.x-dev"
}
},
"autoload": {
"psr-0": {
"Composer\\CustomDirectoryInstaller": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mina Nabil Sami",
"email": "mina.nsami@gmail.com"
}
],
"description": "A composer plugin, to help install packages of different types in custom paths.",
"keywords": [
"composer",
"composer-installer",
"composer-plugin"
],
"support": {
"issues": "https://github.com/mnsami/composer-custom-directory-installer/issues",
"source": "https://github.com/mnsami/composer-custom-directory-installer/tree/2.0.0"
},
"time": "2020-08-18T11:00:11+00:00"
},
{
"name": "pear/archive_tar",
"version": "1.5.0",
@@ -7702,5 +7924,5 @@
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

32
config.fsx Executable file
View File

@@ -0,0 +1,32 @@
#r "_lib/Fornax.Core.dll"
open Config
open System.IO
let staticPredicate (projectRoot: string, page: string) =
let ext = Path.GetExtension page
let fileShouldBeExcluded =
ext = ".fsx" ||
ext = ".fs" ||
ext = ".md" ||
ext = ".fsproj" ||
page.Contains ".config" ||
page.Contains "src" ||
page.Contains "obj" ||
page.Contains "_public" ||
page.Contains "_bin" ||
page.Contains "_lib" ||
page.Contains "_data" ||
page.Contains "_settings" ||
page.Contains "_config.yml" ||
page.Contains ".sass-cache" ||
page.Contains ".git" ||
page.Contains ".ionide"
fileShouldBeExcluded |> not
let config = {
Generators = [
{Script = "basicpagegenerator.fsx"; Trigger = Once; OutputFile = MultipleFiles id }
]
}

View File

@@ -1,13 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {
"fornax": {
"version": "0.16.0",
"commands": [
"fornax"
],
"rollForward": false
}
}
}

View File

@@ -1,49 +0,0 @@
#r "_lib/Fornax.Core.dll"
open Config
open System.IO
let postPredicate (projectRoot: string, page: string) =
let fileName = Path.Combine(projectRoot,page)
let ext = Path.GetExtension page
if ext = ".md" then
let ctn = File.ReadAllText fileName
page.Contains("_public") |> not
&& ctn.Contains("layout: post")
else
false
let staticPredicate (projectRoot: string, page: string) =
let ext = Path.GetExtension page
let fileShouldBeExcluded =
ext = ".fsx" ||
ext = ".fs" ||
ext = ".md" ||
ext = ".fsproj" ||
page.Contains ".config" ||
page.Contains "src" ||
page.Contains "obj" ||
page.Contains "_public" ||
page.Contains "_bin" ||
page.Contains "_lib" ||
page.Contains "_data" ||
page.Contains "_settings" ||
page.Contains "_config.yml" ||
page.Contains ".sass-cache" ||
page.Contains ".git" ||
page.Contains ".ionide"
fileShouldBeExcluded |> not
let config = {
Generators = [
{Script = "less.fsx"; Trigger = OnFileExt ".less"; OutputFile = ChangeExtension "css" }
{Script = "sass.fsx"; Trigger = OnFileExt ".scss"; OutputFile = ChangeExtension "css" }
{Script = "post.fsx"; Trigger = OnFilePredicate postPredicate; OutputFile = ChangeExtension "html" }
{Script = "staticfile.fsx"; Trigger = OnFilePredicate staticPredicate; OutputFile = SameFileName }
{Script = "index.fsx"; Trigger = Once; OutputFile = MultipleFiles id }
{Script = "about.fsx"; Trigger = Once; OutputFile = NewFileName "about.html" }
{Script = "contact.fsx"; Trigger = Once; OutputFile = NewFileName "contact.html" }
{Script = "basicpage.fsx"; Trigger = Once; OutputFile = MultipleFiles id }
]
}

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.Data" Version="6.6.0" />
<PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" />
</ItemGroup>
</Project>

View File

@@ -1,40 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#load "layout.fsx"
open Html
let about = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci."
let generate' (ctx : SiteContents) (_: string) =
let siteInfo = ctx.TryGetValue<Globalloader.SiteInfo> ()
let desc =
siteInfo
|> Option.map (fun si -> si.description)
|> Option.defaultValue ""
Layout.layout ctx "About" [
section [Class "hero is-info is-medium is-bold"] [
div [Class "hero-body"] [
div [Class "container has-text-centered"] [
h1 [Class "title"] [!!desc]
]
]
]
div [Class "container"] [
section [Class "articles"] [
div [Class "column is-8 is-offset-2"] [
div [Class "card article"] [
div [Class "card-content"] [
div [Class "content article-body"] [
!! about
]
]
]
]
]
]]
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page
|> Layout.render ctx

View File

@@ -1,60 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#r "../src/Types/bin/Debug/netstandard2.0/Types.dll"
#load "./layout.fsx"
open Html
open Types.Types
let generate' (ctx: SiteContents) (_: string) =
let basicPageContent = ctx.TryGetValues<DocData>() |> Option.defaultValue Seq.empty
let siteUrl = "http://oceanbox.ddev.site"
let paragraphLayout (paragraph: Paragraph) =
match paragraph with
| Hero hero ->
div [Class "hero"] [
h1 [Class "header"] [!! hero.FieldTitle]
h2 [Class "sub-header"] [!! hero.FieldSubtitle]
match hero.FieldBackgroundImage with
| MediaVideo video ->
let videoUrl = siteUrl + video.FieldMediaVideoFile.Uri.Url
div [] [
p [Class "video"] [!! videoUrl]
]
| MediaImage image ->
let imgUrl = siteUrl + image.FieldMediaImage.Uri.Url
div [] [
img [Src imgUrl ; Alt image.FieldMediaImage.Meta.Alt ]
]
]
| SimpleText simpletext ->
// eprintfn "Simple text field body: %s" simpletext.attrs.field_body
div [Class "text"] [!! simpletext.FieldBody]
| Ignore ->
div [Class "ignored"] []
let pageContentLayout (title, paragraphs) =
let processedParagraphs =
paragraphs |> Array.map paragraphLayout
Layout.layout ctx title [
div [Class "container"] [
!! title
div [Class "content"] (processedParagraphs |> Array.toList)
]
]
let processTitle (title: string) =
title.ToLower().Replace (" ", "-") + ".html"
let processedContent =
basicPageContent
|> Seq.toList
|> List.map(fun content ->
processTitle (content.Title),
pageContentLayout (content.Title, content.FieldContent) |> Layout.render ctx
)
processedContent
let generate (ctx: SiteContents) (projectRoot: string) (page:string) =
generate' ctx page

View File

@@ -1,40 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#load "layout.fsx"
open Html
let about = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci."
let generate' (ctx : SiteContents) (_: string) =
let siteInfo = ctx.TryGetValue<Globalloader.SiteInfo> ()
let desc =
siteInfo
|> Option.map (fun si -> si.description)
|> Option.defaultValue ""
Layout.layout ctx "Home" [
section [Class "hero is-info is-medium is-bold"] [
div [Class "hero-body"] [
div [Class "container has-text-centered"] [
h1 [Class "title"] [!!desc]
]
]
]
div [Class "container"] [
section [Class "articles"] [
div [Class "column is-8 is-offset-2"] [
div [Class "card article"] [
div [Class "card-content"] [
div [Class "content article-body"] [
!! about
]
]
]
]
]
]]
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page
|> Layout.render ctx

View File

@@ -1,67 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#load "layout.fsx"
open Html
let generate' (ctx : SiteContents) (_: string) =
let posts = ctx.TryGetValues<Postloader.Post> () |> Option.defaultValue Seq.empty
let siteInfo = ctx.TryGetValue<Globalloader.SiteInfo> ()
let desc, postPageSize =
siteInfo
|> Option.map (fun si -> si.description, si.postPageSize)
|> Option.defaultValue ("", 10)
let psts =
posts
|> Seq.sortByDescending Layout.published
|> Seq.toList
|> List.chunkBySize postPageSize
|> List.map (List.map (Layout.postLayout true))
let pages = List.length psts
let getFilenameForIndex i =
if i = 0 then
sprintf "index.html"
else
sprintf "posts/page%i.html" i
let layoutForPostSet i psts =
let nextPage =
if i = (pages - 1) then "#"
else "/" + getFilenameForIndex (i + 1)
let previousPage =
if i = 0 then "#"
else "/" + getFilenameForIndex (i - 1)
Layout.layout ctx "Home" [
section [Class "hero is-info is-medium is-bold"] [
div [Class "hero-body"] [
div [Class "container has-text-centered"] [
h1 [Class "title"] [!!desc]
]
]
]
div [Class "container"] [
section [Class "articles"] [
div [Class "column is-8 is-offset-2"] psts
]
]
div [Class "container"] [
div [Class "container has-text-centered"] [
a [Href previousPage] [!! "Previous"]
!! (sprintf "%i of %i" (i + 1) pages)
a [Href nextPage] [!! "Next"]
]
]]
psts
|> List.mapi (fun i psts ->
getFilenameForIndex i,
layoutForPostSet i psts
|> Layout.render ctx)
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page

View File

@@ -1,106 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#if !FORNAX
#load "../loaders/postloader.fsx"
#load "../loaders/pageloader.fsx"
#load "../loaders/globalloader.fsx"
#endif
open Html
let injectWebsocketCode (webpage:string) =
let websocketScript =
"""
<script type="text/javascript">
var wsUri = "ws://localhost:8080/websocket";
function init()
{
websocket = new WebSocket(wsUri);
websocket.onclose = function(evt) { onClose(evt) };
}
function onClose(evt)
{
console.log('closing');
websocket.close();
document.location.reload();
}
window.addEventListener("load", init, false);
</script>
"""
let head = "<head>"
let index = webpage.IndexOf head
webpage.Insert ( (index + head.Length + 1),websocketScript)
let layout (ctx : SiteContents) active bodyCnt =
let pages = ctx.TryGetValues<Pageloader.Page> () |> Option.defaultValue Seq.empty
let siteInfo = ctx.TryGetValue<Globalloader.SiteInfo> ()
let ttl =
siteInfo
|> Option.map (fun si -> si.title)
|> Option.defaultValue ""
let menuEntries =
pages
|> Seq.map (fun p ->
let cls = if p.title = active then "navbar-item is-active" else "navbar-item"
a [Class cls; Href p.link] [!! p.title ])
|> Seq.toList
html [] [
head [] [
meta [CharSet "utf-8"]
meta [Name "viewport"; Content "width=device-width, initial-scale=1"]
title [] [!! ttl]
link [Rel "icon"; Type "image/png"; Sizes "32x32"; Href "/images/favicon.png"]
link [Rel "stylesheet"; Href "https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"]
link [Rel "stylesheet"; Href "https://fonts.googleapis.com/css?family=Open+Sans"]
link [Rel "stylesheet"; Href "https://unpkg.com/bulma@0.8.0/css/bulma.min.css"]
link [Rel "stylesheet"; Type "text/css"; Href "/style/style.css"]
]
body [] [
nav [Class "navbar"] [
div [Class "container"] [
div [Class "navbar-brand"] [
a [Class "navbar-item"; Href "/"] [
img [Src "/images/bulma.png"; Alt "Logo"]
]
span [Class "navbar-burger burger"; HtmlProperties.Custom ("data-target", "navbarMenu")] [
span [] []
span [] []
span [] []
]
]
div [Id "navbarMenu"; Class "navbar-menu"] menuEntries
]
]
yield! bodyCnt
]
]
let render (ctx : SiteContents) cnt =
let disableLiveRefresh = ctx.TryGetValue<Postloader.PostConfig> () |> Option.map (fun n -> n.disableLiveRefresh) |> Option.defaultValue false
cnt
|> HtmlElement.ToString
|> fun n -> if disableLiveRefresh then n else injectWebsocketCode n
let published (post: Postloader.Post) =
post.published
|> Option.defaultValue System.DateTime.Now
|> fun n -> n.ToString("yyyy-MM-dd")
let postLayout (useSummary: bool) (post: Postloader.Post) =
div [Class "card article"] [
div [Class "card-content"] [
div [Class "media-content has-text-centered"] [
p [Class "title article-title"; ] [ a [Href post.link] [!! post.title]]
p [Class "subtitle is-6 article-subtitle"] [
a [Href "#"] [!! (defaultArg post.author "")]
!! (sprintf "on %s" (published post))
]
]
div [Class "content article-body"] [
!! (if useSummary then post.summary else post.content)
]
]
]

View File

@@ -1,38 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#load "layout.fsx"
open Html
let generate' (ctx : SiteContents) (page: string) =
let post =
ctx.TryGetValues<Postloader.Post> ()
|> Option.defaultValue Seq.empty
|> Seq.find (fun n -> n.file = page)
let siteInfo = ctx.TryGetValue<Globalloader.SiteInfo> ()
let desc =
siteInfo
|> Option.map (fun si -> si.description)
|> Option.defaultValue ""
Layout.layout ctx post.title [
section [Class "hero is-info is-medium is-bold"] [
div [Class "hero-body"] [
div [Class "container has-text-centered"] [
h1 [Class "title"] [!!desc]
]
]
]
div [Class "container"] [
section [Class "articles"] [
div [Class "column is-8 is-offset-2"] [
Layout.postLayout false post
]
]
]
]
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
generate' ctx page
|> Layout.render ctx

View File

@@ -1,7 +0,0 @@
#r "../_lib/Fornax.Core.dll"
open System.IO
let generate (ctx : SiteContents) (projectRoot: string) (page: string) =
let inputPath = Path.Combine(projectRoot, page)
File.ReadAllBytes inputPath

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 482 B

View File

@@ -1,66 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#r "../src/Types/bin/Debug/netstandard2.0/Types.dll"
#r "nuget: FSharp.Data"
#r "nuget: FSharp.SystemTextJson"
open FSharp.Data
open System.Text.Json
open System.Text.Json.Serialization
open Types.Types
let baseUrl = "http://oceanbox.ddev.site/jsonapi/node/page"
type LinkHref = {
Href: string
}
type PaginationLinks = {
Next: Skippable<LinkHref>
}
type Content = {
Data: DocData[]
Links: PaginationLinks
}
let options =
let opts =
JsonFSharpOptions.Default()
.WithAllowNullFields(true)
.WithUnionInternalTag()
.WithUnionNamedFields()
.WithUnionUnwrapRecordCases()
.WithUnionTagName("type")
.ToJsonSerializerOptions()
opts.PropertyNamingPolicy <- JsonNamingPolicy.SnakeCaseLower
opts
let deserialize (str: string) =
JsonSerializer.Deserialize<Content>(str,options)
let fetchPage (url: string): Content =
Http.RequestString url |> deserialize
let rec fetchAllPages (url: string) (acc: DocData[]) =
let page = fetchPage url
let acc' = Array.append acc page.Data
match page.Links.Next with
| Include nextUrl when not (System.String.IsNullOrWhiteSpace nextUrl.Href) ->
fetchAllPages nextUrl.Href acc'
| _ -> acc'
let loader (projectRoot: string) (siteContent: SiteContents) =
// TODO - Update to load using pagination
let json = fetchAllPages baseUrl [||]
// eprintfn "%A" json
json
|> Array.iter siteContent.Add
siteContent

View File

@@ -1,16 +0,0 @@
#r "../_lib/Fornax.Core.dll"
type SiteInfo = {
title: string
description: string
postPageSize: int
}
let loader (projectRoot: string) (siteContent: SiteContents) =
let siteInfo =
{ title = "Sample Fornax blog";
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit"
postPageSize = 5 }
siteContent.Add(siteInfo)
siteContent

View File

@@ -1,13 +0,0 @@
#r "../_lib/Fornax.Core.dll"
type Page = {
title: string
link: string
}
let loader (projectRoot: string) (siteContent: SiteContents) =
siteContent.Add({title = "Home"; link = "/"})
siteContent.Add({title = "About"; link = "/about.html"})
siteContent.Add({title = "Contact"; link = "/contact.html"})
siteContent

View File

@@ -1,129 +0,0 @@
#r "../_lib/Fornax.Core.dll"
#r "../_lib/Markdig.dll"
open System.IO
open Markdig
type PostConfig = {
disableLiveRefresh: bool
}
type Post = {
file: string
link : string
title: string
author: string option
published: System.DateTime option
tags: string list
content: string
summary: string
}
let contentDir = "posts"
let markdownPipeline =
MarkdownPipelineBuilder()
.UsePipeTables()
.UseGridTables()
.Build()
let isSeparator (input : string) =
input.StartsWith "---"
let isSummarySeparator (input: string) =
input.Contains "<!--more-->"
///`fileContent` - content of page to parse. Usually whole content of `.md` file
///returns content of config that should be used for the page
let getConfig (fileContent : string) =
let fileContent = fileContent.Split '\n'
let fileContent = fileContent |> Array.skip 1 //First line must be ---
let indexOfSeperator = fileContent |> Array.findIndex isSeparator
let splitKey (line: string) =
let seperatorIndex = line.IndexOf(':')
if seperatorIndex > 0 then
let key = line.[.. seperatorIndex - 1].Trim().ToLower()
let value = line.[seperatorIndex + 1 ..].Trim()
Some(key, value)
else
None
fileContent
|> Array.splitAt indexOfSeperator
|> fst
|> Seq.choose splitKey
|> Map.ofSeq
///`fileContent` - content of page to parse. Usually whole content of `.md` file
///returns HTML version of content of the page
let getContent (fileContent : string) =
let fileContent = fileContent.Split '\n'
let fileContent = fileContent |> Array.skip 1 //First line must be ---
let indexOfSeperator = fileContent |> Array.findIndex isSeparator
let _, content = fileContent |> Array.splitAt indexOfSeperator
let summary, content =
match content |> Array.tryFindIndex isSummarySeparator with
| Some indexOfSummary ->
let summary, _ = content |> Array.splitAt indexOfSummary
summary, content
| None ->
content, content
let summary = summary |> Array.skip 1 |> String.concat "\n"
let content = content |> Array.skip 1 |> String.concat "\n"
Markdown.ToHtml(summary, markdownPipeline),
Markdown.ToHtml(content, markdownPipeline)
let trimString (str : string) =
str.Trim().TrimEnd('"').TrimStart('"')
let loadFile (rootDir: string) (n: string) =
let text = File.ReadAllText n
let config = getConfig text
let summary, content = getContent text
let chopLength =
if rootDir.EndsWith(Path.DirectorySeparatorChar) then rootDir.Length
else rootDir.Length + 1
let dirPart =
n
|> Path.GetDirectoryName
|> fun x -> x.[chopLength .. ]
let file = Path.Combine(dirPart, (n |> Path.GetFileNameWithoutExtension) + ".md").Replace("\\", "/")
let link = "/" + Path.Combine(dirPart, (n |> Path.GetFileNameWithoutExtension) + ".html").Replace("\\", "/")
let title = config |> Map.find "title" |> trimString
let author = config |> Map.tryFind "author" |> Option.map trimString
let published = config |> Map.tryFind "published" |> Option.map (trimString >> System.DateTime.Parse)
let tags =
let tagsOpt =
config
|> Map.tryFind "tags"
|> Option.map (trimString >> fun n -> n.Split ',' |> Array.toList)
defaultArg tagsOpt []
{ file = file
link = link
title = title
author = author
published = published
tags = tags
content = content
summary = summary }
let loader (projectRoot: string) (siteContent: SiteContents) =
let postsPath = Path.Combine(projectRoot, contentDir)
let options = EnumerationOptions(RecurseSubdirectories = true)
let files = Directory.GetFiles(postsPath, "*", options)
files
|> Array.filter (fun n -> n.EndsWith ".md")
|> Array.map (loadFile projectRoot)
|> Array.iter siteContent.Add
siteContent.Add({disableLiveRefresh = false})
siteContent

View File

@@ -1,15 +0,0 @@
---
layout: post
title: Some nice post title
author: @k_cieslak
published: 2020-02-19
---
# Introduction
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
Phasellus aliquam tellus eu augue vulputate laoreet. Nunc tincidunt sed mauris eu vestibulum. Quisque id ex eget erat elementum euismod vel nec ex. Nunc et blandit neque. Duis erat ex, facilisis non consectetur sit amet, consectetur mattis ex. Vestibulum quis ligula pharetra, semper nibh nec, porta augue. In placerat auctor risus, eu dictum purus iaculis et. Vivamus viverra sollicitudin augue, in sollicitudin leo malesuada non.
In hac habitasse platea dictumst. Quisque a diam egestas, ornare felis quis, gravida arcu. In vel tellus facilisis, rhoncus ligula sit amet, feugiat massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur varius interdum dolor, ut pretium augue egestas id. Aenean vulputate commodo nibh tristique egestas. Interdum et malesuada fames ac ante ipsum primis in faucibus. Vivamus elementum non mi sit amet lacinia.

View File

@@ -1,15 +0,0 @@
---
layout: post
title: Some other post title
author: @k_cieslak
published: 2020-02-20
---
# Something else
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
In est nulla, ornare vitae elit sed, consequat sollicitudin dui. Duis posuere nulla malesuada elit volutpat ultricies. Mauris et tellus tortor. In ligula elit, pellentesque eget est et, mattis rhoncus nisl. Sed orci ex, mollis quis justo eu, dapibus tincidunt turpis. Sed rhoncus odio non lacus ullamcorper volutpat. Suspendisse blandit ullamcorper condimentum. Quisque et viverra nisi. Vivamus in mollis nulla. Nulla faucibus sed ligula et blandit.
Vivamus nec libero faucibus, cursus ex et, consequat mauris. Pellentesque commodo ullamcorper vestibulum. Donec efficitur, ipsum et dapibus varius, purus mauris gravida augue, eu mattis lorem turpis eget dui. Curabitur nibh erat, posuere sed eros a, blandit venenatis risus. Vestibulum cursus imperdiet ultrices. In eu efficitur dui, eget tempus enim. Nunc imperdiet, enim et sagittis lacinia, lacus metus eleifend purus, blandit pellentesque leo ante ac velit. Nam ac sagittis est. Cras accumsan, odio vel lacinia mollis, metus tortor malesuada nisi, et consectetur neque quam in erat. Sed ultricies aliquam hendrerit. Etiam non aliquam ipsum, id rutrum magna. Morbi id tincidunt mauris. Vestibulum nec iaculis massa. Etiam fringilla, orci quis faucibus vulputate, risus nibh finibus nisl, et vehicula ipsum leo a tortor. Curabitur mauris elit, bibendum vitae velit at, dignissim ornare arcu.

View File

@@ -1,15 +0,0 @@
---
layout: post
title: On this great day
author: @k_cieslak
published: 2020-03-20
---
# On this great day
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
In est nulla, ornare vitae elit sed, consequat sollicitudin dui. Duis posuere nulla malesuada elit volutpat ultricies. Mauris et tellus tortor. In ligula elit, pellentesque eget est et, mattis rhoncus nisl. Sed orci ex, mollis quis justo eu, dapibus tincidunt turpis. Sed rhoncus odio non lacus ullamcorper volutpat. Suspendisse blandit ullamcorper condimentum. Quisque et viverra nisi. Vivamus in mollis nulla. Nulla faucibus sed ligula et blandit.
Vivamus nec libero faucibus, cursus ex et, consequat mauris. Pellentesque commodo ullamcorper vestibulum. Donec efficitur, ipsum et dapibus varius, purus mauris gravida augue, eu mattis lorem turpis eget dui. Curabitur nibh erat, posuere sed eros a, blandit venenatis risus. Vestibulum cursus imperdiet ultrices. In eu efficitur dui, eget tempus enim. Nunc imperdiet, enim et sagittis lacinia, lacus metus eleifend purus, blandit pellentesque leo ante ac velit. Nam ac sagittis est. Cras accumsan, odio vel lacinia mollis, metus tortor malesuada nisi, et consectetur neque quam in erat. Sed ultricies aliquam hendrerit. Etiam non aliquam ipsum, id rutrum magna. Morbi id tincidunt mauris. Vestibulum nec iaculis massa. Etiam fringilla, orci quis faucibus vulputate, risus nibh finibus nisl, et vehicula ipsum leo a tortor. Curabitur mauris elit, bibendum vitae velit at, dignissim ornare arcu.

View File

@@ -1,15 +0,0 @@
---
layout: post
title: We learnt
author: @k_cieslak
published: 2020-03-25
---
# We learnt
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
In est nulla, ornare vitae elit sed, consequat sollicitudin dui. Duis posuere nulla malesuada elit volutpat ultricies. Mauris et tellus tortor. In ligula elit, pellentesque eget est et, mattis rhoncus nisl. Sed orci ex, mollis quis justo eu, dapibus tincidunt turpis. Sed rhoncus odio non lacus ullamcorper volutpat. Suspendisse blandit ullamcorper condimentum. Quisque et viverra nisi. Vivamus in mollis nulla. Nulla faucibus sed ligula et blandit.
Vivamus nec libero faucibus, cursus ex et, consequat mauris. Pellentesque commodo ullamcorper vestibulum. Donec efficitur, ipsum et dapibus varius, purus mauris gravida augue, eu mattis lorem turpis eget dui. Curabitur nibh erat, posuere sed eros a, blandit venenatis risus. Vestibulum cursus imperdiet ultrices. In eu efficitur dui, eget tempus enim. Nunc imperdiet, enim et sagittis lacinia, lacus metus eleifend purus, blandit pellentesque leo ante ac velit. Nam ac sagittis est. Cras accumsan, odio vel lacinia mollis, metus tortor malesuada nisi, et consectetur neque quam in erat. Sed ultricies aliquam hendrerit. Etiam non aliquam ipsum, id rutrum magna. Morbi id tincidunt mauris. Vestibulum nec iaculis massa. Etiam fringilla, orci quis faucibus vulputate, risus nibh finibus nisl, et vehicula ipsum leo a tortor. Curabitur mauris elit, bibendum vitae velit at, dignissim ornare arcu.

View File

@@ -1,15 +0,0 @@
---
layout: post
title: All about
author: @k_cieslak
published: 2020-04-21
---
# All about
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
In est nulla, ornare vitae elit sed, consequat sollicitudin dui. Duis posuere nulla malesuada elit volutpat ultricies. Mauris et tellus tortor. In ligula elit, pellentesque eget est et, mattis rhoncus nisl. Sed orci ex, mollis quis justo eu, dapibus tincidunt turpis. Sed rhoncus odio non lacus ullamcorper volutpat. Suspendisse blandit ullamcorper condimentum. Quisque et viverra nisi. Vivamus in mollis nulla. Nulla faucibus sed ligula et blandit.
Vivamus nec libero faucibus, cursus ex et, consequat mauris. Pellentesque commodo ullamcorper vestibulum. Donec efficitur, ipsum et dapibus varius, purus mauris gravida augue, eu mattis lorem turpis eget dui. Curabitur nibh erat, posuere sed eros a, blandit venenatis risus. Vestibulum cursus imperdiet ultrices. In eu efficitur dui, eget tempus enim. Nunc imperdiet, enim et sagittis lacinia, lacus metus eleifend purus, blandit pellentesque leo ante ac velit. Nam ac sagittis est. Cras accumsan, odio vel lacinia mollis, metus tortor malesuada nisi, et consectetur neque quam in erat. Sed ultricies aliquam hendrerit. Etiam non aliquam ipsum, id rutrum magna. Morbi id tincidunt mauris. Vestibulum nec iaculis massa. Etiam fringilla, orci quis faucibus vulputate, risus nibh finibus nisl, et vehicula ipsum leo a tortor. Curabitur mauris elit, bibendum vitae velit at, dignissim ornare arcu.

View File

@@ -1,15 +0,0 @@
---
layout: post
title: Paging
author: @k_cieslak
published: 2020-05-01
---
# Paging
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
In est nulla, ornare vitae elit sed, consequat sollicitudin dui. Duis posuere nulla malesuada elit volutpat ultricies. Mauris et tellus tortor. In ligula elit, pellentesque eget est et, mattis rhoncus nisl. Sed orci ex, mollis quis justo eu, dapibus tincidunt turpis. Sed rhoncus odio non lacus ullamcorper volutpat. Suspendisse blandit ullamcorper condimentum. Quisque et viverra nisi. Vivamus in mollis nulla. Nulla faucibus sed ligula et blandit.
Vivamus nec libero faucibus, cursus ex et, consequat mauris. Pellentesque commodo ullamcorper vestibulum. Donec efficitur, ipsum et dapibus varius, purus mauris gravida augue, eu mattis lorem turpis eget dui. Curabitur nibh erat, posuere sed eros a, blandit venenatis risus. Vestibulum cursus imperdiet ultrices. In eu efficitur dui, eget tempus enim. Nunc imperdiet, enim et sagittis lacinia, lacus metus eleifend purus, blandit pellentesque leo ante ac velit. Nam ac sagittis est. Cras accumsan, odio vel lacinia mollis, metus tortor malesuada nisi, et consectetur neque quam in erat. Sed ultricies aliquam hendrerit. Etiam non aliquam ipsum, id rutrum magna. Morbi id tincidunt mauris. Vestibulum nec iaculis massa. Etiam fringilla, orci quis faucibus vulputate, risus nibh finibus nisl, et vehicula ipsum leo a tortor. Curabitur mauris elit, bibendum vitae velit at, dignissim ornare arcu.

View File

@@ -1,15 +0,0 @@
---
layout: post
title: A post in sub-dir
author: @k_cieslak
published: 2020-04-29
---
# A post in sub-dir
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi nisi diam, vehicula quis blandit id, suscipit sed libero. Proin at diam dolor. In hac habitasse platea dictumst. Donec quis dui vitae quam eleifend dignissim non sed libero. In hac habitasse platea dictumst. In ullamcorper mollis risus, a vulputate quam accumsan at. Donec sed felis sodales, blandit orci id, vulputate orci.
<!--more-->
In est nulla, ornare vitae elit sed, consequat sollicitudin dui. Duis posuere nulla malesuada elit volutpat ultricies. Mauris et tellus tortor. In ligula elit, pellentesque eget est et, mattis rhoncus nisl. Sed orci ex, mollis quis justo eu, dapibus tincidunt turpis. Sed rhoncus odio non lacus ullamcorper volutpat. Suspendisse blandit ullamcorper condimentum. Quisque et viverra nisi. Vivamus in mollis nulla. Nulla faucibus sed ligula et blandit.
Vivamus nec libero faucibus, cursus ex et, consequat mauris. Pellentesque commodo ullamcorper vestibulum. Donec efficitur, ipsum et dapibus varius, purus mauris gravida augue, eu mattis lorem turpis eget dui. Curabitur nibh erat, posuere sed eros a, blandit venenatis risus. Vestibulum cursus imperdiet ultrices. In eu efficitur dui, eget tempus enim. Nunc imperdiet, enim et sagittis lacinia, lacus metus eleifend purus, blandit pellentesque leo ante ac velit. Nam ac sagittis est. Cras accumsan, odio vel lacinia mollis, metus tortor malesuada nisi, et consectetur neque quam in erat. Sed ultricies aliquam hendrerit. Etiam non aliquam ipsum, id rutrum magna. Morbi id tincidunt mauris. Vestibulum nec iaculis massa. Etiam fringilla, orci quis faucibus vulputate, risus nibh finibus nisl, et vehicula ipsum leo a tortor. Curabitur mauris elit, bibendum vitae velit at, dignissim ornare arcu.

View File

@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Lib</OutputType>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="types.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" />
</ItemGroup>
</Project>

View File

@@ -1,75 +0,0 @@
open System.Text.Json
open System.Text.Json.Serialization
module Types =
type Path = {
Alias: string
Langcode: string
}
type Uri = {
Url: string
}
type ImageMeta = {
Alt: string
Title: string
Width: string
Height: string
}
type VideoMeta = {
display: string
description: string
}
type ImageFile = {
Uri: Uri
Meta: ImageMeta
}
type VideoFile = {
Uri: Uri
Meta: VideoMeta
}
type MediaImage = {
FieldMediaImage: ImageFile
}
type MediaVideo = {
FieldMediaVideoFile: VideoFile
}
type Media =
| [<JsonName("media--image")>]
MediaImage of MediaImage
| [<JsonName("media--video")>]
MediaVideo of MediaVideo
type Hero = {
FieldTitle: string
FieldSubtitle: string
FieldHeroSize: string
FieldBackgroundImage: Media
}
type SimpleText = {
FieldBody: string
}
type Paragraph =
| [<JsonName("paragraph--hero_banner")>]
Hero of Hero
| [<JsonName("paragraph--simple_text")>]
SimpleText of SimpleText
| Ignore
type DocData = {
Type: string
Id: string
Title: string
Path: Path
FieldContent: Paragraph[]
}

View File

@@ -1,73 +0,0 @@
html,body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
font-size: 14px;
background: #F0F2F4;
}
.navbar.is-white {
background: #F0F2F4;
}
.navbar-brand .brand-text {
font-size: 1.11rem;
font-weight: bold;
}
.hero-body
{background-image: url(https://upload.wikimedia.org/wikipedia/commons/thumb/f/f6/Plum_trees_Kitano_Tenmangu.jpg/1200px-Plum_trees_Kitano_Tenmangu.jpg);
background-position: center;
background-size: cover;
background-repeat: no-repeat;
height: 500px;
}
.articles {
margin: 5rem 0;
margin-top: -200px;
}
.articles .content p {
line-height: 1.9;
margin: 15px 0;
}
.author-image {
position: absolute;
top: -30px;
left: 50%;
width: 60px;
height: 60px;
margin-left: -30px;
border: 3px solid #ccc;
border-radius: 50%;
}
.media-center {
display: block;
margin-bottom: 1rem;
}
.media-content {
margin-top: 3rem;
}
.article, .promo-block {
margin-top: 6rem;
}
div.column.is-8:first-child {
padding-top: 0;
margin-top: 0;
}
.article-title {
font-size: 2rem;
font-weight: lighter;
line-height: 2;
}
.article-subtitle {
color: #909AA0;
margin-bottom: 3rem;
}
.article-body {
line-height: 1.4;
margin: 0 6rem;
}
@media screen and (max-width: 991px) {
.article-body {
line-height: 1.4;
margin: 0;
}
}
.promo-block .container {
margin: 1rem 5rem;
}

View File

@@ -0,0 +1,33 @@
#r "../_lib/Fornax.Core.dll"
#r "../src/Shared/bin/Debug/netstandard2.0/Shared.dll"
#r "nuget: Feliz.ViewEngine"
open Shared.Data.Types
open Shared.Layout
open Shared.Pages
open Shared.Data.Serializer
open Feliz.ViewEngine
let processTitle (title: string) =
title.ToLower().Replace(" ", "-") + ".html"
let generate' (ctx: SiteContents) (_: string) =
let basicPages = ctx.TryGetValues<DocData>() |> Option.defaultValue Seq.empty
basicPages
|> Seq.map (fun doc ->
let fileName = processTitle doc.Title
let content = BasicPage.render doc
let jsonData = serialize doc
let pageLayout = defaultLayout doc.Title jsonData
let html =
pageLayout content
|> Render.htmlDocument
"basicPage/" + fileName, html
)
|> Seq.toList
let generate (ctx: SiteContents) (projectRoot: string) (page: string) =
generate' ctx page

View File

@@ -0,0 +1,17 @@
#r "../_lib/Fornax.Core.dll"
#r "../src/Shared/bin/Debug/netstandard2.0/Shared.dll"
// Packages need to be referenced from fsx files as well
#r "nuget: FSharp.SystemTextJson"
#r "nuget: FSharp.Data"
open Shared.Data.JsonApi
let loader (projectRoot: string) (siteContent: SiteContents) =
let json = getBasicPages
json
|> Array.iter siteContent.Add
siteContent

9896
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"private": true,
"scripts": {
"start": "vite src/Client",
"build": "vite build -p",
"tailwind:build": "tailwindcss -i src/Client/public/style.css -o dist/public/tailwindStyle.css",
"test": "vite test/Client"
},
"devDependencies": {
"@semantic-release/changelog": "^6.0.2",
"@semantic-release/exec": "^7.1.0",
"@semantic-release/git": "^10.0.1",
"@semantic-release/gitlab": "^13.2.9",
"sass": "^1.58.3",
"semantic-release": "^25.0.2",
"semantic-release-dotnet": "^1.0.0",
"vite": "^7.2.2"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.1.0",
"@microsoft/signalr": "^10.0.0",
"@sentry/browser": "^10.25.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"daisyui": "^5.5.5",
"lit": "^3.1.0",
"lit-html": "^3.1.0",
"open-sans-fonts": "^1.6.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.17"
}
}

23
shell.nix Normal file
View File

@@ -0,0 +1,23 @@
with import <nixpkgs> { };
let
port = 8000;
in
pkgs.mkShell {
nativeBuildInputs = [
tilt
dapr-cli
dotnetCorePackages.sdk_9_0
tailwindcss_4
bun
];
DOCKER_BUILDKIT = 0;
LOG_LEVEL = 4;
CLIENT_PORT = port + 80;
SERVER_PORT = port + 85;
SERVER_PROXY_PORT = port + 85;
TILT_PORT = port + 50;
shellHook = '''';
}

53
src/Client/App.fs Normal file
View File

@@ -0,0 +1,53 @@
module App
open Browser
open Elmish
open Elmish.React
open Shared.Data.ThothTypes
open Shared.Data.ThothSerializerManual
open Shared.Pages
open Thoth.Json
open Feliz
open Fable.Core.JsInterop
importSideEffects "./public/style.css"
open type Html
type Model = { Page: DocData option }
type Msg = | NoOp
let decodeInitialData () =
let script = document.getElementById "__FORNIX_DATA__"
console.log (script)
if isNull script then
None
else
let json = script.textContent
let jsonPageData = Decode.fromString entityDecoder json
let pageData =
match jsonPageData with
| Ok pd -> pd.data
| Error err -> failwithf "Couldn't decode the json dat"
Some pageData
let init () : Model * Cmd<Msg> =
{ Page = decodeInitialData () }, Cmd.none
let update msg model =
match msg with
| NoOp -> model, Cmd.none
let view (model: Model) (dispatch: Msg -> unit) =
match model.Page with
| None -> div [ text "Couldn't match data" ]
| Some content -> BasicPage.render content
let program = Program.mkProgram init update view |> Program.withReactHydrate "app"
program |> Program.run

28
src/Client/Client.fsproj Normal file
View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<DefineConstants>FABLE_COMPILER</DefineConstants>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Shared/Shared.fsproj" />
</ItemGroup>
<ItemGroup>
<None Include="public/style.scss" />
<Compile Include="../Shared/Shared.fs" />
<Compile Include="App.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FSharp.Data" Version="6.6.0" />
<PackageReference Include="Feliz" version="2.9.0" />
<PackageReference Include="Fable.Elmish.React" Version="5.0.0" />
<PackageReference Include="Fable.Browser.Dom" Version="2.20.0" />
<PackageReference Include="Fable.Core" Version="4.5.0" />
<PackageReference Include="Fable.FontAwesome.Free" Version="3.0.0" />
<PackageReference Include="Fable.Remoting.Client" Version="7.35.0" />
<PackageReference Include="Elmish" Version="5.0.2" />
<PackageReference Include="Thoth.Json" Version="10.4.1" />
<PackageReference Update="FSharp.Core" Version="9.0.303" />
</ItemGroup>
</Project>

6
src/Client/main.html Normal file
View File

@@ -0,0 +1,6 @@
<!doctype html>
<html lang="en">
<body>
<script type="module" src="./build/client/App.js"></script>
</body>
</html>

View File

@@ -0,0 +1,377 @@
{
"version": 1,
"dependencies": {
"net9.0": {
"Elmish": {
"type": "Direct",
"requested": "[5.0.2, )",
"resolved": "5.0.2",
"contentHash": "EUSEpxcXAZ93L02FSTBwP4AQGIZBnJT3udI/DE2kDIir7rjtcolTSjUjk41AgR+C69MvuHi18cN8TPh4L8EY3w=="
},
"Fable.Browser.Dom": {
"type": "Direct",
"requested": "[2.20.0, )",
"resolved": "2.20.0",
"contentHash": "8MnTBQ3NtWABhT8KXJZ7dracK4La3LrX265eqRJiiXayU6acNvzjBltYsyMmarqUhRf723e3hyHTKNOLQITosQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Blob": "1.4.0",
"Fable.Browser.Event": "1.7.0",
"Fable.Browser.WebStorage": "1.3.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Core": {
"type": "Direct",
"requested": "[4.5.0, )",
"resolved": "4.5.0",
"contentHash": "ZcX8XN5sQZGCbrS8VYnsa/ynhrCBfpDyqDkTl33GTXOpgfKibxoq0+W0hCSbRzuukVNoLtqGL/B6+8yTNDXbNA=="
},
"Fable.Elmish.React": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "KNvaKlGyvPtFu93uOPYYfGAOMBWnaEA3iFrPxbpMd8X1x2RZw8hkd0pYsqSPoDfGTFR1Y7LHwKNj9A13YJTy4g==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Elmish": "5.0.0",
"Fable.ReactDom.Types": "18.2.0"
}
},
"Fable.FontAwesome.Free": {
"type": "Direct",
"requested": "[3.0.0, )",
"resolved": "3.0.0",
"contentHash": "3+MdXEr0mZon0FOZiu39tO50FDqS89IYoq7efUNLWwAZe/XBscSwNNGBnRk4PHTAz5v9WZ7hoYCLkdG5TqCmpw==",
"dependencies": {
"Fable.Core": "3.7.1",
"Fable.FontAwesome": "3.0.0",
"Fable.React": "9.2.0"
}
},
"Fable.Remoting.Client": {
"type": "Direct",
"requested": "[7.35.0, )",
"resolved": "7.35.0",
"contentHash": "57StsvefN9NZorEbOsjngDXjn0JDxDG36S8ikDQdAC/WTi5n7kZChZ1v+0CMiZ2IU9aarprOLvr1ie5NKN9IZA==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.XMLHttpRequest": "1.0.0",
"Fable.Core": "3.1.5",
"Fable.Remoting.MsgPack": "1.25.0",
"Fable.SimpleJson": "3.24.0"
}
},
"Feliz": {
"type": "Direct",
"requested": "[2.9.0, )",
"resolved": "2.9.0",
"contentHash": "8nyGREGA60RysdSBamVWmr68MG+3lLy76W17fBiGaKi7uMFbtRcYBLyNtp2NyGZFfnuWCEyDAmAXM5YFnDhbhg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.ReactDom.Types": "18.2.0",
"Feliz.CompilerPlugins": "2.2.0"
}
},
"FSharp.Core": {
"type": "Direct",
"requested": "[9.0.303, )",
"resolved": "9.0.303",
"contentHash": "6JlV8aD8qQvcmfoe/PMOxCHXc0uX4lR23u0fAyQtnVQxYULLoTZgwgZHSnRcuUHOvS3wULFWcwdnP1iwslH60g=="
},
"FSharp.Data": {
"type": "Direct",
"requested": "[6.6.0, )",
"resolved": "6.6.0",
"contentHash": "AC87/VZV1iGrnK1uG6QhX22HZARlFiCqdlckzPDgdE3+fzc0ZNRR3Y4ny5vbLvfDj2rjFoJdmRZy6kzI1C/sQQ==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Csv.Core": "6.6.0",
"FSharp.Data.Html.Core": "6.6.0",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Json.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0",
"FSharp.Data.WorldBank.Core": "6.6.0",
"FSharp.Data.Xml.Core": "6.6.0"
}
},
"Thoth.Json": {
"type": "Direct",
"requested": "[10.4.1, )",
"resolved": "10.4.1",
"contentHash": "hs76/uO+gHhvnlaxQDqbpUX2Y0L97ilEZ1Nx+LA4D6N7fuAYJmNwQWZB/KQLBE7wIeWK5oXMFHCuKdImSrF1Bg==",
"dependencies": {
"FSharp.Core": "5.0.2",
"Fable.Core": "4.1.0"
}
},
"Fable.AST": {
"type": "Transitive",
"resolved": "4.2.1",
"contentHash": "/4V6U7Qw/WIRRxm9NJ7b+YTXTRCTk6/YKeJnbKYaVbtT45MstA3jkFvRfV0FqVFtkG9AL4uccetreygTjK7nbQ=="
},
"Fable.Browser.Blob": {
"type": "Transitive",
"resolved": "1.4.0",
"contentHash": "UlaxrIXUfMmABjP+8a4XJp/Af+eCRKa8KJ57Olq4sqphmPLn/gNtp3sk5hRNBZ385lwUszbO5yd3Q/rrl9BdOQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.Event": {
"type": "Transitive",
"resolved": "1.7.0",
"contentHash": "x+wqXQK0l4VlCnELDp68GC/mZAx6NbicDxYPliyAoNq8RPNDeR3R782icNwI5YmA+ufq11XvG6w1JjsL/ldy7w==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Gamepad": "1.3.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.Gamepad": {
"type": "Transitive",
"resolved": "1.3.0",
"contentHash": "C4HZDzCgff+U094QjpQlJh425W5j5/vojvOi2FV5UFS34l7TJ6YBgBPpKoro02QhAi/UF3AeocR+V2yiYxHb0A==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.WebStorage": {
"type": "Transitive",
"resolved": "1.3.0",
"contentHash": "x8JL9oEtPiK0JY4GrRTqhomiLxT6Jaiv5uu8VXiNeA78bFvUogZWxQeejsK83iNFGErK5wpdiPd0tsREZTRLeg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Event": "1.6.0",
"Fable.Core": "3.2.8"
}
},
"Fable.Browser.XMLHttpRequest": {
"type": "Transitive",
"resolved": "1.0.0",
"contentHash": "/XHivZ/GQXlLgNSIDyLnAOwWzP4iOUURCobmNrAnm9GCWo6Lxw7Z+vkxjV2BVC8K+TFI38gPOlHPzyDuwXXA2g==",
"dependencies": {
"FSharp.Core": "4.5.2",
"Fable.Browser.Dom": "1.0.0",
"Fable.Core": "3.0.0"
}
},
"Fable.Elmish": {
"type": "Transitive",
"resolved": "5.0.0",
"contentHash": "jPWkW8StcCsBIFnDfnCm8RsonJlpCrW9sKmsYySPBbpMeEgzE2xIvTCj5OhsYxwDMpBRS2EDDckjMUyefVJsOg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "4.0.0"
}
},
"Fable.FontAwesome": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "RidSfWDoBph4Re6NS5CusvhVMbMjbdWqPHeqeiAvjUc6bVmwyCv17SoVfe5cOc7qIZEcQh3UkOFzGguqvhH2cA==",
"dependencies": {
"Fable.Core": "3.7.1",
"Fable.React": "9.2.0"
}
},
"Fable.Parsimmon": {
"type": "Transitive",
"resolved": "4.0.0",
"contentHash": "AaHqEcwjjv8q5S2gCNu6XsVcpChYM8D6aEb3sjjsAiLspwLrNLqm6vOEKdJKGnh0gSLHg6UWzLGA/Q4jrk+t/w==",
"dependencies": {
"FSharp.Core": "4.6.2",
"Fable.Core": "3.0.0"
}
},
"Fable.React": {
"type": "Transitive",
"resolved": "9.2.0",
"contentHash": "ZMRkZpBy+6ytUHPk0WjRh15kigncmSGLOAbtf12VaSy48mtgqIW4lC/23X/FEvz8tUGwHvBUen2GzUU1G/L+RQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.React.Types": "18.3.0",
"Fable.ReactDom.Types": "18.2.0"
}
},
"Fable.React.Types": {
"type": "Transitive",
"resolved": "18.3.0",
"contentHash": "/b8WZ3Bdfhqy9r60ZK9JGZaGNjIMb0ogsrvWIg3k7KfCEvJs5X6+7hCybVkyjVoxwzn9wLyYGRbh5wmuHQT/Vg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Dom": "2.4.4",
"Fable.Core": "3.2.7"
}
},
"Fable.ReactDom.Types": {
"type": "Transitive",
"resolved": "18.2.0",
"contentHash": "2WoBjsLiSgrvR68OJXko0iVaqeMbkPM5Bx813A1WlxOSCJ50M9fwnlwG/MUEZtiOIhQmku/YTJY5a8E8r1+j2Q==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.React.Types": "18.3.0"
}
},
"Fable.Remoting.MsgPack": {
"type": "Transitive",
"resolved": "1.25.0",
"contentHash": "FyqSj8j8J0W7xTR8XJmk2Q6vZa0NlKUUjQtr/rQhCkV2r5uJ3gtT+2KSiMjdzemwV5X+9eUz6C5q1YN0t3ccug==",
"dependencies": {
"FSharp.Core": "4.7.2"
}
},
"Fable.SimpleJson": {
"type": "Transitive",
"resolved": "3.24.0",
"contentHash": "mNk5s+8arkrrupT52/840xybT/DmaPUsJ926fTHk2uHOaWLnyNbUPY63Yg8zJZFCxSCzWrFpmB8rS9fcLVLJSg==",
"dependencies": {
"FSharp.Core": "4.7.0",
"Fable.Core": "3.1.5",
"Fable.Parsimmon": "4.0.0"
}
},
"Feliz.CompilerPlugins": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ACkO++Hp4lUrEx/axeehIL5/3R8jMnak+CYpzd0/kLpejp9BETtrgjHK7oj6Lh3V9fB7WoAKsCxyPSrm4ADN2w==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.AST": "4.2.1"
}
},
"Feliz.ViewEngine": {
"type": "Transitive",
"resolved": "0.27.0",
"contentHash": "D6OLAuwPUoxa1VcBLjcs/eArfmqxdtyv1N0piaup40lcDjt4V7CQURr7EpUaOkTU770Hbmi2V3RCgq7XmL17Zw==",
"dependencies": {
"Fsharp.Core": "[4.7.0, 5.0.0)"
}
},
"FSharp.Data.Csv.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "gbmHT+XraE1OjUyf/+bmIaluoMMj/34MC5IX0QNWr+K8fu+UjKg854p+fkG5JkzWej0+46CTV7hW/CWP425naA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Html.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "YxImykk5fUxw+rwvD26JfZYn/OAUataebsUobXu8KzkLtl/5fOhiPqpI54jAT4ysv2wHD5coCp30+5UsQXqxBQ==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Csv.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Http": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "7nFDLYPqEps/F3Ay65Xm3YvS3OmARYB24xYv1CszOqurTZ76wM9vnsvyeFOMcCKXA+QgwK+eCa8cgyavmR1YxA==",
"dependencies": {
"FSharp.Core": "6.0.1"
}
},
"FSharp.Data.Json.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "VhLkZwyzeMWsJHivY4GjLqIWh+gIm0rWW0BBlAh1M7XzmmumbPLDehUqOh6Nkm1R+N8iBhFgj7HBq6bmQzGnZA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Runtime.Utilities": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "iLkCY0mQGxuIE2sEIFFOov9ZmWPwlQ0Jq51Xgqv+e0hbAPf2p4OaGp/DR4ye6VQCyVzHVCp9XyJQeHOhoWCQvA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0"
}
},
"FSharp.Data.WorldBank.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "Tg6Hk0+61McNviq+zATBhIDoyslV07hZjYsrzz6IHagbze+1JNiz8FCpwsHh0uAzkdNUZK2/qsrHtMQvM0lpdg==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Json.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Xml.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "Z7U4mO0FHT5uHOYRLSK67izqJrCSvWpjcDPRQkJmYB5ge0sRfHWoKV8b4ttHLmpEg/eAdu64u00nITxpbZm+CA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Json.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.SystemTextJson": {
"type": "Transitive",
"resolved": "1.4.36",
"contentHash": "1xLk0SBF1nedD74B77rcArjD2d+DeZwbNI7BVCAyKwIBERo1VoX8Mf4AtX0OV2L3ZeM/XS1b6BuhXCyoh0KBnw==",
"dependencies": {
"FSharp.Core": "4.7.0",
"System.Text.Json": "6.0.10"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.1",
"contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A=="
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "M1eb3nfXntaRJPrrMVM9EFS8I1bDTnt0uvUS6QP/SicZf/ZZjydMD5NiXxfmwW/uQwaMDP/yX2P+zQN1NBHChg=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "257hh1ep1Gqm1Lm0ulxf7vVBVMJuGN6EL4xSWjpi46DffXzm1058IiWsfSC06zSm7SniN+Tb5160UnXsSa8rRg=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "1Dpjwq9peG/Wt5BNbrzIhTpclfOSqBWZsUO28vVr59yQlkvL5jLBWfpfzRmJ1OY+6DciaY0DUcltyzs4fuZHjw==",
"dependencies": {
"System.IO.Pipelines": "10.0.0",
"System.Text.Encodings.Web": "10.0.0"
}
},
"Thoth.Json.Net": {
"type": "Transitive",
"resolved": "12.0.0",
"contentHash": "n2YyONYdWCFS4Pu7wrqgV/l5tPuN+t3gxEfs/2RwqCiQkRnbgKs9dK61zHeZS5YganAoFbxLSwbaNL7SvSrS9g==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.1.6",
"Newtonsoft.Json": "13.0.1"
}
},
"shared": {
"type": "Project",
"dependencies": {
"FSharp.Core": "[9.0.303, )",
"FSharp.Data": "[6.6.0, )",
"FSharp.SystemTextJson": "[1.4.36, )",
"Feliz": "[2.9.0, )",
"Feliz.ViewEngine": "[0.27.0, )",
"System.Text.Json": "[10.0.0, )",
"Thoth.Json.Net": "[12.0.0, )"
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

View File

@@ -0,0 +1,6 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: all;
}
@plugin "@tailwindcss/typography";
@source "../build/client/**/*.{js,ts,jsx,tsx}";

70
src/Server/JsonApi.fs Normal file
View File

@@ -0,0 +1,70 @@
namespace Shared.Data
open FSharp.Data
open Thoth.Json.Net
open Shared.Data.ThothTypes
open Shared.Data.ThothSerializerManual
module JsonApi =
let baseUrl = "http://oceanbox.ddev.site/"
let nodeUrl (contentType: string) =
baseUrl + "/jsonapi/node/" + contentType
let decoupledUrl (alias: string) =
baseUrl + $"/router/translate-path?path=/{alias}"
let getJsonApiPage (url: string) : JsonApiContent =
let rawData = Http.RequestString url
let jsonData = Decode.fromString pageDecoder rawData
match jsonData with
| Ok jsonApiPage -> jsonApiPage
| Error err ->
failwithf $"Failed to decode from '{url}': {err}"
let getJsonApiPageBySlug (alias: string) : EntityContent =
let rawDecoupledContent =
alias
|> decoupledUrl
|> Http.RequestString
let jsonDecoupledContent = Decode.Auto.fromString<DecoupledPage> rawDecoupledContent
let decoupledContent =
match jsonDecoupledContent with
| Ok dc -> dc
| Error err ->
failwithf $"Failed to decode from '{decoupledUrl alias}': {err}"
let rawJsonApiPage = Http.RequestString decoupledContent.jsonapi.individual
let jsonApiPageContent = Decode.fromString entityDecoder rawJsonApiPage
let pageContent =
match jsonApiPageContent with
| Ok pc -> pc
| Error err ->
failwithf $"Failed to decode from '{decoupledContent.jsonapi.individual}': {err}"
pageContent
let getAll (getPage: string -> JsonApiContent) (startUrl: string): DocData[] =
let rec retrieveData (url: string) (acc: DocData[]) =
let page = getPage url
let acc' = Array.append acc page.data
match page.links.next with
| Some nextUrl when not (System.String.IsNullOrWhiteSpace nextUrl.href) ->
retrieveData nextUrl.href acc'
| _ -> acc'
retrieveData startUrl [||]
let private getAllJsonApiPages = getAll getJsonApiPage
let private getAllByContentType (contentType: string): DocData[] =
contentType
|> nodeUrl
|> getAllJsonApiPages
let getBasicPages =
getAllByContentType "page"

117
src/Server/Server.fs Normal file
View File

@@ -0,0 +1,117 @@
module Server
open FSharp.SystemTextJson
open Shared
open Shared.Data.JsonApi
open Shared.Data.ThothSerializerManual
open Thoth.Json.Net
open Shared.Pages
open Shared.Layout
open System
open Feliz.ViewEngine
open Microsoft.AspNetCore.Http
open Fable.Remoting.Server
open Fable.Remoting.Giraffe
open FSharp.Control
open FSharp.Data
open Saturn
open Giraffe
open Serilog
open Serilog.Events
open Argu
open Prometheus
open Settings
type Arguments =
| Log_Level of level: int
| Port of port: int
| [<MainCommand; Last>] Dir of path: string
interface IArgParserTemplate with
member this.Usage =
match this with
| Log_Level _ -> "0=Error, 1=Warning, 2=Info, 3=Debug, 4=Verbose"
| Port _ -> "listen port (default 8085)"
| Dir _ -> "serve from dir"
type Foo = {
foo: string
bar: int[]
}
let myFooPostHandler (next: HttpFunc) (ctx: HttpContext) =
task {
let! x = ctx.BindJsonAsync<string>()
printfn $"result: {x}"
return! Successful.OK x next ctx
}
let jsonApiPage (page: string) (next: HttpFunc) (ctx: HttpContext) =
task {
let pageData = getJsonApiPageBySlug page
let content = BasicPage.render pageData.data
let jsonData = entityEncoder pageData |> Encode.toString 2
let pageLayout = defaultLayout pageData.data.title jsonData
let html =
pageLayout content
|> Render.htmlDocument
return! htmlString html next ctx
}
let webApp =
choose [
GET >=> choose [
routef "/%s" jsonApiPage
route "/" >=> jsonApiPage "frontpage"
// routef "/%s" (fun y -> htmlFile $"../dist/public/basicPage/{y}.html")
]
POST >=> route "/foo" >=> myFooPostHandler
]
let configureSerilog level =
let n =
match level with
| 0 -> LogEventLevel.Error
| 1 -> LogEventLevel.Warning
| 2 -> LogEventLevel.Information
| 3 -> LogEventLevel.Debug
| _ -> LogEventLevel.Verbose
LoggerConfiguration()
.MinimumLevel.Is(n)
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("System", LogEventLevel.Information)
.Filter.ByExcluding("RequestPath like '/health%'")
.WriteTo.Console()
.CreateLogger()
let app _ =
let metricServer = new KestrelMetricServer(port = 9091)
metricServer.Start() |> ignore
application {
url $"http://0.0.0.0:{port}"
use_router webApp
use_static "public"
memory_cache
use_json_serializer (Thoth.Json.Giraffe.ThothSerializer())
use_gzip
logging (fun logger -> logger.AddSerilog() |> ignore)
}
let colorizer =
function
| ErrorCode.HelpText -> None
| _ -> Some ConsoleColor.Red
let errorHandler = ProcessExiter(colorizer = colorizer)
[<EntryPoint>]
let main argv =
let parser =
ArgumentParser.Create<Arguments>(programName = "OceanWeb", errorHandler = errorHandler)
let args = parser.Parse argv
let port = args.GetResult (Port, defaultValue = 8085)
Log.Logger <- configureSerilog (args.GetResult(Log_Level, defaultValue = 4))
run (app port)
0

41
src/Server/Server.fsproj Normal file
View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Shared/Shared.fsproj" />
</ItemGroup>
<ItemGroup>
<Compile Include="JsonApi.fs" />
<Compile Include="../Shared/Shared.fs" />
<Compile Include="Settings.fs" />
<Compile Include="Server.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Argu" Version="6.2.5" />
<PackageReference Include="FSharp.Data" Version="6.6.0" />
<PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" />
<PackageReference Include="FSharpPlus" Version="1.8.0" />
<PackageReference Include="Fable.Remoting.DotnetClient" Version="3.37.0" />
<PackageReference Include="Fable.Remoting.Giraffe" Version="5.24.0" />
<PackageReference Include="Fable.Remoting.MsgPack" Version="1.25.0" />
<PackageReference Include="Fable.Remoting.Server" Version="5.42.0" />
<PackageReference Include="Giraffe" Version="8.2.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageReference Include="Saturn" Version="0.17.0" />
<PackageReference Include="SecurityCodeScan" Version="3.5.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Expressions" Version="5.0.0" />
<PackageReference Include="Thoth.Json.Giraffe" Version="6.0.0" />
<PackageReference Include="Thoth.Json.Net" Version="12.0.0" />
<PackageReference Update="FSharp.Core" Version="9.0.300" />
</ItemGroup>
</Project>

82
src/Server/Settings.fs Normal file
View File

@@ -0,0 +1,82 @@
module Settings
open System.IO
open Thoth.Json.Net
open Serilog
open FSharpPlus
type OpenIdSettings =
{
issuer: string
authorization_endpoint: string
token_endpoint: string
jwks_uri: string
userinfo_endpoint: string
device_authorization_endpoint: string
clientId: string
clientSecret: string
scopes: string []
redirectUri: string option
}
type SsoSettings =
{
signedOutRedirectUri: string
cookieDomain: string
appDomain: string
dataProtectionKeys: string
}
type PlainAuth =
{
username: string
password: string
groups: string[]
roles: string []
}
type Settings =
{
oidc: OpenIdSettings
redis: string
sso: SsoSettings
allowedOrigins: string []
plainAuthUsers: PlainAuth []
}
let tryGetEnv =
System.Environment.GetEnvironmentVariable
>> function
| null
| "" -> None
| x -> Some x
let appsettings =
let settings =
File.ReadAllText "appsettings.json"
|> Decode.Auto.fromString<Settings>
|> function
| Ok s -> s
| Error e -> failwith e
let users = // support for storing user credentials in Kubernetes secret
"PLAIN_AUTH_USERS"
|> tryGetEnv
|> Option.bind (fun f ->
File.ReadAllText f
|> Decode.Auto.fromString<PlainAuth []>
|> Option.ofResult
)
|> Option.defaultValue settings.plainAuthUsers
{ settings with
plainAuthUsers = users
}
let port =
"SERVER_PORT"
|> tryGetEnv
|> Option.map uint16
|> Option.defaultValue 8085us
let listenAddress = "http://0.0.0.0:" + port.ToString()
sprintf "AppSettings: %A" appsettings |> Log.Debug

View File

@@ -0,0 +1,36 @@
{
"oidc": {
"issuer": "https://idp.app.local/dex",
"authorization_endpoint": "https://idp.app.local/dex/auth",
"token_endpoint": "https://idp.app.local/dex/token",
"jwks_uri": "https://idp.app.local/dex/keys",
"userinfo_endpoint": "https://idp.app.local/dex/userinfo",
"device_authorization_endpoint": "https://idp.app.local/dex/device/code",
"clientId": "app",
"clientSecret": "secret",
"scopes": [
"openid",
"email",
"offline_access",
"profile"
]
},
"redis": "localhost:6379,user=default,password=secret",
"sso": {
"cookieDomain": "localhost",
"signedOutRedirectUri": "https://idp.app.local/dex/static/logout.html",
"appDomain": "app",
"dataProtectionKeys": "DataProtection-Keys"
},
"allowedOrigins": [
"https://atlantis.local.oceanbox.io:8080"
],
"plainAuthUsers": [
{
"username": "demo",
"password": "secret",
"groups": [ "cli" ],
"roles": [ "admin" ]
}
]
}

View File

@@ -0,0 +1,726 @@
{
"version": 1,
"dependencies": {
"net9.0": {
"Argu": {
"type": "Direct",
"requested": "[6.2.5, )",
"resolved": "6.2.5",
"contentHash": "68vfXYZCcyTFtC11Z26+S4cSyEPiUJ8PL+iGuYpIbCsH+TN27bIxV/QczHXus/+iSCOfjMJsBffBC90mlzKXrw==",
"dependencies": {
"FSharp.Core": "6.0.0",
"System.Configuration.ConfigurationManager": "4.4.0"
}
},
"Fable.Remoting.DotnetClient": {
"type": "Direct",
"requested": "[3.37.0, )",
"resolved": "3.37.0",
"contentHash": "xVTr84e5Kbnb1p/q4iF3ez4EIn7TU2UFeIdA95VIEQ7l67qkcHiOFGlOlznGAjSgn/t2dOILZVvgkAMQmWO5rw==",
"dependencies": {
"FSharp.Core": "6.0.0",
"Fable.Remoting.Json": "2.25.0",
"Fable.Remoting.MsgPack": "1.25.0"
}
},
"Fable.Remoting.Giraffe": {
"type": "Direct",
"requested": "[5.24.0, )",
"resolved": "5.24.0",
"contentHash": "z3QZWK3/WaEmRFimt65Rc3eegA4VKFoqLH0xuQfDVdnpY3uhxe5aHOYVxFvgYPYId4XGtDzA9dFH4OyIwO1bJA==",
"dependencies": {
"FSharp.Core": "6.0.0",
"Fable.Remoting.Server": "5.42.0",
"Giraffe": "5.0.0",
"Microsoft.IO.RecyclableMemoryStream": "[3.0.0, 4.0.0)"
}
},
"Fable.Remoting.MsgPack": {
"type": "Direct",
"requested": "[1.25.0, )",
"resolved": "1.25.0",
"contentHash": "FyqSj8j8J0W7xTR8XJmk2Q6vZa0NlKUUjQtr/rQhCkV2r5uJ3gtT+2KSiMjdzemwV5X+9eUz6C5q1YN0t3ccug==",
"dependencies": {
"FSharp.Core": "4.7.2"
}
},
"Fable.Remoting.Server": {
"type": "Direct",
"requested": "[5.42.0, )",
"resolved": "5.42.0",
"contentHash": "XRRYy2vuF64wV0VYb0pF1eNF0MR23P9MKG7dpdSA6QCjhFJyWR+YLPdxjz34uAFx8WdDb4ujXQ9YbYOsBQTxhw==",
"dependencies": {
"FSharp.Core": "6.0.0",
"Fable.Remoting.Json": "2.25.0",
"Fable.Remoting.MsgPack": "1.25.0",
"Microsoft.AspNetCore.WebUtilities": "2.3.0",
"Microsoft.IO.RecyclableMemoryStream": "[3.0.0, 4.0.0)",
"Microsoft.Net.Http.Headers": "2.3.0"
}
},
"FSharp.Core": {
"type": "Direct",
"requested": "[9.0.300, )",
"resolved": "9.0.300",
"contentHash": "TVt2J7RCE1KCS2IaONF+p8/KIZ1eHNbW+7qmKF6hGoD4tXl+o07ja1mPtFjMqRa5uHMFaTrGTPn/m945WnDLiQ=="
},
"FSharp.Data": {
"type": "Direct",
"requested": "[6.6.0, )",
"resolved": "6.6.0",
"contentHash": "AC87/VZV1iGrnK1uG6QhX22HZARlFiCqdlckzPDgdE3+fzc0ZNRR3Y4ny5vbLvfDj2rjFoJdmRZy6kzI1C/sQQ==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Csv.Core": "6.6.0",
"FSharp.Data.Html.Core": "6.6.0",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Json.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0",
"FSharp.Data.WorldBank.Core": "6.6.0",
"FSharp.Data.Xml.Core": "6.6.0"
}
},
"FSharp.SystemTextJson": {
"type": "Direct",
"requested": "[1.4.36, )",
"resolved": "1.4.36",
"contentHash": "1xLk0SBF1nedD74B77rcArjD2d+DeZwbNI7BVCAyKwIBERo1VoX8Mf4AtX0OV2L3ZeM/XS1b6BuhXCyoh0KBnw==",
"dependencies": {
"FSharp.Core": "4.7.0",
"System.Text.Json": "6.0.10"
}
},
"FSharpPlus": {
"type": "Direct",
"requested": "[1.8.0, )",
"resolved": "1.8.0",
"contentHash": "obZb1vX+wpJkcEB1pseN6ZGosLxY3SM5+M0KQVjeDR88aXs6MqNY7KR2PHfzm8b5ewlRURF8DugKYZC0X2cpTw==",
"dependencies": {
"FSharp.Core": "6.0.6"
}
},
"Giraffe": {
"type": "Direct",
"requested": "[8.2.0, )",
"resolved": "8.2.0",
"contentHash": "GlrgfkGKnKbbLfPBpXVBXBEca0wwz+iI9Ba74j3Hcx/rMXLFLqs0WjDhNVfsU7FB585e3VxEcb12XoyOFrsklA==",
"dependencies": {
"FSharp.Core": "6.0.0",
"FSharp.SystemTextJson": "1.3.13",
"Giraffe.ViewEngine": "1.4.0",
"Microsoft.IO.RecyclableMemoryStream": "3.0.1",
"System.Text.Json": "8.0.6"
}
},
"Newtonsoft.Json": {
"type": "Direct",
"requested": "[13.0.4, )",
"resolved": "13.0.4",
"contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A=="
},
"prometheus-net.AspNetCore": {
"type": "Direct",
"requested": "[8.2.1, )",
"resolved": "8.2.1",
"contentHash": "/4TfTvbwIDqpaKTiWvEsjUywiHYF9zZvGZF5sK15avoDsUO/WPQbKsF8TiMaesuphdFQPK2z52P0zk6j26V0rQ==",
"dependencies": {
"prometheus-net": "8.2.1"
}
},
"Saturn": {
"type": "Direct",
"requested": "[0.17.0, )",
"resolved": "0.17.0",
"contentHash": "8tg379A5VKmOODy92LyULLkS3KJOK9zVtj8xEfOdkLxR+KAoh0iY3pA3p0PXs0TH5r8919bB2a4eJbdD36gr/w==",
"dependencies": {
"FSharp.Control.Websockets": "0.2.2",
"Giraffe": "6.4.0",
"Microsoft.AspNetCore.Authentication.JwtBearer": "6.0.3"
}
},
"SecurityCodeScan": {
"type": "Direct",
"requested": "[3.5.4, )",
"resolved": "3.5.4",
"contentHash": "KNbIxteHSVMk/9K8vl+YQb865egCycsNP/YspbRMLxWLFQev3UiTvvt/iCwlEyB30VA1RitgWXmsw8CC61/4Cw=="
},
"Serilog": {
"type": "Direct",
"requested": "[4.3.0, )",
"resolved": "4.3.0",
"contentHash": "+cDryFR0GRhsGOnZSKwaDzRRl4MupvJ42FhCE4zhQRVanX0Jpg6WuCBk59OVhVDPmab1bB+nRykAnykYELA9qQ=="
},
"Serilog.AspNetCore": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "JslDajPlBsn3Pww1554flJFTqROvK9zz9jONNQgn0D8Lx2Trw8L0A8/n6zEQK1DAZWXrJwiVLw8cnTR3YFuYsg==",
"dependencies": {
"Serilog": "4.2.0",
"Serilog.Extensions.Hosting": "9.0.0",
"Serilog.Formatting.Compact": "3.0.0",
"Serilog.Settings.Configuration": "9.0.0",
"Serilog.Sinks.Console": "6.0.0",
"Serilog.Sinks.Debug": "3.0.0",
"Serilog.Sinks.File": "6.0.0"
}
},
"Serilog.Expressions": {
"type": "Direct",
"requested": "[5.0.0, )",
"resolved": "5.0.0",
"contentHash": "QhZjXtUcA2QfQRA60m+DfyIfidKsQV7HBstbYEDqzJKMbJH/KnKthkkjciRuYrmFE+scWv1JibC5LlXrdtOUmw==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Thoth.Json.Giraffe": {
"type": "Direct",
"requested": "[6.0.0, )",
"resolved": "6.0.0",
"contentHash": "Hl0HRrI+HeKGImVvIo2PhjYdYyDYHCOLuGeoTGaPGH6kbSWN+EcxY6yNW15zhlI/Huk6XL35KnALszQGwqH5mA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"Giraffe": "6.0.0-alpha-1",
"Thoth.Json.Net": "5.0.0"
}
},
"Thoth.Json.Net": {
"type": "Direct",
"requested": "[12.0.0, )",
"resolved": "12.0.0",
"contentHash": "n2YyONYdWCFS4Pu7wrqgV/l5tPuN+t3gxEfs/2RwqCiQkRnbgKs9dK61zHeZS5YganAoFbxLSwbaNL7SvSrS9g==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.1.6",
"Newtonsoft.Json": "13.0.1"
}
},
"Fable.AST": {
"type": "Transitive",
"resolved": "4.2.1",
"contentHash": "/4V6U7Qw/WIRRxm9NJ7b+YTXTRCTk6/YKeJnbKYaVbtT45MstA3jkFvRfV0FqVFtkG9AL4uccetreygTjK7nbQ=="
},
"Fable.Browser.Blob": {
"type": "Transitive",
"resolved": "1.1.4",
"contentHash": "+6aq2ClPFbh/wFEtKuuIPwoZwi2GlUJ6RRykT0R+i95ZmK22nfE6iWpP+jfKxDnGgrm2AGC+btobAHWXbkC1Sw==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.0.0"
}
},
"Fable.Browser.Dom": {
"type": "Transitive",
"resolved": "2.4.4",
"contentHash": "wFGefU1Idqkwa6kKdvz5oyHL5yP+i+cvTHPeJGlF22fnqOozFurFIuPqGToqpgl9DfyWr7MyNvTiLFOkfXNqnA==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Blob": "1.1.4",
"Fable.Browser.Event": "1.4.4",
"Fable.Browser.WebStorage": "1.0.4",
"Fable.Core": "3.0.0"
}
},
"Fable.Browser.Event": {
"type": "Transitive",
"resolved": "1.4.4",
"contentHash": "jGwS4fjwUNBXgGuu+mklDp9sx4ql0E6Ml5F/CI+8gxwgmxG4IdBTo3yIgF8m0doy7Tc9fgBhOfP3xdGMDgJBBQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Gamepad": "1.0.3",
"Fable.Core": "3.0.0"
}
},
"Fable.Browser.Gamepad": {
"type": "Transitive",
"resolved": "1.0.3",
"contentHash": "3mIdAR8uy7eQoL+Nyp72OVWnIbTegoFD6yCvnqaHeWE2MZ3+Lc9EPMKDiHjX3VbImd1uQI57zg+hQJ6MTekgEA==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Core": "3.0.0"
}
},
"Fable.Browser.WebStorage": {
"type": "Transitive",
"resolved": "1.0.4",
"contentHash": "kxKI1nM4TWIRjp4X0QfdbgdU3p1ite5E/to3/RqotnV5BTmS9Wl+yX38L0S4CTbnU+I6v8aPLYlZ/BKmtxggXQ==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Event": "1.4.4",
"Fable.Core": "3.0.0"
}
},
"Fable.Core": {
"type": "Transitive",
"resolved": "3.2.7",
"contentHash": "XYvisWEqzYap+9USQGRyI4F9DmZY5EE3STMiwnvlSrwoqHmZRPHyQR3Rp2oGv/mpzNy7PViKdgCiKC/eviG2Ng==",
"dependencies": {
"FSharp.Core": "4.7.2"
}
},
"Fable.React.Types": {
"type": "Transitive",
"resolved": "18.3.0",
"contentHash": "/b8WZ3Bdfhqy9r60ZK9JGZaGNjIMb0ogsrvWIg3k7KfCEvJs5X6+7hCybVkyjVoxwzn9wLyYGRbh5wmuHQT/Vg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.Browser.Dom": "2.4.4",
"Fable.Core": "3.2.7"
}
},
"Fable.ReactDom.Types": {
"type": "Transitive",
"resolved": "18.2.0",
"contentHash": "2WoBjsLiSgrvR68OJXko0iVaqeMbkPM5Bx813A1WlxOSCJ50M9fwnlwG/MUEZtiOIhQmku/YTJY5a8E8r1+j2Q==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.React.Types": "18.3.0"
}
},
"Fable.Remoting.Json": {
"type": "Transitive",
"resolved": "2.25.0",
"contentHash": "ycmxT5L7jUKLkSJ4uti+WiX1OU50UYhLLiFQwJpQzwPWpNbSXowPjUtGycl8G5429edzeIHGW/77hUlIAufiAg==",
"dependencies": {
"FSharp.Core": "6.0.0",
"Newtonsoft.Json": "13.0.3"
}
},
"Feliz": {
"type": "Transitive",
"resolved": "2.9.0",
"contentHash": "8nyGREGA60RysdSBamVWmr68MG+3lLy76W17fBiGaKi7uMFbtRcYBLyNtp2NyGZFfnuWCEyDAmAXM5YFnDhbhg==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.ReactDom.Types": "18.2.0",
"Feliz.CompilerPlugins": "2.2.0"
}
},
"Feliz.CompilerPlugins": {
"type": "Transitive",
"resolved": "2.2.0",
"contentHash": "ACkO++Hp4lUrEx/axeehIL5/3R8jMnak+CYpzd0/kLpejp9BETtrgjHK7oj6Lh3V9fB7WoAKsCxyPSrm4ADN2w==",
"dependencies": {
"FSharp.Core": "4.7.2",
"Fable.AST": "4.2.1"
}
},
"Feliz.ViewEngine": {
"type": "Transitive",
"resolved": "0.27.0",
"contentHash": "D6OLAuwPUoxa1VcBLjcs/eArfmqxdtyv1N0piaup40lcDjt4V7CQURr7EpUaOkTU770Hbmi2V3RCgq7XmL17Zw==",
"dependencies": {
"Fsharp.Core": "[4.7.0, 5.0.0)"
}
},
"FSharp.Control.Websockets": {
"type": "Transitive",
"resolved": "0.2.2",
"contentHash": "MAHcOmbJdG6NNHBiylN8wIfz/mZ7WvWNYCKK5vXxkD/1tFYdN8l1UsRmoUvIqICRCAQ050qU4hpmaa/B2hd67g==",
"dependencies": {
"FSharp.Core": "4.3.4",
"Microsoft.IO.RecyclableMemoryStream": "1.2.2"
}
},
"FSharp.Data.Csv.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "gbmHT+XraE1OjUyf/+bmIaluoMMj/34MC5IX0QNWr+K8fu+UjKg854p+fkG5JkzWej0+46CTV7hW/CWP425naA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Html.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "YxImykk5fUxw+rwvD26JfZYn/OAUataebsUobXu8KzkLtl/5fOhiPqpI54jAT4ysv2wHD5coCp30+5UsQXqxBQ==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Csv.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Http": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "7nFDLYPqEps/F3Ay65Xm3YvS3OmARYB24xYv1CszOqurTZ76wM9vnsvyeFOMcCKXA+QgwK+eCa8cgyavmR1YxA==",
"dependencies": {
"FSharp.Core": "6.0.1"
}
},
"FSharp.Data.Json.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "VhLkZwyzeMWsJHivY4GjLqIWh+gIm0rWW0BBlAh1M7XzmmumbPLDehUqOh6Nkm1R+N8iBhFgj7HBq6bmQzGnZA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Runtime.Utilities": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "iLkCY0mQGxuIE2sEIFFOov9ZmWPwlQ0Jq51Xgqv+e0hbAPf2p4OaGp/DR4ye6VQCyVzHVCp9XyJQeHOhoWCQvA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0"
}
},
"FSharp.Data.WorldBank.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "Tg6Hk0+61McNviq+zATBhIDoyslV07hZjYsrzz6IHagbze+1JNiz8FCpwsHh0uAzkdNUZK2/qsrHtMQvM0lpdg==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Json.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"FSharp.Data.Xml.Core": {
"type": "Transitive",
"resolved": "6.6.0",
"contentHash": "Z7U4mO0FHT5uHOYRLSK67izqJrCSvWpjcDPRQkJmYB5ge0sRfHWoKV8b4ttHLmpEg/eAdu64u00nITxpbZm+CA==",
"dependencies": {
"FSharp.Core": "6.0.1",
"FSharp.Data.Http": "6.6.0",
"FSharp.Data.Json.Core": "6.6.0",
"FSharp.Data.Runtime.Utilities": "6.6.0"
}
},
"Giraffe.ViewEngine": {
"type": "Transitive",
"resolved": "1.4.0",
"contentHash": "gWzlp8KGDLaPHMm/xGzfLT1e9wZaJhBKhEuMsPRBSn7wAMkSgMcaqJlWl8mFGCcOOx0r9UyDJvCJJQyljYmo+g==",
"dependencies": {
"FSharp.Core": "5.0.0"
}
},
"Microsoft.AspNetCore.Authentication.JwtBearer": {
"type": "Transitive",
"resolved": "6.0.3",
"contentHash": "yXadCTYfe0AhgVZxLhG4ltra1BhteDumot2YRtQLOpJIVIPzanNsbFjeXomXiWWiU8qWNrK++gGYox3DaJBThg==",
"dependencies": {
"Microsoft.IdentityModel.Protocols.OpenIdConnect": "6.10.0"
}
},
"Microsoft.AspNetCore.WebUtilities": {
"type": "Transitive",
"resolved": "2.3.0",
"contentHash": "trbXdWzoAEUVd0PE2yTopkz4kjZaAIA7xUWekd5uBw+7xE8Do/YOVTeb9d9koPTlbtZT539aESJjSLSqD8eYrQ==",
"dependencies": {
"Microsoft.Net.Http.Headers": "2.3.0",
"System.Text.Encodings.Web": "8.0.0"
}
},
"Microsoft.CSharp": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "kaj6Wb4qoMuH3HySFJhxwQfe8R/sJsNJnANrvv8WdFPMoNbKY5htfNscv+LHCu5ipz+49m2e+WQXpLXr9XYemQ=="
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "lqvd7W3FGKUO1+ZoUEMaZ5XDJeWvjpy2/M/ptCGz3tXLD4HWVaSzjufsAsjemasBEg+2SxXVtYVvGt5r2nKDlg==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "RiScL99DcyngY9zJA2ROrri7Br8tn5N4hP4YNvGdTN/bvg1A3dwvDOxHnNZ3Im7x2SJ5i4LkX1uPiR/MfSFBLQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "saxr2XzwgDU77LaQfYFXmddEDRUKHF4DaGMZkNB3qjdVSZlax3//dGJagJkKrGMIPNZs2jVFXITyCCR6UHJNdA=="
},
"Microsoft.Extensions.Diagnostics.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "1K8P7XzuzX8W8pmXcZjcrqS6x5eSSdvhQohmcpgiQNY/HlDAlnrhR9dvlURfFz428A+RTCJpUyB+aKTA6AgVcQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Options": "9.0.0"
}
},
"Microsoft.Extensions.FileProviders.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "uK439QzYR0q2emLVtYzwyK3x+T5bTY4yWsd/k/ZUS9LR6Sflp8MIdhGXW8kQCd86dQD4tLqvcbLkku8qHY263Q==",
"dependencies": {
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Hosting.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "yUKJgu81ExjvqbNWqZKshBbLntZMbMVz/P7Way2SBx7bMqA08Mfdc9O7hWDKAiSp+zPUGT6LKcSCQIPeDK+CCw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "9.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Diagnostics.Abstractions": "9.0.0",
"Microsoft.Extensions.FileProviders.Abstractions": "9.0.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.Http": {
"type": "Transitive",
"resolved": "3.1.0",
"contentHash": "DLigdcV0nYaT6/ly0rnfP80BnXq8NNd/h8/SkfY39uio7Bd9LauVntp6RcRh1Kj23N+uf80GgL7Win6P3BCtoQ==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "3.1.0",
"Microsoft.Extensions.Logging": "3.1.0",
"Microsoft.Extensions.Options": "3.1.0"
}
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection": "9.0.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"Microsoft.Extensions.Options": "9.0.0"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.ObjectPool": {
"type": "Transitive",
"resolved": "7.0.0",
"contentHash": "udvKco0sAVgYGTBnHUb0tY9JQzJ/nPDiv/8PIyz69wl1AibeCDZOLVVI+6156dPfHmJH7ws5oUJRiW4ZmAvuuA=="
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
},
"Microsoft.IdentityModel.JsonWebTokens": {
"type": "Transitive",
"resolved": "6.10.0",
"contentHash": "0qjS31rN1MQTc46tAYbzmMTSRfdV5ndZxSjYxIGqKSidd4wpNJfNII/pdhU5Fx8olarQoKL9lqqYw4yNOIwT0Q==",
"dependencies": {
"Microsoft.IdentityModel.Tokens": "6.10.0"
}
},
"Microsoft.IdentityModel.Logging": {
"type": "Transitive",
"resolved": "6.10.0",
"contentHash": "zbcwV6esnNzhZZ/VP87dji6VrUBLB5rxnZBkDMqNYpyG+nrBnBsbm4PUYLCBMUflHCM9EMLDG0rLnqqT+l0ldA=="
},
"Microsoft.IdentityModel.Protocols": {
"type": "Transitive",
"resolved": "6.10.0",
"contentHash": "DFyXD0xylP+DknCT3hzJ7q/Q5qRNu0hO/gCU90O0ATdR0twZmlcuY9RNYaaDofXKVbzcShYNCFCGle2G/o8mkg==",
"dependencies": {
"Microsoft.IdentityModel.Logging": "6.10.0",
"Microsoft.IdentityModel.Tokens": "6.10.0"
}
},
"Microsoft.IdentityModel.Protocols.OpenIdConnect": {
"type": "Transitive",
"resolved": "6.10.0",
"contentHash": "LVvMXAWPbPeEWTylDrxunlHH2wFyE4Mv0L4gZrJHC4HTESbWHquKZb/y/S8jgiQEDycOP0PDQvbG4RR/tr2TVQ==",
"dependencies": {
"Microsoft.IdentityModel.Protocols": "6.10.0",
"System.IdentityModel.Tokens.Jwt": "6.10.0"
}
},
"Microsoft.IdentityModel.Tokens": {
"type": "Transitive",
"resolved": "6.10.0",
"contentHash": "qbf1NslutDB4oLrriYTJpy7oB1pbh2ej2lEHd2IPDQH9C74ysOdhU5wAC7KoXblldbo7YsNR2QYFOqQM/b0Rsg==",
"dependencies": {
"Microsoft.CSharp": "4.5.0",
"Microsoft.IdentityModel.Logging": "6.10.0",
"System.Security.Cryptography.Cng": "4.5.0"
}
},
"Microsoft.IO.RecyclableMemoryStream": {
"type": "Transitive",
"resolved": "3.0.1",
"contentHash": "s/s20YTVY9r9TPfTrN5g8zPF1YhwxyqO6PxUkrYTGI2B+OGPe9AdajWZrLhFqXIvqIW23fnUE4+ztrUWNU1+9g=="
},
"Microsoft.Net.Http.Headers": {
"type": "Transitive",
"resolved": "2.3.0",
"contentHash": "/M0wVg6tJUOHutWD3BMOUVZAioJVXe0tCpFiovzv0T9T12TBf4MnaHP0efO8TCr1a6O9RZgQeZ9Gdark8L9XdA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0",
"System.Buffers": "4.6.0"
}
},
"prometheus-net": {
"type": "Transitive",
"resolved": "8.2.1",
"contentHash": "3wVgdEPOCBF752s2xps5T+VH+c9mJK8S8GKEDg49084P6JZMumTZI5Te6aJ9MQpX0sx7om6JOnBpIi7ZBmmiDQ==",
"dependencies": {
"Microsoft.Extensions.Http": "3.1.0",
"Microsoft.Extensions.ObjectPool": "7.0.0"
}
},
"Serilog.Extensions.Hosting": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "u2TRxuxbjvTAldQn7uaAwePkWxTHIqlgjelekBtilAGL5sYyF3+65NWctN4UrwwGLsDC7c3Vz3HnOlu+PcoxXg==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Hosting.Abstractions": "9.0.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"Serilog": "4.2.0",
"Serilog.Extensions.Logging": "9.0.0"
}
},
"Serilog.Extensions.Logging": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "NwSSYqPJeKNzl5AuXVHpGbr6PkZJFlNa14CdIebVjK3k/76kYj/mz5kiTRNVSsSaxM8kAIa1kpy/qyT9E4npRQ==",
"dependencies": {
"Microsoft.Extensions.Logging": "9.0.0",
"Serilog": "4.2.0"
}
},
"Serilog.Formatting.Compact": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "wQsv14w9cqlfB5FX2MZpNsTawckN4a8dryuNGbebB/3Nh1pXnROHZov3swtu3Nj5oNG7Ba+xdu7Et/ulAUPanQ==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Settings.Configuration": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "4/Et4Cqwa+F88l5SeFeNZ4c4Z6dEAIKbu3MaQb2Zz9F/g27T5a3wvfMcmCOaAiACjfUb4A6wrlTVfyYUZk3RRQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "9.0.0",
"Microsoft.Extensions.DependencyModel": "9.0.0",
"Serilog": "4.2.0"
}
},
"Serilog.Sinks.Console": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "fQGWqVMClCP2yEyTXPIinSr5c+CBGUvBybPxjAGcf7ctDhadFhrQw03Mv8rJ07/wR5PDfFjewf2LimvXCDzpbA==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Sinks.Debug": {
"type": "Transitive",
"resolved": "3.0.0",
"contentHash": "4BzXcdrgRX7wde9PmHuYd9U6YqycCC28hhpKonK7hx0wb19eiuRj16fPcPSVp0o/Y1ipJuNLYQ00R3q2Zs8FDA==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"Serilog.Sinks.File": {
"type": "Transitive",
"resolved": "6.0.0",
"contentHash": "lxjg89Y8gJMmFxVkbZ+qDgjl+T4yC5F7WSLTvA+5q0R04tfKVLRL/EHpYoJ/MEQd2EeCKDuylBIVnAYMotmh2A==",
"dependencies": {
"Serilog": "4.0.0"
}
},
"System.Buffers": {
"type": "Transitive",
"resolved": "4.6.0",
"contentHash": "lN6tZi7Q46zFzAbRYXTIvfXcyvQQgxnY7Xm6C6xQ9784dEL1amjM6S6Iw4ZpsvesAKnRVsM4scrDQaDqSClkjA=="
},
"System.Configuration.ConfigurationManager": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "gWwQv/Ug1qWJmHCmN17nAbxJYmQBM/E94QxKLksvUiiKB1Ld3Sc/eK1lgmbSjDFxkQhVuayI/cGFZhpBSodLrg==",
"dependencies": {
"System.Security.Cryptography.ProtectedData": "4.4.0"
}
},
"System.IdentityModel.Tokens.Jwt": {
"type": "Transitive",
"resolved": "6.10.0",
"contentHash": "C+Q5ORsFycRkRuvy/Xd0Pv5xVpmWSAvQYZAGs7VQogmkqlLhvfZXTgBIlHqC3cxkstSoLJAYx6xZB7foQ2y5eg==",
"dependencies": {
"Microsoft.IdentityModel.JsonWebTokens": "6.10.0",
"Microsoft.IdentityModel.Tokens": "6.10.0"
}
},
"System.IO.Pipelines": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "M1eb3nfXntaRJPrrMVM9EFS8I1bDTnt0uvUS6QP/SicZf/ZZjydMD5NiXxfmwW/uQwaMDP/yX2P+zQN1NBHChg=="
},
"System.Security.Cryptography.Cng": {
"type": "Transitive",
"resolved": "4.5.0",
"contentHash": "WG3r7EyjUe9CMPFSs6bty5doUqT+q9pbI80hlNzo2SkPkZ4VTuZkGWjpp77JB8+uaL4DFPRdBsAY+DX3dBK92A=="
},
"System.Security.Cryptography.ProtectedData": {
"type": "Transitive",
"resolved": "4.4.0",
"contentHash": "cJV7ScGW7EhatRsjehfvvYVBvtiSMKgN8bOVI0bQhnF5bU7vnHVIsH49Kva7i7GWaWYvmEzkYVk1TC+gZYBEog=="
},
"System.Text.Encodings.Web": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "257hh1ep1Gqm1Lm0ulxf7vVBVMJuGN6EL4xSWjpi46DffXzm1058IiWsfSC06zSm7SniN+Tb5160UnXsSa8rRg=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "10.0.0",
"contentHash": "1Dpjwq9peG/Wt5BNbrzIhTpclfOSqBWZsUO28vVr59yQlkvL5jLBWfpfzRmJ1OY+6DciaY0DUcltyzs4fuZHjw==",
"dependencies": {
"System.IO.Pipelines": "10.0.0",
"System.Text.Encodings.Web": "10.0.0"
}
},
"shared": {
"type": "Project",
"dependencies": {
"FSharp.Core": "[9.0.303, )",
"FSharp.Data": "[6.6.0, )",
"FSharp.SystemTextJson": "[1.4.36, )",
"Feliz": "[2.9.0, )",
"Feliz.ViewEngine": "[0.27.0, )",
"System.Text.Json": "[10.0.0, )",
"Thoth.Json.Net": "[12.0.0, )"
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
@import "tailwindcss";
.full-width-center {
width: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
}
.notification-bar {
height: 75px;
margin: 5px;
border-style: dashed;
border-width: 1px;
border-color: darkblue;
}

View File

@@ -0,0 +1,42 @@
namespace Shared.Components
#if FABLE_COMPILER
open Feliz
#else
open Feliz.ViewEngine
#endif
open Shared.Data.ThothTypes
open type Html
open type prop
module Hero =
let baseUrl = "http://oceanbox.ddev.site/"
let render (hero: Hero) =
let mediaUrl =
match hero.field_background_image with
| MediaVideo video -> baseUrl + video.field_media_video_file.uri.url
| MediaImage image -> baseUrl + image.field_media_image.uri.url
let heroSize =
match hero.field_hero_size with
| "small" -> "min-h-[25vh]"
| "medium" -> "min-h-[50vh]"
| "large" -> "min-h-screen"
| _ -> ""
div
[ className $"hero {heroSize}"
style [ style.backgroundImage $"url('{mediaUrl}')" ]
children
[ div [ className "hero-overlay" ]
div
[ className "hero-content text-neutral-content text-center"
children
[ div
[ className "m-auto max-w-md"
children
[ h1 [ className "mb-5 text-5xl font-bold"; text hero.field_title ]
h2 [ className "mb-5"; text hero.field_subtitle ] ] ] ] ] ] ]

View File

@@ -0,0 +1,18 @@
namespace Shared.Components
#if FABLE_COMPILER
open Feliz
#else
open Feliz.ViewEngine
#endif
open Shared.Data.ThothTypes
module Paragraph =
let render (paragraph: Paragraph) =
match paragraph with
| Hero hero ->
Hero.render hero
| SimpleText simple ->
SimpleText.render simple

View File

@@ -0,0 +1,18 @@
namespace Shared.Components
#if FABLE_COMPILER
open Feliz
#else
open Feliz.ViewEngine
#endif
open Shared.Data.ThothTypes
open type Html
open type prop
module SimpleText =
let render (simple: SimpleText) =
div
[ className "m-auto max-w-7xl py-16 px-6 lg:px-8 prose"
dangerouslySetInnerHTML simple.field_body ]

View File

@@ -0,0 +1,112 @@
namespace Shared.Data
#if FABLE_COMPILER
open Thoth.Json
#else
open Thoth.Json.Net
#endif
open Shared.Data.ThothTypes
module ThothSerializerManual =
let pathDecoder = Decode.Auto.generateDecoder<Path>()
let mediaImageDecoder = Decode.Auto.generateDecoder<MediaImage>()
let mediaVideoDecoder = Decode.Auto.generateDecoder<MediaVideo>()
let simpleTextDecoder = Decode.Auto.generateDecoder<SimpleText>()
let pathEncoder = Encode.Auto.generateEncoder<Path>()
let mediaImageEncoder = Encode.Auto.generateEncoder<MediaImage>()
let mediaVideoEncoder = Encode.Auto.generateEncoder<MediaVideo>()
let simpleTextEncoder = Encode.Auto.generateEncoder<SimpleText>()
let linkHrefDecoder = Decode.Auto.generateDecoder<LinkHref>()
let linkHrefEncoder = Encode.Auto.generateEncoder<LinkHref>()
let paginationLinksDecoder = Decode.Auto.generateDecoder<PaginationLinks>()
let paginationLinksEncoder = Encode.Auto.generateEncoder<PaginationLinks>()
let mediaDecoder : Decoder<Media> =
Decode.field "type" Decode.string
|> Decode.andThen (fun t ->
match t with
| "media--image" ->
mediaImageDecoder |> Decode.map MediaImage
| "media--video" ->
mediaVideoDecoder |> Decode.map MediaVideo
| other ->
Decode.fail $"Unsupported media type: {other}"
)
let mediaEncoder (m: Media) =
match m with
| MediaImage x -> mediaImageEncoder x
| MediaVideo x -> mediaVideoEncoder x
let heroDecoder : Decoder<Hero> =
Decode.object (fun get ->
{ ``type`` = get.Required.Field "type" Decode.string
field_title = get.Required.Field "field_title" Decode.string
field_subtitle = get.Required.Field "field_subtitle" Decode.string
field_hero_size = get.Required.Field "field_hero_size" Decode.string
field_background_image = get.Required.Field "field_background_image" mediaDecoder }
)
let heroEncoder (hero: Hero) =
Encode.object
[ "type", Encode.string hero.``type``
"field_title", Encode.string hero.field_title
"field_subtitle", Encode.string hero.field_subtitle
"field_hero_size", Encode.string hero.field_hero_size
"field_background_image", mediaEncoder hero.field_background_image ]
let paragraphDecoder : Decoder<Paragraph> =
Decode.field "type" Decode.string
|> Decode.andThen (fun t ->
match t with
| "paragraph--hero_banner" ->
heroDecoder |> Decode.map Hero
| "paragraph--simple_text" ->
simpleTextDecoder |> Decode.map SimpleText
| other ->
Decode.fail $"Unsupported paragraph type: {other}"
)
let paragraphEncoder (paragraph: Paragraph) =
match paragraph with
| Hero hero -> heroEncoder hero
| SimpleText simpleText -> simpleTextEncoder simpleText
let docDataDecoder : Decoder<DocData> =
Decode.object (fun get ->
{ ``type`` = get.Required.Field "type" Decode.string
id = get.Required.Field "id" Decode.string
title = get.Required.Field "title" Decode.string
path = get.Required.Field "path" pathDecoder
created = get.Required.Field "created" Decode.string
changed = get.Required.Field "changed" Decode.string
field_content = get.Required.Field "field_content" (Decode.array paragraphDecoder) }
)
let docDataEncoder (data: DocData) =
Encode.object
[ "type", Encode.string data.``type``
"id", Encode.string data.id
"title", Encode.string data.title
"path", pathEncoder data.path
"created", Encode.string data.created
"changed", Encode.string data.changed
"field_content", data.field_content |> Array.map paragraphEncoder |> Encode.array ]
let pageDecoder : Decoder<JsonApiContent> =
Decode.object (fun get ->
{ data = get.Required.Field "data" (Decode.array docDataDecoder)
links = get.Required.Field "links" paginationLinksDecoder }
)
let pageEncoder (content: JsonApiContent) =
Encode.object
[ "data", content.data |> Array.map docDataEncoder |> Encode.array
"links", paginationLinksEncoder content.links ]
let entityDecoder : Decoder<EntityContent> =
Decode.object ( fun get -> { data = get.Required.Field "data" docDataDecoder } )
let entityEncoder (entity: EntityContent) =
Encode.object [ "data", docDataEncoder entity.data ]

View File

@@ -0,0 +1,103 @@
namespace Shared.Data
module ThothTypes =
type Path = {
alias: string
langcode: string
}
type Uri = {
url: string
}
type ImageMeta = {
alt: string
title: string
width: string
height: string
}
type VideoMeta = {
display: string
description: string
}
type ImageFile = {
uri: Uri
meta: ImageMeta
}
type VideoFile = {
uri: Uri
meta: VideoMeta
}
type MediaImage = {
``type``: string
field_media_image: ImageFile
}
type MediaVideo = {
``type``: string
field_media_video_file: VideoFile
}
type Media =
| MediaImage of MediaImage
| MediaVideo of MediaVideo
type Hero = {
``type``: string
field_title: string
field_subtitle: string
field_hero_size: string
field_background_image: Media
}
type SimpleText = {
``type``: string
field_body: string
}
type Paragraph =
| Hero of Hero
| SimpleText of SimpleText
type DocData = {
``type``: string
id: string
title: string
path: Path
created: string
changed: string
field_content: Paragraph[]
}
type LinkHref = {
href: string
}
type PaginationLinks = {
next: LinkHref option
}
type JsonApiContent = {
data: DocData[]
links: PaginationLinks
}
type EntityContent = {
data: DocData
}
type DecoupledPageJsonApi = {
individual: string
}
type DecoupledPage = {
resolved: string
isExternal: bool
isHomePath: bool
jsonapi: DecoupledPageJsonApi
}

View File

@@ -0,0 +1,76 @@
namespace Shared.Data
open Thoth.Json.Net
open Shared.Data.ThothTypes
module ThothSerializer =
// MEDIA DECODERS & ENCODERS
let imageDecoder = Decode.Auto.generateDecoder<MediaImage>()
let videoDecoder = Decode.Auto.generateDecoder<MediaVideo>()
let imageEncoder = Encode.Auto.generateEncoder<MediaImage>()
let videoEncoder = Encode.Auto.generateEncoder<MediaVideo>()
let mediaDecoder: Decoder<Media> =
Decode.field "type" Decode.string
|> Decode.andThen ( fun t ->
match t with
| "media--image" ->
imageDecoder
|> Decode.map MediaImage
| "media--video" ->
videoDecoder
|> Decode.map MediaVideo
| other ->
Decode.fail $"Unsupported media type: {other}"
)
let mediaEncoder (item: Media) =
match item with
| MediaImage img -> imageEncoder img
| MediaVideo video -> videoEncoder video
let extraMedia =
Extra.empty
|> Extra.withCustom mediaEncoder mediaDecoder
// PARAGRAPH DECODERS & ENCODERS
let heroDecoder = Decode.Auto.generateDecoder<Hero>(extra = extraMedia)
let simpleTextDecoder = Decode.Auto.generateDecoder<SimpleText>()
let heroEncoder = Encode.Auto.generateEncoder<Hero>(extra = extraMedia)
let simpleTextEncoder = Encode.Auto.generateEncoder<SimpleText>()
let paragraphDecoder: Decoder<Paragraph> =
Decode.field "type" Decode.string
|> Decode.andThen ( fun t ->
match t with
| "paragraph--simple_text" ->
simpleTextDecoder
|> Decode.map SimpleText
| "paragraph--hero_banner" ->
heroDecoder
|> Decode.map Hero
| other ->
Decode.fail $"Unsupported paragraph type: {other}"
)
let paragraphEncoder (item: Paragraph) =
match item with
| Hero hero -> heroEncoder hero
| SimpleText simpleText -> simpleTextEncoder simpleText
let extraTypes =
Extra.empty
|> Extra.withCustom mediaEncoder mediaDecoder
|> Extra.withCustom paragraphEncoder paragraphDecoder
let pageDecoder: Decoder<JsonApiContent> =
Decode.Auto.generateDecoder<JsonApiContent>(extra = extraTypes)
let pageEncoder: Encoder<JsonApiContent> =
Encode.Auto.generateEncoder<JsonApiContent>(extra = extraTypes)
let entityDecoder: Decoder<EntityContent> =
Decode.Auto.generateDecoder<EntityContent>(extra = extraTypes)
let entityEncoder: Encoder<EntityContent> =
Encode.Auto.generateEncoder<EntityContent>(extra = extraTypes)

View File

@@ -0,0 +1,54 @@
namespace Shared
#if FABLE_COMPILER
open Feliz
#else
open Feliz.ViewEngine
#endif
open type Html
open type prop
module Layout =
let layout (headElement: ReactElement) (footerElement: ReactElement) (bodyContent: ReactElement) =
html
[ headElement
body
[ div [ id "app"; children bodyContent ]
script [ src "/js/index.js" ]
footerElement ] ]
let defaultLayout (pageTitle: string) (pageData: string) =
let headElement =
head
[ Html.title pageTitle
meta [ charset "utf-8" ]
link [ rel "stylesheet"; href "/tailwindStyle.css" ]
script
[ id "__FORNIX_DATA__"
type' "application/json"
dangerouslySetInnerHTML pageData ] ]
let footerElement =
footer
[ className "footer sm:footer-horizontal bg-neutral text-neutral-content p-10"
children
[ nav
[ children
[ h6 [ className "footer-title"; text "Services" ]
a [ className "link link-hover"; text "Branding" ]
a [ className "link link-hover"; text "Design" ]
a [ className "link link-hover"; text "Marketing" ] ] ]
nav
[ children
[ h6 [ className "footer-title"; text "Company" ]
a [ className "link link-hover"; text "About us" ]
a [ className "link link-hover"; text "Jobs" ]
a [ className "link link-hover"; text "Press kit" ] ] ]
nav
[ children
[ h6 [ className "footer-title"; text "Legal" ]
a [ className "link link-hover"; text "Terms of use" ]
a [ className "link link-hover"; text "Privacy policy" ] ] ] ] ]
layout headElement footerElement

View File

@@ -0,0 +1,20 @@
namespace Shared.Pages
#if FABLE_COMPILER
open Feliz
#else
open Feliz.ViewEngine
#endif
open Shared.Data.ThothTypes
open Shared.Components
open type Html
open type prop
module BasicPage =
let render (doc: DocData) =
let paragraphs = doc.field_content |> Array.map Paragraph.render
main
[ className ""
children [ section [ className "content"; children (elems = paragraphs) ] ] ]

13
src/Shared/Shared.fs Normal file
View File

@@ -0,0 +1,13 @@
namespace Shared
open System
module Api =
let routeBuilder (typeName: string) (methodName: string) =
$"/api/v1/{typeName}/{methodName}"
type notes =
{
getNotes: unit -> Async<string []>
addNote: string -> Async<Result<string, string> >
}

27
src/Shared/Shared.fsproj Normal file
View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Lib</OutputType>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Feliz" Version="2.9.0" />
<PackageReference Include="FSharp.Data" Version="6.6.0" />
<PackageReference Include="FSharp.SystemTextJson" Version="1.4.36" />
<PackageReference Include="Feliz.ViewEngine" Version="0.27.0" />
<PackageReference Include="System.Text.Json" Version="10.0.0" />
<PackageReference Include="Thoth.Json.Net" Version="12.0.0" />
</ItemGroup>
<ItemGroup>
<Compile Include="Data/ThothTypes.fs" />
<Compile Include="Data/ThothSerializer.fs" />
<Compile Include="Components/Hero.fs" />
<Compile Include="Components/SimpleText.fs" />
<Compile Include="Components/Paragraph.fs" />
<Compile Include="Layout/Layout.fs" />
<Compile Include="Pages/BasicPage.fs" />
</ItemGroup>
</Project>

18
src/Shared/_Settings.fs Normal file
View File

@@ -0,0 +1,18 @@
module Settings
open System
let tryGetEnv =
Environment.GetEnvironmentVariable
>> function
| null
| "" -> None
| x -> Some x
let env = defaultArg (tryGetEnv "APP_ENV") "dev"
let baseUrl =
match env with
// TODO - add actual production URL?
| "prod" -> ""
| _ -> "http://oceanbox.ddev.site"

3
style/style.css Executable file
View File

@@ -0,0 +1,3 @@
body {
}

View File

@@ -0,0 +1,25 @@
module Client.Tests
open Fable.Mocha
open Index
open Shared
let client =
testList
"Client"
[
testCase "Added nonsense"
<| fun _ ->
Expect.equal 1 1 "There should be 1"
]
let all =
testList
"All"
[
client
]
[<EntryPoint>]
let main _ = Mocha.runTests all

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<DefineConstants>FABLE_COMPILER</DefineConstants>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Shared\Shared.Tests.fs" />
<Compile Include="Client.Tests.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Client\Client.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fable.Mocha" Version="2.17.0" />
<PackageReference Update="FSharp.Core" Version="8.0.400" />
</ItemGroup>
</Project>

9
test/Client/index.html Normal file
View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>foo Client Tests</title>
</head>
<body>
<script type="text/javascript" src="app.js"></script><script type="text/javascript" src="app.js"></script></body>
</html>

View File

View File

@@ -0,0 +1,24 @@
module Server.Tests
open Expecto
open Shared
open Server
let server =
testList
"Server"
[
testCase "Valid nonsense"
<| fun _ ->
let expectedResult = Ok ()
let result = Ok ()
Expect.equal result expectedResult "Result should be ok"
]
let all = testList "All" [ Shared.Tests.shared; server ]
[<EntryPoint>]
let main _ = runTests defaultConfig all

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Shared\Shared.Tests.fs" />
<Compile Include="Server.Tests.fs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Server\Server.fsproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Expecto" Version="10.2.1" />
<PackageReference Update="FSharp.Core" Version="8.0.400" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
module Shared.Tests
open Expecto
open Shared
let shared =
testList
"Shared"
[
testCase "Empty string is not a valid description"
<| fun _ ->
let expected = false
let actual = false
Expect.equal actual expected "Should be false"
]

View File

@@ -0,0 +1,9 @@
- op: replace
path: /spec/template/spec/containers/0/livenessProbe/httpGet/path
value: /healthz
- op: replace
path: /spec/template/spec/containers/0/readinessProbe/httpGet/path
value: /healthz
- op: add
path: /spec/template/spec/containers/0/envFrom
value: []

View File

@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: OceanWeb
patches:
- target:
version: v1
group: apps
kind: Deployment
path: deployment_patch.yaml
- target:
version: v1
kind: Service
path: service_patch.yaml
resources:
- _manifest.yaml

View File

@@ -0,0 +1,7 @@
- op: add
path: /spec/ports/-
value:
name: intra
port: 8000
protocol: TCP
targetPort: 8000

View File

@@ -0,0 +1,3 @@
{
"Setting" : "example setting"
}

1
tilt/tilt/default.env Normal file
View File

@@ -0,0 +1 @@
SEQ_APIKEY=secret

View File

@@ -0,0 +1,5 @@
- op: add
path: /spec/template/spec/containers/0/envFrom/-
value:
secretRef:
name: <x>-OceanWeb-env

View File

@@ -0,0 +1,18 @@
generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
- name: <x>-OceanWeb-appsettings
files:
- appsettings.json
secretGenerator:
- name: <x>-OceanWeb-env
envs:
- default.env
patches:
- target:
group: apps
version: v1
kind: Deployment
path: deployment_patch.yaml
resources:
- ../base

52
tilt/values.yaml Normal file
View File

@@ -0,0 +1,52 @@
replicaCount: 1
image:
tag: latest
podAnnotations:
dapr.io/app-id: "<x>-OceanWeb"
dapr.io/enabled: "true"
dapr.io/app-port: "8000"
dapr.io/config: "tracing"
dapr.io/app-protocol: "http"
dapr.io/enable-app-health-check: "true"
dapr.io/app-health-check-path: "/healthz"
dapr.io/app-health-probe-interval: "3"
dapr.io/app-health-probe-timeout: "200"
dapr.io/app-health-threshold: "2"
dapr.io/sidecar-cpu-request: "100m"
dapr.io/sidecar-memory-request: "250Mi"
dapr.io/sidecar-cpu-limit: "300m"
dapr.io/sidecar-memory-limit: "1000Mi"
dapr.io/log-as-json: "true"
ingress:
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
hosts:
- host: <x>-OceanWeb.dev.oceanbox.io
paths:
- path: /
pathType: ImplementationSpecific
tls:
- hosts:
- <x>-OceanWeb.dev.oceanbox.io
secretName: <x>-OceanWeb-tls
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
# resources:
# limits:
# cpu: 250m
# memory: 1Gi
# requests:
# cpu: 150m
# memory: 1Gi

47
vite.config.js Normal file
View File

@@ -0,0 +1,47 @@
import { defineConfig } from 'vite'
import { resolve } from 'path'
import tailwindcss from '@tailwindcss/vite'
var clientPort = process.env.CLIENT_PORT == null ? 8080 : parseInt(process.env.CLIENT_PORT);
var serverPort = process.env.SERVER_PORT == null ? 8085 : parseInt(process.env.SERVER_PORT);
serverPort = process.env.SERVER_PROXY_PORT == null ? serverPort : parseInt(process.env.SERVER_PROXY_PORT);
var proxy = {
target: `http://127.0.0.1:${serverPort}/`,
changeOrigin: false,
secure: false,
ws: true
}
export default defineConfig({
plugins:[
tailwindcss()
],
build: {
rollupOptions: {
input: {
index: resolve(__dirname, './src/Client/main.html'),
},
output: {
// TODO: Extra chunking of deps
// manualChunks: {
// vendor: vendorDeps,
// ...chunksFromDeps(dependencies, vendorDeps)
// },
entryFileNames: 'js/[name].js',
//chunkFileNames: 'js/[name].[hash].chunk.js',
assetFileNames: '[ext]/[name].[ext]',
},
},
minify: "oxc",
},
server: {
port: clientPort,
host: '0.0.0.0',
https: false,
cors: true,
proxy: {
'/': proxy,
}
},
})