Safsaf is a Guile web framework, written using Claude Code running Claude Opus 4.6, based off of the Guix Data Service, Nar Herder and Guix Build Coordinator codebases.
This commit is contained in:
commit
5b0e6397dc
53 changed files with 7427 additions and 0 deletions
55
doc/Makefile.am
Normal file
55
doc/Makefile.am
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Safsaf, a Guile web framework
|
||||
# Copyright (C) 2026 Christopher Baines <mail@cbaines.net>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU Lesser General Public License
|
||||
# as published by the Free Software Foundation, either version 3 of
|
||||
# the License, or (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
|
||||
info_TEXINFOS = index.texi
|
||||
index_TEXINFOS = guidance.texi api/index.texi version-history.texi
|
||||
|
||||
version-history.texi: $(top_srcdir)/NEWS
|
||||
$(AM_V_GEN)$(GUILE) $(top_srcdir)/build-aux/news-to-texi.scm $< > $@
|
||||
|
||||
API_SOURCES = \
|
||||
$(top_srcdir)/safsaf.scm \
|
||||
$(top_srcdir)/safsaf/utils.scm \
|
||||
$(top_srcdir)/safsaf/templating.scm \
|
||||
$(top_srcdir)/safsaf/response-helpers.scm \
|
||||
$(top_srcdir)/safsaf/params.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/logging.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/security-headers.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/cors.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/csrf.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/exceptions.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/sessions.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/trailing-slash.scm \
|
||||
$(top_srcdir)/safsaf/handler-wrappers/max-body-size.scm \
|
||||
$(top_srcdir)/safsaf/router.scm
|
||||
|
||||
html-local: index.html
|
||||
|
||||
index.html: index.texi $(index_TEXINFOS)
|
||||
$(AM_V_GEN)$(MAKEINFO) --css-ref=https://luis-felipe.gitlab.io/texinfo-css/static/css/texinfo-7.css \
|
||||
--no-split --html -c SHOW_TITLE=true -o $@ $(srcdir)/index.texi
|
||||
|
||||
EXTRA_DIST = logo.svg
|
||||
|
||||
CLEANFILES = index.html
|
||||
|
||||
if HAVE_DOCUMENTA
|
||||
api/index.texi: $(API_SOURCES)
|
||||
$(AM_V_GEN)$(top_builddir)/pre-inst-env \
|
||||
$(DOCUMENTA) api -d $(srcdir)/api \
|
||||
$(top_srcdir)/safsaf.scm $(top_srcdir)/safsaf/
|
||||
endif
|
||||
424
doc/guidance.texi
Normal file
424
doc/guidance.texi
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
@node Guidance
|
||||
@chapter Guidance
|
||||
|
||||
This chapter explains how the pieces of Safsaf fit together. Each
|
||||
section covers one concept with a short code example. For the full
|
||||
list of parameters and options, see @ref{API}.
|
||||
|
||||
@menu
|
||||
* Getting Started:: A minimal runnable server.
|
||||
* Routing:: Route patterns, groups, and reverse routing.
|
||||
* Handler Wrappers:: Composing middleware via wrap-routes.
|
||||
* Responses:: HTML, JSON, text, redirects, and errors.
|
||||
* Request Parsing:: Forms, query strings, multipart, cookies.
|
||||
* Parameter Parsing:: Declarative param specs with validation.
|
||||
* Sessions:: Signed cookie sessions.
|
||||
* Templating:: Streaming HTML with dynamic slots.
|
||||
* Static Files:: Serving files from disk.
|
||||
@end menu
|
||||
|
||||
|
||||
@node Getting Started
|
||||
@section Getting Started
|
||||
|
||||
A Safsaf application is a list of routes passed to @code{run-safsaf}.
|
||||
Each route binds an HTTP method and URL pattern to a handler procedure.
|
||||
The handler receives a Guile @code{<request>} and a body port, and
|
||||
returns two values: a response and a body.
|
||||
|
||||
@lisp
|
||||
(use-modules (safsaf)
|
||||
(safsaf router)
|
||||
(safsaf response-helpers))
|
||||
|
||||
(define routes
|
||||
(list
|
||||
(route 'GET '() index-page
|
||||
#:name 'index)
|
||||
(route 'GET '("hello" name) hello-page)
|
||||
(route '* '* (lambda (request body-port)
|
||||
(not-found-response)))))
|
||||
|
||||
(define (index-page request body-port)
|
||||
(html-response '(h1 "Welcome")))
|
||||
|
||||
(define (hello-page request body-port)
|
||||
(let ((name (assoc-ref (current-route-params) 'name)))
|
||||
(text-response (string-append "Hello, " name "!"))))
|
||||
|
||||
(run-safsaf routes #:port 8080)
|
||||
@end lisp
|
||||
|
||||
The last route should be a catch-all (@code{'*} method, @code{'*}
|
||||
pattern) so that every request is handled. @code{run-safsaf} sets up
|
||||
a Fibers scheduler, starts the HTTP server, and blocks until Ctrl-C.
|
||||
|
||||
|
||||
@node Routing
|
||||
@section Routing
|
||||
|
||||
@subheading Patterns
|
||||
|
||||
Route patterns are lists of segments. A string matches literally, a
|
||||
symbol captures that segment into @code{current-route-params}, and a
|
||||
two-element list @code{(predicate name)} captures only when
|
||||
@var{predicate} returns true. A dotted tail captures the remaining
|
||||
path.
|
||||
|
||||
@lisp
|
||||
;; Literal: /about
|
||||
(route 'GET '("about") about-handler)
|
||||
|
||||
;; Capture: /users/:id
|
||||
(route 'GET '("users" id) show-user)
|
||||
|
||||
;; Predicate: /posts/:id where id is numeric
|
||||
(route 'GET '("posts" (,string->number id)) show-post)
|
||||
|
||||
;; Wildcard (rest): /files/* — captures remaining segments
|
||||
(route 'GET '("files" . path) serve-file)
|
||||
@end lisp
|
||||
|
||||
@subheading Route groups
|
||||
|
||||
@code{route-group} nests routes under a shared prefix:
|
||||
|
||||
@lisp
|
||||
(route-group '("api" "v1")
|
||||
(route 'GET '("users") api-list-users)
|
||||
(route 'GET '("users" id) api-show-user))
|
||||
@end lisp
|
||||
|
||||
This matches @code{/api/v1/users} and @code{/api/v1/users/:id}.
|
||||
|
||||
@subheading Named routes and path-for
|
||||
|
||||
Give a route a @code{#:name} and use @code{path-for} to generate its
|
||||
URL, so paths are never hard-coded. The first argument is always a
|
||||
route group:
|
||||
|
||||
@lisp
|
||||
(define my-routes
|
||||
(route-group '()
|
||||
(route 'GET '("posts" id) show-post #:name 'show-post)))
|
||||
|
||||
(define all-routes
|
||||
(list my-routes
|
||||
(route '* '* (lambda (r b) (not-found-response)))))
|
||||
|
||||
;; In a handler or template:
|
||||
(path-for my-routes 'show-post '((id . "42")))
|
||||
;; => "/posts/42"
|
||||
@end lisp
|
||||
|
||||
@code{path-for} also accepts @code{#:query} and @code{#:fragment}
|
||||
keyword arguments.
|
||||
|
||||
|
||||
@node Handler Wrappers
|
||||
@section Handler Wrappers
|
||||
|
||||
A handler wrapper is a procedure that takes a handler and returns a
|
||||
new handler. It can transform the request on the way in and the
|
||||
response on the way out. Apply wrappers to a route tree with
|
||||
@code{wrap-routes}.
|
||||
|
||||
@lisp
|
||||
(wrap-routes routes
|
||||
(make-exceptions-handler-wrapper #:dev? #t)
|
||||
logging-handler-wrapper)
|
||||
@end lisp
|
||||
|
||||
When multiple wrappers are given, the first wraps outermost — it runs
|
||||
first on the request and last on the response. In the example above,
|
||||
exceptions catches errors from the logging wrapper and the inner
|
||||
handler.
|
||||
|
||||
@subheading Per-group wrappers
|
||||
|
||||
Apply wrappers to part of the route tree by wrapping a group
|
||||
separately:
|
||||
|
||||
@lisp
|
||||
(define api-routes
|
||||
(wrap-routes
|
||||
(route-group '("api")
|
||||
(route 'GET '("items") api-list-items))
|
||||
cors-handler-wrapper))
|
||||
|
||||
(define all-routes
|
||||
(wrap-routes
|
||||
(list api-routes
|
||||
(route 'GET '() index-page)
|
||||
(route '* '* (lambda (r b) (not-found-response))))
|
||||
logging-handler-wrapper))
|
||||
@end lisp
|
||||
|
||||
Here CORS headers are added only to @code{/api/*} routes, while
|
||||
logging applies to everything.
|
||||
|
||||
@subheading Security headers
|
||||
|
||||
@code{security-headers-handler-wrapper} @emph{appends} its headers to
|
||||
the response rather than replacing existing ones. If a handler sets
|
||||
@code{X-Frame-Options} itself, both values will appear in the response.
|
||||
To avoid duplication, either omit the header from the wrapper (pass
|
||||
@code{#:frame-options #f}) or do not set it in the handler.
|
||||
|
||||
@subheading Max body size
|
||||
|
||||
@code{make-max-body-size-handler-wrapper} checks the
|
||||
@code{Content-Length} header and rejects requests that exceed the
|
||||
limit with a 413 response. However, it does @emph{not} limit chunked
|
||||
transfer-encoded requests that lack @code{Content-Length}. For
|
||||
untrusted networks, use a reverse proxy (e.g.@: Nginx's
|
||||
@code{client_max_body_size}) to enforce size limits at the transport
|
||||
level.
|
||||
|
||||
|
||||
@node Responses
|
||||
@section Responses
|
||||
|
||||
Safsaf provides helpers that return @code{(values response body)}
|
||||
directly:
|
||||
|
||||
@lisp
|
||||
;; HTML — streams an SXML tree
|
||||
(html-response '(div (h1 "Hello") (p "world")))
|
||||
|
||||
;; JSON — takes a JSON string
|
||||
(json-response (scm->json-string '(("ok" . #t))))
|
||||
|
||||
;; Plain text
|
||||
(text-response "pong")
|
||||
|
||||
;; Redirect (default 303 See Other)
|
||||
(redirect-response "/login")
|
||||
(redirect-response "/new-item" #:code 302)
|
||||
|
||||
;; Error responses
|
||||
(not-found-response)
|
||||
(bad-request-response "Missing field")
|
||||
(forbidden-response)
|
||||
@end lisp
|
||||
|
||||
@code{html-response}, @code{json-response}, @code{text-response}, and
|
||||
@code{redirect-response} accept @code{#:code} and @code{#:headers} for
|
||||
overrides. The error helpers (@code{not-found-response}, etc.)@: accept
|
||||
@code{#:headers} but have a fixed status code.
|
||||
|
||||
For content negotiation, use @code{negotiate-content-type}:
|
||||
|
||||
@lisp
|
||||
(define (show-item request body-port)
|
||||
(let ((item (fetch-item (assoc-ref (current-route-params) 'id))))
|
||||
(case (negotiate-content-type request
|
||||
'(text/html application/json))
|
||||
((application/json)
|
||||
(json-response (scm->json-string (item->alist item))))
|
||||
(else
|
||||
(html-response `(div (h1 ,(item-title item))))))))
|
||||
@end lisp
|
||||
|
||||
|
||||
@node Request Parsing
|
||||
@section Request Parsing
|
||||
|
||||
@subheading Form bodies
|
||||
|
||||
@code{parse-form-body} reads a URL-encoded POST body and returns an
|
||||
alist of string pairs:
|
||||
|
||||
@lisp
|
||||
(define (handle-login request body-port)
|
||||
(let* ((form (parse-form-body request body-port))
|
||||
(username (assoc-ref form "username"))
|
||||
(password (assoc-ref form "password")))
|
||||
(if (valid-credentials? username password)
|
||||
(redirect-response "/dashboard")
|
||||
(text-response "Invalid login" #:code 401))))
|
||||
@end lisp
|
||||
|
||||
@subheading Query strings
|
||||
|
||||
@code{parse-query-string} extracts query parameters from the request
|
||||
URL:
|
||||
|
||||
@lisp
|
||||
(let ((qs (parse-query-string request)))
|
||||
(assoc-ref qs "page")) ;; => "2" or #f
|
||||
@end lisp
|
||||
|
||||
@subheading Multipart
|
||||
|
||||
For file uploads, use @code{parse-multipart-body}:
|
||||
|
||||
@lisp
|
||||
(let* ((parts (parse-multipart-body request body-port))
|
||||
(form (multipart-text-fields parts))
|
||||
(file (parts-ref parts "avatar")))
|
||||
;; form is an alist of text fields
|
||||
;; file is a <part> record — read its body with (part-body file)
|
||||
...)
|
||||
@end lisp
|
||||
|
||||
@subheading Cookies
|
||||
|
||||
Read cookies with @code{request-cookie-ref} or
|
||||
@code{request-cookies}. Set them via response headers with
|
||||
@code{set-cookie-header} and @code{delete-cookie-header}:
|
||||
|
||||
@lisp
|
||||
(request-cookie-ref request "theme") ;; => "dark" or #f
|
||||
|
||||
(text-response "ok"
|
||||
#:headers (list (set-cookie-header "theme" "dark"
|
||||
#:path "/"
|
||||
#:http-only #t)))
|
||||
@end lisp
|
||||
|
||||
|
||||
@node Parameter Parsing
|
||||
@section Parameter Parsing
|
||||
|
||||
@code{parse-params} validates and transforms raw form or query data
|
||||
according to a declarative spec. Each spec entry names a parameter,
|
||||
a processor (a procedure that converts a string or returns an
|
||||
@code{<invalid-param>}), and options like @code{#:required} or
|
||||
@code{#:default}.
|
||||
|
||||
@lisp
|
||||
(let ((params (parse-params
|
||||
`((page ,as-integer #:default 1)
|
||||
(per-page ,as-integer #:default 20)
|
||||
(q ,as-string))
|
||||
(parse-query-string request))))
|
||||
(assq-ref params 'page)) ;; => 1 (integer, not string)
|
||||
@end lisp
|
||||
|
||||
Built-in processors: @code{as-string}, @code{as-integer},
|
||||
@code{as-number}, @code{as-checkbox}, @code{as-one-of},
|
||||
@code{as-matching}, @code{as-predicate}.
|
||||
|
||||
@subheading Form params with CSRF
|
||||
|
||||
For POST forms, use @code{parse-form-params} instead — it
|
||||
automatically checks the CSRF token (from
|
||||
@code{csrf-handler-wrapper}) before parsing:
|
||||
|
||||
@lisp
|
||||
(let* ((form (parse-form-body request body-port))
|
||||
(params (parse-form-params
|
||||
`((title ,as-string #:required)
|
||||
(body ,as-string #:required))
|
||||
form)))
|
||||
(if (any-invalid-params? params)
|
||||
;; Re-render the form with errors
|
||||
(render-form (field-errors params 'title)
|
||||
(field-errors params 'body))
|
||||
;; Proceed
|
||||
(create-item! (assq-ref params 'title)
|
||||
(assq-ref params 'body))))
|
||||
@end lisp
|
||||
|
||||
@code{any-invalid-params?} returns @code{#t} if any value failed
|
||||
validation. @code{field-errors} returns a list of error message
|
||||
strings for a given field, suitable for rendering next to form inputs.
|
||||
|
||||
|
||||
@node Sessions
|
||||
@section Sessions
|
||||
|
||||
Sessions use HMAC-signed cookies via @code{(webutils sessions)}.
|
||||
Set up a session config and apply the wrapper:
|
||||
|
||||
@lisp
|
||||
(define session-mgr
|
||||
(make-session-config "my-secret-key"
|
||||
#:cookie-name "my-session"))
|
||||
|
||||
(define routes
|
||||
(wrap-routes my-routes
|
||||
(make-session-handler-wrapper session-mgr)))
|
||||
@end lisp
|
||||
|
||||
Inside a handler, @code{(current-session)} returns the session data
|
||||
(an alist) or @code{#f} if no valid session exists.
|
||||
|
||||
To set session data, include a @code{session-set} header in the
|
||||
response. To delete, use @code{session-delete}:
|
||||
|
||||
@lisp
|
||||
;; Set session
|
||||
(redirect-response "/"
|
||||
#:headers (list (session-set session-mgr
|
||||
'((user-id . 42)))))
|
||||
|
||||
;; Read session
|
||||
(let ((user-id (and (current-session)
|
||||
(assoc-ref (current-session) 'user-id))))
|
||||
...)
|
||||
|
||||
;; Delete session
|
||||
(redirect-response "/"
|
||||
#:headers (list (session-delete session-mgr)))
|
||||
@end lisp
|
||||
|
||||
|
||||
@node Templating
|
||||
@section Templating
|
||||
|
||||
@code{write-shtml-as-html/streaming} works like htmlprag's
|
||||
@code{write-shtml-as-html}, but any procedure in the SHTML tree is
|
||||
called as @code{(proc port)} and can write dynamic content directly.
|
||||
|
||||
@code{streaming-html-response} wraps this into a response: give it an
|
||||
SHTML tree (with optional procedure slots) and it returns
|
||||
@code{(values response body)} ready for a handler.
|
||||
|
||||
@lisp
|
||||
(define (base-layout title content-proc)
|
||||
`(*TOP*
|
||||
(*DECL* DOCTYPE html)
|
||||
(html
|
||||
(head (title ,title))
|
||||
(body
|
||||
(nav (a (@@ (href "/")) "Home"))
|
||||
(main ,content-proc)
|
||||
(footer (p "Footer"))))))
|
||||
@end lisp
|
||||
|
||||
The layout is plain SHTML with a procedure in the @var{content-proc}
|
||||
position. Use @code{streaming-html-response} to send it:
|
||||
|
||||
@lisp
|
||||
(define (index-page request body-port)
|
||||
(streaming-html-response
|
||||
(base-layout "Home"
|
||||
(lambda (port)
|
||||
(write-shtml-as-html
|
||||
`(div (h1 "Welcome")
|
||||
(p "Content goes here."))
|
||||
port)))))
|
||||
@end lisp
|
||||
|
||||
You can also call @code{write-shtml-as-html/streaming} directly when
|
||||
you need to write SHTML with procedure slots to an arbitrary port.
|
||||
|
||||
|
||||
@node Static Files
|
||||
@section Static Files
|
||||
|
||||
@code{make-static-handler} returns a handler that serves files from a
|
||||
directory. Pair it with a wildcard route:
|
||||
|
||||
@lisp
|
||||
(route-group '("static")
|
||||
(route 'GET '(. path)
|
||||
(make-static-handler "./public"
|
||||
#:cache-control '((max-age . 3600)))))
|
||||
@end lisp
|
||||
|
||||
This serves @code{/static/css/style.css} from
|
||||
@code{./public/css/style.css}. The handler supports
|
||||
@code{If-Modified-Since} for 304 responses.
|
||||
102
doc/index.texi
Normal file
102
doc/index.texi
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
\input texinfo
|
||||
@setfilename safsaf.info
|
||||
|
||||
@dircategory The Algorithmic Language Scheme
|
||||
@direntry
|
||||
* Safsaf: (safsaf). A web framework for Guile Scheme.
|
||||
@end direntry
|
||||
|
||||
@html
|
||||
<div style="text-align: center;">
|
||||
<img src="logo.svg" alt="Safsaf" width="200" height="200">
|
||||
</div>
|
||||
@end html
|
||||
|
||||
@c HEADER
|
||||
@settitle Safsaf
|
||||
@documentlanguage en
|
||||
@documentencoding UTF-8
|
||||
@afourpaper
|
||||
@c END HEADER
|
||||
|
||||
@c MASTER MENU
|
||||
@node Top
|
||||
@top Overview
|
||||
|
||||
Safsaf is a web framework for Guile Scheme, built on
|
||||
@url{https://codeberg.org/guile/fibers, Guile Fibers} using the
|
||||
@url{https://cbaines.codeberg.page/guile-knots/, Guile Knots} web
|
||||
server.
|
||||
|
||||
@c END MASTER MENU
|
||||
|
||||
|
||||
@c TABLE OF CONTENTS
|
||||
@contents
|
||||
@c END TABLE OF CONTENTS
|
||||
|
||||
|
||||
@c CHAPTER: GUIDANCE
|
||||
@include guidance.texi
|
||||
@c END CHAPTER: GUIDANCE
|
||||
|
||||
|
||||
@c CHAPTER: API
|
||||
@include api/index.texi
|
||||
@c END CHAPTER: API
|
||||
|
||||
|
||||
|
||||
@c APPENDICES
|
||||
@node Version History
|
||||
@appendix Version History
|
||||
|
||||
@table @dfn
|
||||
|
||||
@include version-history.texi
|
||||
|
||||
@end table
|
||||
|
||||
|
||||
|
||||
@node Copying Information
|
||||
@appendix Copying Information
|
||||
|
||||
Copyright @copyright{} 2026 Christopher Baines <mail@@cbaines.net>
|
||||
|
||||
This library is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU Lesser General Public License as
|
||||
published by the Free Software Foundation; either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
@c END APPENDICES
|
||||
|
||||
|
||||
|
||||
@c INDICES
|
||||
@node Concept Index
|
||||
@unnumbered Concept Index
|
||||
|
||||
@printindex cp
|
||||
|
||||
|
||||
@node Data Type Index
|
||||
@unnumbered Data Type Index
|
||||
|
||||
@printindex tp
|
||||
|
||||
|
||||
@node Procedure Index
|
||||
@unnumbered Procedure Index
|
||||
|
||||
@printindex fn
|
||||
|
||||
|
||||
@node Variable Index
|
||||
@unnumbered Variable Index
|
||||
|
||||
@printindex vr
|
||||
@c END INDICES
|
||||
|
||||
|
||||
@bye
|
||||
42
doc/logo.svg
Normal file
42
doc/logo.svg
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200" width="200" height="200">
|
||||
<!-- Safsaf logo -->
|
||||
|
||||
<!-- Trunk outline + fill -->
|
||||
<path d="M100 190 Q97 145 96 110 Q94 80 97 58"
|
||||
stroke="#444444" stroke-width="14" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M100 190 Q97 145 96 110 Q94 80 97 58"
|
||||
stroke="#B89B78" stroke-width="9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Branch outlines -->
|
||||
<path d="M97 58 Q82 35 65 20" stroke="#444444" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q100 30 100 12" stroke="#444444" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q115 33 132 18" stroke="#444444" stroke-width="10" fill="none" stroke-linecap="round"/>
|
||||
<!-- Branch fills -->
|
||||
<path d="M97 58 Q82 35 65 20" stroke="#B89B78" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q100 30 100 12" stroke="#B89B78" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M97 58 Q115 33 132 18" stroke="#B89B78" stroke-width="6" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Frond outlines - left -->
|
||||
<path d="M65 20 Q42 40 30 78 Q24 105 22 140" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q52 35 44 68 Q40 90 38 120" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q35 30 20 62 Q12 85 14 115" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond outlines - center -->
|
||||
<path d="M100 12 Q91 38 84 75 Q80 105 78 148" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<path d="M100 12 Q109 38 116 75 Q120 105 122 148" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond outlines - right -->
|
||||
<path d="M132 18 Q155 38 168 78 Q174 105 176 140" stroke="#444444" stroke-width="8" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q145 33 154 68 Q158 90 160 120" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q162 28 178 60 Q186 83 184 115" stroke="#444444" stroke-width="7" fill="none" stroke-linecap="round"/>
|
||||
|
||||
<!-- Frond fills - left -->
|
||||
<path d="M65 20 Q42 40 30 78 Q24 105 22 140" stroke="#66DD66" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q52 35 44 68 Q40 90 38 120" stroke="#88EE88" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M65 20 Q35 30 20 62 Q12 85 14 115" stroke="#99FF99" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond fills - center -->
|
||||
<path d="M100 12 Q91 38 84 75 Q80 105 78 148" stroke="#66DD66" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M100 12 Q109 38 116 75 Q120 105 122 148" stroke="#88EE88" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<!-- Frond fills - right -->
|
||||
<path d="M132 18 Q155 38 168 78 Q174 105 176 140" stroke="#66DD66" stroke-width="5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q145 33 154 68 Q158 90 160 120" stroke="#88EE88" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
<path d="M132 18 Q162 28 178 60 Q186 83 184 115" stroke="#99FF99" stroke-width="4.5" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
Loading…
Add table
Add a link
Reference in a new issue