Skip to content

Commit 1b6ecec

Browse files
authored
feat: URL-addressable preview with suffix-based routing (#426)
1 parent 8269bb4 commit 1b6ecec

28 files changed

+732
-504
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@frontman/frontman-core": minor
3+
"@frontman-ai/astro": minor
4+
"@frontman-ai/vite": minor
5+
"@frontman-ai/nextjs": minor
6+
"@frontman/client": minor
7+
---
8+
9+
URL-addressable preview: persist iframe URL in browser address bar using suffix-based routing. Navigation within the preview iframe is now reflected in the browser URL, enabling shareable deep links and browser back/forward support.

apps/frontman_server/config/runtime.exs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ if config_env() in [:dev, :test] do
2727
openai_api_key: env!("OPENAI_API_KEY", :string, nil)
2828
end
2929

30-
# Discord webhook for #new-users signup alerts
31-
config :frontman_server,
32-
discord_new_users_webhook_url: env!("DISCORD_NEW_USERS_WEBHOOK_URL", :string!),
33-
discord_pg_channel: "new_user"
34-
3530
# WorkOS configuration for OAuth (GitHub, Google)
3631
config :workos, WorkOS.Client,
3732
api_key: env!("WORKOS_API_KEY", :string, nil),
@@ -91,6 +86,10 @@ if config_env() in [:dev, :test] do
9186
end
9287

9388
if config_env() == :prod do
89+
config :frontman_server,
90+
discord_new_users_webhook_url: env!("DISCORD_NEW_USERS_WEBHOOK_URL", :string!),
91+
discord_pg_channel: "new_user"
92+
9493
config :sentry,
9594
dsn:
9695
"https://442ae992e5a5ccfc42e6910220aeb2a9@o4510512511320064.ingest.de.sentry.io/4510512546185296",

apps/frontman_server/envs/.test.env

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,3 @@ GOOGLE_API_KEY=test-google-key
44
XAI_API_KEY=test-xai-key
55
OPENROUTER_API_KEY=test-openrouter-key
66
REQ_LLM_FIXTURES_MODE=replay
7-
DISCORD_NEW_USERS_WEBHOOK_URL=https://discord.com/api/webhooks/test/test

apps/frontman_server/lib/frontman_server/application.ex

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ defmodule FrontmanServer.Application do
3232
nil
3333
)
3434

35+
# Discord new-user signup alerts (PG LISTEN/NOTIFY → webhook) – prod only
36+
discord_children =
37+
if Application.get_env(:frontman_server, :discord_new_users_webhook_url) do
38+
[
39+
{Postgrex.Notifications, [name: FrontmanServer.PGNotifications] ++ pg_notify_opts()},
40+
{FrontmanServer.Notifications.Discord,
41+
webhook_url: Application.get_env(:frontman_server, :discord_new_users_webhook_url),
42+
channel: Application.get_env(:frontman_server, :discord_pg_channel),
43+
notifications_pid: FrontmanServer.PGNotifications}
44+
]
45+
else
46+
[]
47+
end
48+
3549
children =
3650
[
3751
FrontmanServerWeb.Telemetry,
@@ -46,14 +60,8 @@ defmodule FrontmanServer.Application do
4660
# TaskSupervisor for agent execution tasks
4761
{Task.Supervisor, name: FrontmanServer.TaskSupervisor},
4862
# Start to serve requests, typically the last entry
49-
FrontmanServerWeb.Endpoint,
50-
# Discord new-user signup alerts (PG LISTEN/NOTIFY → webhook)
51-
{Postgrex.Notifications, [name: FrontmanServer.PGNotifications] ++ pg_notify_opts()},
52-
{FrontmanServer.Notifications.Discord,
53-
webhook_url: Application.get_env(:frontman_server, :discord_new_users_webhook_url),
54-
channel: Application.get_env(:frontman_server, :discord_pg_channel),
55-
notifications_pid: FrontmanServer.PGNotifications}
56-
]
63+
FrontmanServerWeb.Endpoint
64+
] ++ discord_children
5765

5866
# See https://hexdocs.pm/elixir/Supervisor.html
5967
# for other strategies and supported options

