Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,6 @@ func TestFunction(t *testing.T) {

### Vite plugin tests

- `tests/feature/vite_plugin_test.go` runs the published `goravel-vite-plugin` against a real `vite build` and `npm run dev`. It runs `npm ci` itself when `node_modules/goravel-vite-plugin` is not installed, so the first run needs the network.
- `tests/feature/vite_plugin_test.go` runs `goravel-vite-plugin` against a real `vite build` and `npm run dev`. For now `package.json` links the local checkout (`file:../vite-plugin`, branch `modular`, built with `npm run build`), since no release builds module entries yet. It runs `npm ci` itself when `node_modules/goravel-vite-plugin` is not installed.
- The suite builds into `public/build` and owns `public/hot` while it runs; it removes the build and puts back the hot file a dev server had written.
- `vite.build_directory` and `vite.hot_file` in `config/vite.go` are paths relative to `public` (`build`, `hot`); a fixture hot file is a path under `public` too, never absolute.
9 changes: 9 additions & 0 deletions app/facades/modular.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package facades

import (
"github.com/goravel/framework/contracts/modular"
)

func Modular() modular.Registry {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about 'Module'?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean Module.Registry() ?

return App().MakeModular()
}
1 change: 1 addition & 0 deletions bootstrap/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ var BootstrapRunners = func() []contractsfoundation.Runner {

func Boot() contractsfoundation.Application {
return foundation.Setup().
WithModules(Modules).
WithMigrations(Migrations).
WithSeeders(Seeders).
WithRouting(func() {
Expand Down
15 changes: 15 additions & 0 deletions bootstrap/modules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package bootstrap

import (
"github.com/goravel/framework/contracts/modular"

"goravel/modules/admin"
"goravel/modules/blog"
)

func Modules() []modular.Module {
return []modular.Module{
&admin.Module{},
&blog.Module{},
}
}
21 changes: 21 additions & 0 deletions config/modular.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package config

import (
"github.com/goravel/framework/contracts/http"

"goravel/app/facades"
)

func init() {
config := facades.Config()
config.Add("modular", map[string]any{
// Directory of the application's own modules, relative to the project root.
"path": "modules",

// Route groups wrapping what modules register with Routes() and ApiRoutes().
"routes": map[string]any{
"web": map[string]any{"prefix": "", "middleware": []http.Middleware{}},
"api": map[string]any{"prefix": "api", "middleware": []http.Middleware{}},
},
})
}
Binary file modified goravel
Binary file not shown.
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"goravel/bootstrap"
)

//go:generate go run . artisan modular:publish

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why need this line?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feature exists for third party modules. If the module source does not live in the project dir then vite cannot work properly (building, figuring out the path). Similar logic applies for language files, views etc. We need an way to bring them into project dir.
go provides a generate command.

The go generate command is a built-in Go tool that automates running code-generation programs directly from instructions embedded in your Go source files. It is designed for package authors to reduce repetitive boilerplate code, and it must be run manually during development rather than automatically during go build

We are using it to make the integration easier.
People will be able to build individual parts like auth, blog, chat - publish and re-use them without requiring to change anything.


func main() {
app := bootstrap.Boot()

Expand Down
Empty file added modules/.gitkeep
Empty file.
10 changes: 10 additions & 0 deletions modules/admin/contracts/dashboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Package contracts holds the capabilities other modules implement to take part
// in the admin. Go interfaces are structural, so implementing one needs no import.
package contracts

// ProvidesDashboardScripts lists the Vite entries the admin loads before it
// mounts, relative to the module directory, e.g. "resources/js/dashboard.ts".
// Each one must also be in the module's ViteEntries(), which is what builds it.
type ProvidesDashboardScripts interface {
DashboardScripts() []string
}
59 changes: 59 additions & 0 deletions modules/admin/dashboard/entries.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Package dashboard decides which Vite entries make up the admin page.
package dashboard

import (
"fmt"
"path"
"path/filepath"
"slices"

"github.com/goravel/framework/contracts/modular"

"goravel/modules/admin/contracts"
)

// Name is the admin module's name, Main its own entry, which mounts the admin.
const (
Name = "admin"
Main = "resources/js/main.ts"
)

// Entries returns the project-relative Vite entries of the admin page: every
// module's dashboard scripts in module order, then the admin's Main, so all
// modules have registered by the time the admin mounts.
func Entries(registry modular.Registry) ([]string, error) {
var entries []string
for _, module := range registry.All() {
provider, ok := module.(contracts.ProvidesDashboardScripts)
if !ok {
continue
}

var declared []string
if vite, ok := module.(modular.ProvidesViteEntries); ok {
declared = vite.ViteEntries()
}

for _, script := range provider.DashboardScripts() {
if !slices.Contains(declared, script) {
return nil, fmt.Errorf("dashboard script [%s] of module [%s] is not in its ViteEntries()", script, module.Name())
}

entries = append(entries, entry(registry, module.Name(), script))
}
}

if !registry.Has(Name) {
return nil, fmt.Errorf("module [%s] is not registered", Name)
}

return append(entries, entry(registry, Name, Main)), nil
}

// entry joins a module-relative file onto the module directory, in the slash
// form Vite's manifest uses.
func entry(registry modular.Registry, name, file string) string {
dir, _ := registry.Path(name)

return path.Join(filepath.ToSlash(dir), file)
}
51 changes: 51 additions & 0 deletions modules/admin/http/controllers/dashboard_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package controllers

import (
"encoding/json"
"strings"

"github.com/goravel/framework/contracts/http"

"goravel/app/facades"
"goravel/modules/admin/dashboard"
)

type DashboardController struct{}

func NewDashboardController() *DashboardController {
return &DashboardController{}
}

// Show renders the page the admin mounts in, with every dashboard script in one
// Vite call so shared chunks are preloaded once.
func (r *DashboardController) Show(ctx http.Context) http.Response {
entries, err := dashboard.Entries(facades.Modular())
if err != nil {
return r.fail(ctx, err)
}

tags, err := facades.Vite().Tags(entries...)
if err != nil {
return r.fail(ctx, err)
}

boot, err := json.Marshal(map[string]any{
"appName": facades.Config().GetString("app.name"),
"basePath": "/" + strings.Trim(facades.Config().GetString("modules.admin.path", "admin"), "/"),
})
if err != nil {
return r.fail(ctx, err)
}

return ctx.Response().View().Make("admin/dashboard.tmpl", map[string]any{
"title": facades.Lang(ctx).Get("admin/messages.title"),
"tags": tags,
"boot": string(boot),
})
}

func (r *DashboardController) fail(ctx http.Context, err error) http.Response {
facades.Log().Error(err)

return ctx.Response().String(http.StatusInternalServerError, err.Error())
}
3 changes: 3 additions & 0 deletions modules/admin/lang/en/messages.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"title": "Admin"
}
59 changes: 59 additions & 0 deletions modules/admin/module.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package admin

import (
"embed"
"io/fs"

"github.com/goravel/framework/contracts/route"

"goravel/modules/admin/dashboard"
"goravel/modules/admin/routes"
)

//go:embed resources/views
var views embed.FS

//go:embed lang
var lang embed.FS

// Module is a Vue admin that other modules fill: they register pages and
// dashboard widgets from a dashboard script (contracts.ProvidesDashboardScripts).
type Module struct{}

func (m *Module) Name() string {
return dashboard.Name
}

// Order -1 makes the admin the last module, so its routes come after the others'.
func (m *Module) Order() int {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not respect the module slice order in the bootstrap/modules.go file? Then this function can be removed entirely.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make:module inserts in the end, and some app may require this ordering i.e. put themselves in the end. users don't need to be aware of that order unless necessary. this is also optional, so no need to implement if not necessary

return -1
}

// Config is available as "modules.admin": path is where the admin answers.
func (m *Module) Config() map[string]any {
return map[string]any{
"path": "admin",
}
}

func (m *Module) Routes(router route.Router) {
routes.Web(router)
}

// Views are named "admin/<file>". The application already has views under
// resources/views/admin, so this module's page is "admin/dashboard.tmpl".
func (m *Module) Views() fs.FS {
sub, _ := fs.Sub(views, "resources/views")

return sub
}

func (m *Module) Translations() fs.FS {
sub, _ := fs.Sub(lang, "lang")

return sub
}

func (m *Module) ViteEntries() []string {
return []string{dashboard.Main}
}
33 changes: 33 additions & 0 deletions modules/admin/resources/js/admin.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
:root {
--admin-bg: #f6f7f9;
--admin-surface: #fff;
--admin-border: #e3e6ea;
--admin-text: #1d2433;
--admin-muted: #687284;
--admin-accent: #3b5bdb;
font-family: system-ui, sans-serif;
color: var(--admin-text);
background: var(--admin-bg);
}

body { margin: 0; }
.admin { display: flex; min-height: 100vh; }
.admin-sidebar { width: 220px; padding: 20px 12px; background: var(--admin-surface); border-right: 1px solid var(--admin-border); }
.admin-brand { display: block; padding: 0 8px 16px; }
.admin-sidebar h2 { margin: 16px 8px 4px; font-size: 12px; text-transform: uppercase; color: var(--admin-muted); }
.admin-sidebar a { display: block; padding: 6px 8px; border-radius: 6px; color: inherit; text-decoration: none; }
.admin-sidebar a.active { background: var(--admin-bg); color: var(--admin-accent); }
.admin-main { flex: 1; padding: 20px 32px; }
.admin-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 24px; }
.admin-header h1 { margin: 0; font-size: 22px; }
.admin-header-actions { display: flex; gap: 8px; }
.admin-widgets { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
.admin-card { padding: 16px; background: var(--admin-surface); border: 1px solid var(--admin-border); border-radius: 8px; }
.admin-card h3 { margin: 0 0 4px; font-size: 13px; font-weight: 500; color: var(--admin-muted); }
.admin-stat { font-size: 30px; font-weight: 600; }
.admin-button { display: inline-block; padding: 7px 14px; border: 0; border-radius: 6px; background: var(--admin-accent); color: #fff; font: inherit; text-decoration: none; cursor: pointer; }
.admin-table { width: 100%; border-collapse: collapse; background: var(--admin-surface); }
.admin-table th, .admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--admin-border); text-align: left; }
.admin-form { display: grid; gap: 12px; max-width: 520px; }
.admin-form input, .admin-form textarea { padding: 8px; border: 1px solid var(--admin-border); border-radius: 6px; font: inherit; }
.admin-error { color: #c92a2a; font-size: 13px; }
17 changes: 17 additions & 0 deletions modules/admin/resources/js/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// What a dashboard script imports as '@admin/index'. Its first import is the
// admin's own registration, so the dashboard comes before every module.
import DashboardPage from './layout/DashboardPage.vue'
import { registerModule } from './lib/registry'

registerModule({
id: 'dashboard',
menu: (menu) => [{ label: 'General', items: [{ label: 'Dashboard', path: '/' }] }, ...menu],
routes: [{ path: '/', component: DashboardPage }],
})

export { default as AdminLink } from './layout/AdminLink.vue'
export { DashboardWidgets, HeaderActions } from './layout/slots'
export { basePath } from './lib/boot'
export { registerModule, type AdminRoute, type MenuFilter, type MenuGroup, type MenuItem, type ModuleRegistration } from './lib/registry'
export { navigate } from './lib/router'
export { createSlot, type Slot } from './lib/slot'
50 changes: 50 additions & 0 deletions modules/admin/resources/js/layout/AdminLayout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue'

import { appName } from '../lib/boot'
import { menu, modules, routes } from '../lib/registry'
import { activePath, currentPath, match } from '../lib/router'
import AdminLink from './AdminLink.vue'
import { DashboardWidgets, HeaderActions } from './slots'

// The registry is sealed before mount, so these are read once.
const groups = menu()
const fills = modules().flatMap((module) => (module.fills ? [module.fills] : []))
const allRoutes = routes()

const matched = computed(() => match(allRoutes, currentPath.value))
const active = computed(() => activePath(currentPath.value, groups))
const title = computed(() => groups.flatMap((group) => group.items).find((item) => item.path === active.value)?.label ?? appName)
</script>

<template>
<DashboardWidgets.Provider>
<HeaderActions.Provider>
<div class="admin">
<aside class="admin-sidebar">
<strong class="admin-brand">{{ appName }}</strong>
<nav v-for="group in groups" :key="group.label">
<h2>{{ group.label }}</h2>
<AdminLink v-for="item in group.items" :key="item.path" :to="item.path" :class="{ active: item.path === active }">
{{ item.label }}
</AdminLink>
</nav>
</aside>

<div class="admin-main">
<header class="admin-header">
<h1>{{ title }}</h1>
<HeaderActions.Outlet class="admin-header-actions" />
</header>

<main>
<component :is="matched.route.component" v-if="matched" :key="currentPath" v-bind="matched.params" />
<p v-else>Page not found.</p>
</main>
</div>
</div>

<component :is="fill" v-for="(fill, i) in fills" :key="i" />
</HeaderActions.Provider>
</DashboardWidgets.Provider>
</template>
17 changes: 17 additions & 0 deletions modules/admin/resources/js/layout/AdminLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { href, navigate } from '../lib/router'

const props = defineProps<{ to: string }>()

// Client-side navigation for a plain click; a new tab or window still gets the real URL.
function onClick(event: MouseEvent) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return

event.preventDefault()
navigate(props.to)
}
</script>

<template>
<a :href="href(to)" @click="onClick"><slot /></a>
</template>
7 changes: 7 additions & 0 deletions modules/admin/resources/js/layout/DashboardPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script setup lang="ts">
import { DashboardWidgets } from './slots'
</script>

<template>
<DashboardWidgets.Outlet class="admin-widgets" />
</template>
7 changes: 7 additions & 0 deletions modules/admin/resources/js/layout/slots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createSlot } from '../lib/slot'

/** The dashboard's widget grid. Modules fill it from the `fills` of their registration, in module order. */
export const DashboardWidgets = createSlot('dashboard-widgets')

/** The top right of the header. A page renders <HeaderActions.Fill> for as long as it is shown. */
export const HeaderActions = createSlot('header-actions')
Loading
Loading