libs/bindings/src/Astro.res

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,24 @@ type astroConfig = {
2424
// Vite plugin type — opaque, we just pass plugin objects through
2525
type vitePlugin
2626

27+
// Vite dev server connect middleware stack
28+
type connectMiddlewareStack
29+
30+
@send
31+
external use: (connectMiddlewareStack, NodeHttp.connectMiddleware) => unit = "use"
32+
33+
// Vite dev server (minimal bindings for astro:server:setup)
34+
type viteDevServer = {middlewares: connectMiddlewareStack}
35+
36+
// Config for constructing a Vite plugin with typed fields we use.
37+
// Keeps vitePlugin opaque while avoiding Obj.magic at call sites.
38+
type vitePluginConfig = {
39+
name: string,
40+
configureServer?: viteDevServer => unit,
41+
}
42+
43+
external makeVitePlugin: vitePluginConfig => vitePlugin = "%identity"
44+
2745
// Partial Astro config for updateConfig — only the fields we need
2846
type partialViteConfig = {plugins?: array<vitePlugin>}
2947
type partialAstroConfig = {vite?: partialViteConfig}
@@ -38,15 +56,6 @@ type configSetupHookContext = {
3856
command: astroCommand,
3957
}
4058

41-
// Vite dev server connect middleware stack
42-
type connectMiddlewareStack
43-
44-
@send
45-
external use: (connectMiddlewareStack, NodeHttp.connectMiddleware) => unit = "use"
46-
47-
// Vite dev server (minimal bindings for astro:server:setup)
48-
type viteDevServer = {middlewares: connectMiddlewareStack}
49-
5059
// --- Server-side toolbar object (available in astro:server:setup hook) ---
5160
// Must be defined before serverSetupHookContext which references it.
5261

libs/bindings/src/LocalStorage.res

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Bindings for the Web Storage API (localStorage)
2+
3+
@val @scope("localStorage")
4+
external getItem: string => Nullable.t<string> = "getItem"
5+
6+
@val @scope("localStorage")
7+
external setItem: (string, string) => unit = "setItem"
8+
9+
@val @scope("localStorage")
10+
external removeItem: string => unit = "removeItem"
11+
12+
@val @scope("localStorage")
13+
external key: int => Nullable.t<string> = "key"
14+
15+
@val @scope("localStorage")
16+
external length: int = "length"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
type t
2+
3+
type mutationRecord = {
4+
@as("type") type_: string,
5+
target: WebAPI.DOMAPI.node,
6+
addedNodes: array<WebAPI.DOMAPI.node>,
7+
removedNodes: array<WebAPI.DOMAPI.node>,
8+
attributeName: Null.t<string>,
9+
oldValue: Null.t<string>,
10+
}
11+
12+
type observeOptions = {
13+
"childList": bool,
14+
"attributes": bool,
15+
"characterData": bool,
16+
"subtree": bool,
17+
"attributeOldValue": bool,
18+
"characterDataOldValue": bool,
19+
}
20+
21+
@new
22+
external make: (array<mutationRecord> => unit) => t = "MutationObserver"
23+
24+
@send
25+
external observe: (t, WebAPI.DOMAPI.node, observeOptions) => unit = "observe"
26+
27+
@send
28+
external disconnect: t => unit = "disconnect"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
type destination
2+
3+
type t
4+
5+
@get external destination: t => destination = "destination"
6+
@get external url: destination => string = "url"

libs/client/src/Client__FtueState.res

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,13 @@ type t =
1616
| WelcomeShown
1717
| Completed
1818

19-
@val @scope("localStorage") external getItem: string => Nullable.t<string> = "getItem"
20-
@val @scope("localStorage") external setItem: (string, string) => unit = "setItem"
21-
@val @scope("localStorage") external localStorageKey: (int) => Nullable.t<string> = "key"
22-
@val @scope("localStorage") external localStorageLength: int = "length"
23-
2419
// Check whether any other frontman:* localStorage key exists, indicating a returning user
2520
let hasExistingFrontmanData = (): bool => {
2621
try {
27-
let len = localStorageLength
22+
let len = FrontmanBindings.LocalStorage.length
2823
let found = ref(false)
2924
for i in 0 to len - 1 {
30-
switch localStorageKey(i)->Nullable.toOption {
25+
switch FrontmanBindings.LocalStorage.key(i)->Nullable.toOption {
3126
| Some(k) =>
3227
switch k->String.startsWith("frontman:") && k !== storageKey {
3328
| true => found := true
@@ -44,15 +39,15 @@ let hasExistingFrontmanData = (): bool => {
4439

4540
let get = (): t => {
4641
try {
47-
switch getItem(storageKey)->Nullable.toOption {
42+
switch FrontmanBindings.LocalStorage.getItem(storageKey)->Nullable.toOption {
4843
| Some("welcome_shown") => WelcomeShown
4944
| Some("completed") => Completed
5045
| Some(_) | None =>
5146
// No FTUE key — check if user is truly new or an existing user who predates FTUE
5247
switch hasExistingFrontmanData() {
5348
| true =>
5449
// Auto-migrate existing user: write Completed so this check only runs once
55-
setItem(storageKey, "completed")
50+
FrontmanBindings.LocalStorage.setItem(storageKey, "completed")
5651
Completed
5752
| false => New
5853
}
@@ -63,9 +58,9 @@ let get = (): t => {
6358
}
6459

6560
let setWelcomeShown = () => {
66-
setItem(storageKey, "welcome_shown")
61+
FrontmanBindings.LocalStorage.setItem(storageKey, "welcome_shown")
6762
}
6863

6964
let setCompleted = () => {
70-
setItem(storageKey, "completed")
65+
FrontmanBindings.LocalStorage.setItem(storageKey, "completed")
7166
}

0 commit comments

Comments
 (0)