pesanle-theme 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/dist/cli.js +433 -0
- package/package.json +39 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pesanle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pesanle-theme
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Compile and push a storefront theme to a [Pesanle](https://pesanle.id) shop:
|
|
4
|
+
sign in, scaffold a project, push it as a draft, preview it, publish it live.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx pesanle-theme login # once per machine
|
|
8
|
+
npx pesanle-theme init ./my-theme --shop my-shop
|
|
9
|
+
cd my-theme
|
|
10
|
+
npx pesanle-theme push # compile → the shop's draft
|
|
11
|
+
npx pesanle-theme preview # a signed link that draws the draft
|
|
12
|
+
npx pesanle-theme publish # the draft → live
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The CLI talks to **Pesanle** and nothing else — no endpoint, project, database
|
|
16
|
+
or API key. `login` signs you in with your Pesanle email and password; the
|
|
17
|
+
dashboard mints a token for the machine and does all the compiling, storing
|
|
18
|
+
and publishing server-side. Themes are authored in an HTML-first,
|
|
19
|
+
Liquid-shaped language and **compiled** to a JavaScript render function:
|
|
20
|
+
there is no interpreter at request time, and an unknown filter, a typo'd drop
|
|
21
|
+
property or a missing snippet fails the push, not a shopper's page.
|
|
22
|
+
|
|
23
|
+
The same theme can be browsed and edited in the dashboard — **Online store →
|
|
24
|
+
Themes → Code theme** — with a file tree, an editor and a live preview of the
|
|
25
|
+
draft. The CLI and the dashboard write the same draft.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
| Command | What it does |
|
|
30
|
+
| --------------------------------- | ---------------------------------------------------------------------------------- |
|
|
31
|
+
| `login` | Sign in to Pesanle and store a token for this machine. |
|
|
32
|
+
| `logout` | Revoke the token and forget it locally. |
|
|
33
|
+
| `whoami` | Show the signed-in account. |
|
|
34
|
+
| `init [dir]` | Scaffold a working theme project and write `.pesanle.json` with `--shop`. |
|
|
35
|
+
| `push [dir]` | Send the folder to Pesanle, which compiles it and writes the shop's **draft**. |
|
|
36
|
+
| `publish [dir]` | Copy the draft live, with one version snapshot per publish. `--push` pushes first. |
|
|
37
|
+
| `preview [dir]` | Print a signed link that draws the **draft** for an hour. |
|
|
38
|
+
| `pull [dir]` | Write the shop's published theme back into the folder (`--draft` for the draft). |
|
|
39
|
+
| `status [dir]` | The shop, its engine, and the draft and published versions. |
|
|
40
|
+
| `engine <template\|page-builder>` | Flip which engine draws the shop. |
|
|
41
|
+
|
|
42
|
+
Flags: `--shop`, `--name`, `--api`, `--email`, `--force`, `--draft`, `--push`,
|
|
43
|
+
`--no-engine`.
|
|
44
|
+
|
|
45
|
+
## Configuration
|
|
46
|
+
|
|
47
|
+
- **`.pesanle.json`** — written by `init --shop`, safe to commit:
|
|
48
|
+
`{ "shop": "my-shop", "name": "My theme" }`.
|
|
49
|
+
- **`~/.pesanle/credentials.json`** — the token `login` minted (mode `0600`);
|
|
50
|
+
`PESANLE_HOME` moves it. Never the password.
|
|
51
|
+
- **`PESANLE_EMAIL` / `PESANLE_PASSWORD`** — for a non-interactive login (CI).
|
|
52
|
+
- **`PESANLE_API_URL`** or **`--api`** — a self-hosted Pesanle dashboard.
|
|
53
|
+
|
|
54
|
+
## What a theme looks like
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
my-theme/
|
|
58
|
+
layout/theme.tpl the shell around every page of the shop
|
|
59
|
+
templates/index.tpl home
|
|
60
|
+
templates/product.tpl a product page
|
|
61
|
+
templates/catalog.tpl the catalogue
|
|
62
|
+
templates/page.tpl a seller's custom page
|
|
63
|
+
templates/pages/about.tpl a page of the theme's own, at /about
|
|
64
|
+
snippets/product-card.tpl a partial
|
|
65
|
+
sections/featured.tpl an editor-visible section with a schema
|
|
66
|
+
assets/theme.css scoped under .theme
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A route the theme does not define is drawn by the platform's default theme,
|
|
70
|
+
so a project can start with one page and grow. Pages are addressed by file
|
|
71
|
+
name: `templates/pages/about.tpl` answers `/about`. A seller's page built in
|
|
72
|
+
the dashboard answers `/<slug>` (and `/pages/<slug>`) and is drawn by
|
|
73
|
+
`templates/page.tpl` with its sections in the `pageBody` slot. Interactive
|
|
74
|
+
pieces — the buy box, reviews, the cart drawer, the order-tracking form —
|
|
75
|
+
arrive as host slots; a theme never ships its own JavaScript.
|
|
76
|
+
|
|
77
|
+
## The language in one screen
|
|
78
|
+
|
|
79
|
+
Output and text:
|
|
80
|
+
|
|
81
|
+
```liquid
|
|
82
|
+
<h1>{{ product.title }}</h1>
|
|
83
|
+
<p>{{ product.price | money }}</p>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Tags: `{% if %} / {% elsif %} / {% else %}` (with `and`, `or`, `==`, `!=`,
|
|
87
|
+
`<`, `>`, `contains`), `{% unless %}`, `{% for x in list %}…{% else %}…{% endfor %}`,
|
|
88
|
+
`{% render 'snippet', key: value %}` (a partial, with its own scope),
|
|
89
|
+
`{% slot 'productPurchase' %}` (a host island), `{% schema %}` (a section's
|
|
90
|
+
settings, in JSON), `{% comment %}…{% endcomment %}`.
|
|
91
|
+
|
|
92
|
+
Filters: `default`, `upcase`, `downcase`, `capitalize`, `strip`,
|
|
93
|
+
`strip_newlines`, `handleize`, `url_encode`, `json`, `truncate`, `replace`,
|
|
94
|
+
`append`, `prepend`, `split`, `join`, `size`, `first`, `last`, `slice`,
|
|
95
|
+
`reverse`, `uniq`, `compact`, `map`, `where`, `sort`, `sum`, `date`, `plus`,
|
|
96
|
+
`minus`, `times`, `divided_by`, `modulo`, `abs`, `floor`, `ceil`, `round`,
|
|
97
|
+
`money`, `link_url`, `image_url`, `video_url`, `page_url`.
|
|
98
|
+
|
|
99
|
+
Drops: `shop`, `page`, `settings`, `section`, `product`, `collection`,
|
|
100
|
+
`category`, `article`, `blog`, `articles`, `search`, `listing`, `collections`,
|
|
101
|
+
`categories`, `latest`, `products`, `vouchers`, `branches`, `cart`,
|
|
102
|
+
`customer`, and `routes` (`home`, `products`, `search`, `cart`, `account`,
|
|
103
|
+
`order_tracking`).
|
|
104
|
+
|
|
105
|
+
A wrong filter, an unknown drop property or a missing snippet is a **compile
|
|
106
|
+
error**: `push` reports the file and line and writes nothing. The language
|
|
107
|
+
deliberately has no `assign`, no `include` with arbitrary scope, no `{% raw %}`
|
|
108
|
+
and no way to emit `<script>`, `style="…"`, `on*` handlers or `javascript:`
|
|
109
|
+
URLs — a theme is markup, and interactivity is the host's.
|
|
110
|
+
|
|
111
|
+
## Requirements
|
|
112
|
+
|
|
113
|
+
Node 20.19 or newer. The CLI sends your theme files to your Pesanle dashboard
|
|
114
|
+
and nothing else.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @bun
|
|
3
|
+
import{existsSync as F0,mkdirSync as j,writeFileSync as T0}from"fs";import{basename as w,join as l,resolve as d}from"path";class G extends Error{status;diagnostics;name="CliApiError";constructor($,K,N=[]){super($);this.status=K;this.diagnostics=N}}class H{options;constructor($){this.options=$}async request($,K={}){let N={"content-type":"application/json"};if(this.options.token)N.authorization=`Bearer ${this.options.token}`;let W;try{W=await fetch(`${this.options.api.replace(/\/+$/,"")}${$}`,{method:K.method??"GET",headers:N,body:K.body===void 0?void 0:JSON.stringify(K.body)})}catch(Q){throw new G(`Could not reach ${this.options.api} — ${Q.message}`,0)}let b=await W.json().catch(()=>({}));if(!W.ok||b.ok!==!0)throw new G(b.error??`The API answered ${W.status}.`,W.status,b.diagnostics??[]);return b.data}async login($,K,N){return this.request("/api/cli/login",{method:"POST",body:{email:$,password:K,name:N}})}async logout(){await this.request("/api/cli/logout",{method:"POST"})}async me(){return this.request("/api/cli/me")}async theme($){return this.request(`/api/cli/shops/${encodeURIComponent($)}/theme`)}async push($,K,N){return this.request(`/api/cli/shops/${encodeURIComponent($)}/theme/draft`,{method:"PUT",body:{files:K,name:N}})}async publish($){return this.request(`/api/cli/shops/${encodeURIComponent($)}/theme/publish`,{method:"POST"})}async files($,K){return this.request(`/api/cli/shops/${encodeURIComponent($)}/theme/files${K?"?draft=1":""}`)}async preview($){return this.request(`/api/cli/shops/${encodeURIComponent($)}/theme/preview`)}async engine($,K){return this.request(`/api/cli/shops/${encodeURIComponent($)}/engine`,{method:"POST",body:{engine:K}})}}import{chmodSync as n,existsSync as S,mkdirSync as t,readFileSync as E,rmSync as s,writeFileSync as y}from"node:fs";import{homedir as r}from"node:os";import{dirname as a,join as q}from"node:path";var x=".pesanle.json",I="https://dashboard.pesanle.com";function D($){let K=q($,x);if(!S(K))return null;try{let N=JSON.parse(E(K,"utf8"));if(typeof N.shop!=="string"||!N.shop)return null;return{shop:N.shop,name:typeof N.name==="string"?N.name:void 0}}catch{return null}}function L($,K){y(q($,x),`${JSON.stringify({shop:K.shop,...K.name?{name:K.name}:{}},null,2)}
|
|
4
|
+
`)}function _(){let $=process.env.PESANLE_HOME?.trim()||q(r(),".pesanle");return q($,"credentials.json")}function M(){let $=_();if(!S($))return null;try{let K=JSON.parse(E($,"utf8"));if(typeof K.token!=="string"||!K.token)return null;return{api:typeof K.api==="string"&&K.api?K.api:I,token:K.token,email:typeof K.email==="string"?K.email:void 0,name:typeof K.name==="string"?K.name:void 0}}catch{return null}}function v($){let K=_();t(a(K),{recursive:!0,mode:448}),y(K,`${JSON.stringify($,null,2)}
|
|
5
|
+
`,{mode:384}),n(K,384)}function k(){s(_(),{force:!0})}function O($){return $?.trim()||process.env.PESANLE_API_URL?.trim()||M()?.api||I}import{readdirSync as e,readFileSync as $0}from"node:fs";import{join as K0,relative as N0,sep as W0}from"node:path";var b0=[".tpl",".css"];function m($){let K={},N=(W)=>{for(let b of e(W,{withFileTypes:!0})){if(b.name.startsWith("."))continue;let Q=K0(W,b.name);if(b.isDirectory())N(Q);else if(b0.some((B)=>b.name.endsWith(B)))K[N0($,Q).split(W0).join("/")]=$0(Q,"utf8")}};return N($),K}var T=($)=>`\x1B[1m${$}\x1B[0m`,u=($)=>`\x1B[2m${$}\x1B[0m`,z0=($)=>`\x1B[32m${$}\x1B[0m`,Q0=($)=>`\x1B[33m${$}\x1B[0m`,V0=($)=>`\x1B[31m${$}\x1B[0m`,F=0;function X($){F+=1,console.log(`
|
|
6
|
+
${u(`[${F}]`)} ${T($)}`)}function V($){console.log(` ${z0("OK")} ${$}`)}function z($){console.log(` ${u($)}`)}function R($){console.log(` ${Q0("WARN")} ${$}`)}function U($){console.error(` ${V0("FAIL")} ${$}`)}function f(){F=0}import{createInterface as Z0}from"node:readline";async function h($,K=""){if(!process.stdin.isTTY)return K;let N=Z0({input:process.stdin,output:process.stdout});try{return(await new Promise((b)=>N.question(`${$}: `,b))).trim()||K}finally{N.close()}}async function p($,K){let N=process.env[K]?.trim();if(N)return N;if(!process.stdin.isTTY)throw Error(`No terminal to prompt on: set ${K}.`);let W=process.stdin,b=W.isRaw;return process.stdout.write(`${$}: `),new Promise((Q,B)=>{let Z="",A=(i)=>{for(let Y of i.toString("utf8")){if(Y===`
|
|
7
|
+
`||Y==="\r"){if(C(),process.stdout.write(`
|
|
8
|
+
`),!Z)B(Error("Nothing was entered."));else Q(Z);return}if(Y==="\x03"){C(),process.stdout.write(`
|
|
9
|
+
`),B(Error("Cancelled."));return}if(Y===""||Y==="\b"){Z=Z.slice(0,-1);continue}Z+=Y}},C=()=>{if(W.removeListener("data",A),W.setRawMode)W.setRawMode(b??!1);W.pause()};if(W.resume(),W.setRawMode)W.setRawMode(!0);W.on("data",A)})}import{existsSync as B0,mkdirSync as X0,writeFileSync as Y0}from"node:fs";import{dirname as H0,join as J0}from"node:path";var G0=`<div class="theme">
|
|
10
|
+
<header class="theme-header">
|
|
11
|
+
<a class="theme-brand" href="{{ routes.home }}">{{ shop.name }}</a>
|
|
12
|
+
<nav class="theme-nav">
|
|
13
|
+
<a href="{{ routes.products }}">Shop</a>
|
|
14
|
+
{% for category in categories limit: 4 %}
|
|
15
|
+
<a href="{{ category.url }}">{{ category.name }}</a>
|
|
16
|
+
{% endfor %}
|
|
17
|
+
</nav>
|
|
18
|
+
<div class="theme-actions">
|
|
19
|
+
<a href="{{ routes.search }}">Search</a>
|
|
20
|
+
{% slot 'cartTrigger' %}
|
|
21
|
+
</div>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<main class="theme-main">{{ content_for_layout }}</main>
|
|
25
|
+
|
|
26
|
+
<footer class="theme-footer">
|
|
27
|
+
<p>{{ shop.name }}</p>
|
|
28
|
+
<p>{{ shop.address | default: shop.email }}</p>
|
|
29
|
+
</footer>
|
|
30
|
+
|
|
31
|
+
{% slot 'cartDrawer' %}
|
|
32
|
+
</div>
|
|
33
|
+
`,M0=`<section class="hero">
|
|
34
|
+
<div>
|
|
35
|
+
<h1>{{ shop.name }}</h1>
|
|
36
|
+
<p>{{ shop.description | default: 'Everything we make, in one place.' }}</p>
|
|
37
|
+
<a class="button" href="{{ routes.products }}">Shop all</a>
|
|
38
|
+
</div>
|
|
39
|
+
{% if shop.banner_url %}
|
|
40
|
+
<img class="hero-image" src="{{ shop.banner_url }}" alt="{{ shop.name }}" />
|
|
41
|
+
{% endif %}
|
|
42
|
+
</section>
|
|
43
|
+
|
|
44
|
+
<section class="band">
|
|
45
|
+
<h2>New arrivals</h2>
|
|
46
|
+
<div class="grid">
|
|
47
|
+
{% for product in latest limit: 8 %}
|
|
48
|
+
{% render 'product-card', product: product %}
|
|
49
|
+
{% endfor %}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
`,q0=`<article class="product">
|
|
53
|
+
<div class="gallery">
|
|
54
|
+
{% for image in product.images limit: 4 %}
|
|
55
|
+
{% if image.url %}<img src="{{ image.url }}" alt="{{ product.title }}" />{% endif %}
|
|
56
|
+
{% endfor %}
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="product-info">
|
|
60
|
+
<h1>{{ product.title }}</h1>
|
|
61
|
+
<p class="price">
|
|
62
|
+
{{ product.price | money }}
|
|
63
|
+
{% if product.on_sale %}<s>{{ product.compare_at_price | money }}</s>{% endif %}
|
|
64
|
+
</p>
|
|
65
|
+
{% if product.available != blank and product.available <= 0 %}
|
|
66
|
+
<p class="sold-out">Sold out</p>
|
|
67
|
+
{% endif %}
|
|
68
|
+
|
|
69
|
+
<div class="buy">{% slot 'productPurchase' %}</div>
|
|
70
|
+
|
|
71
|
+
{% if product.description %}<p class="description">{{ product.description }}</p>{% endif %}
|
|
72
|
+
</div>
|
|
73
|
+
</article>
|
|
74
|
+
|
|
75
|
+
{% if product.related.size > 0 %}
|
|
76
|
+
<section class="band">
|
|
77
|
+
<h2>You may also like</h2>
|
|
78
|
+
<div class="grid">
|
|
79
|
+
{% for related in product.related limit: 4 %}
|
|
80
|
+
{% render 'product-card', product: related %}
|
|
81
|
+
{% endfor %}
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
{% endif %}
|
|
85
|
+
`,I0=`<section class="listing-head">
|
|
86
|
+
<h1>{{ listing.title }}</h1>
|
|
87
|
+
{% if listing.description %}<p>{{ listing.description }}</p>{% endif %}
|
|
88
|
+
</section>
|
|
89
|
+
|
|
90
|
+
<p class="count">{{ listing.total }} products</p>
|
|
91
|
+
|
|
92
|
+
<div class="grid">
|
|
93
|
+
{% for product in listing.products limit: 12 %}
|
|
94
|
+
{% render 'product-card', product: product %}
|
|
95
|
+
{% endfor %}
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{% if listing.pages > 1 %}
|
|
99
|
+
<nav class="pager">
|
|
100
|
+
{% for pageNumber in (1..listing.pages) %}
|
|
101
|
+
{% if pageNumber == listing.page %}
|
|
102
|
+
<span class="current">{{ pageNumber }}</span>
|
|
103
|
+
{% else %}
|
|
104
|
+
<a href="{{ pageNumber | page_url }}">{{ pageNumber }}</a>
|
|
105
|
+
{% endif %}
|
|
106
|
+
{% endfor %}
|
|
107
|
+
</nav>
|
|
108
|
+
{% endif %}
|
|
109
|
+
`,O0=`<article class="page">
|
|
110
|
+
<p class="eyebrow">{{ shop.name }}</p>
|
|
111
|
+
<h1>{{ page.title }}</h1>
|
|
112
|
+
<p>
|
|
113
|
+
This page is the theme's own: <code>templates/pages/about.tpl</code> answers
|
|
114
|
+
<code>/about</code>. Rename the file to change the address, or delete it and
|
|
115
|
+
build the page in the dashboard instead.
|
|
116
|
+
</p>
|
|
117
|
+
</article>
|
|
118
|
+
`,R0=`<a class="card" href="{{ product.url }}">
|
|
119
|
+
<span class="card-media">
|
|
120
|
+
{% if product.image_url %}
|
|
121
|
+
<img src="{{ product.image_url }}" alt="{{ product.title }}" loading="lazy" />
|
|
122
|
+
{% endif %}
|
|
123
|
+
{% if product.on_sale %}<span class="badge">Sale</span>{% endif %}
|
|
124
|
+
</span>
|
|
125
|
+
<span class="card-name">{{ product.title }}</span>
|
|
126
|
+
<span class="card-price">
|
|
127
|
+
{{ product.price | money }}
|
|
128
|
+
{% if product.on_sale %}<s>{{ product.compare_at_price | money }}</s>{% endif %}
|
|
129
|
+
</span>
|
|
130
|
+
</a>
|
|
131
|
+
`,D0=`/* Scoped under .theme — the wrapper the storefront puts around the theme. */
|
|
132
|
+
.theme {
|
|
133
|
+
background: #faf9f6;
|
|
134
|
+
color: #171512;
|
|
135
|
+
font-family: var(--chakra-fonts-body);
|
|
136
|
+
min-height: 100dvh;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.theme a {
|
|
140
|
+
color: inherit;
|
|
141
|
+
text-decoration: none;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.theme img {
|
|
145
|
+
display: block;
|
|
146
|
+
max-width: 100%;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.theme-header {
|
|
150
|
+
align-items: center;
|
|
151
|
+
border-bottom: 1px solid #e6e2da;
|
|
152
|
+
display: flex;
|
|
153
|
+
gap: 2rem;
|
|
154
|
+
justify-content: space-between;
|
|
155
|
+
padding: 1rem clamp(1rem, 4vw, 2.5rem);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.theme-brand {
|
|
159
|
+
font-family: var(--chakra-fonts-heading);
|
|
160
|
+
font-size: 1.3rem;
|
|
161
|
+
font-weight: 700;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.theme-nav,
|
|
165
|
+
.theme-actions {
|
|
166
|
+
display: flex;
|
|
167
|
+
gap: 1.2rem;
|
|
168
|
+
font-size: 0.95rem;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.theme-main {
|
|
172
|
+
margin: 0 auto;
|
|
173
|
+
max-width: 1200px;
|
|
174
|
+
padding: 0 clamp(1rem, 4vw, 2.5rem);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.theme-footer {
|
|
178
|
+
border-top: 1px solid #e6e2da;
|
|
179
|
+
color: #6f6a60;
|
|
180
|
+
font-size: 0.9rem;
|
|
181
|
+
margin-top: 4rem;
|
|
182
|
+
padding: 2rem clamp(1rem, 4vw, 2.5rem);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.hero {
|
|
186
|
+
align-items: center;
|
|
187
|
+
display: grid;
|
|
188
|
+
gap: 2.5rem;
|
|
189
|
+
padding: clamp(2rem, 5vw, 4rem) 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@media (min-width: 900px) {
|
|
193
|
+
.hero {
|
|
194
|
+
grid-template-columns: 1fr 1fr;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.hero h1 {
|
|
199
|
+
font-family: var(--chakra-fonts-heading);
|
|
200
|
+
font-size: clamp(2.2rem, 5vw, 3.8rem);
|
|
201
|
+
letter-spacing: -0.02em;
|
|
202
|
+
margin: 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.hero p {
|
|
206
|
+
color: #6f6a60;
|
|
207
|
+
line-height: 1.7;
|
|
208
|
+
margin: 1rem 0 1.5rem;
|
|
209
|
+
max-width: 32rem;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.hero-image {
|
|
213
|
+
aspect-ratio: 4 / 3;
|
|
214
|
+
border-radius: 1rem;
|
|
215
|
+
object-fit: cover;
|
|
216
|
+
width: 100%;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.button {
|
|
220
|
+
background: #171512;
|
|
221
|
+
border-radius: 999px;
|
|
222
|
+
color: #faf9f6;
|
|
223
|
+
display: inline-block;
|
|
224
|
+
font-weight: 600;
|
|
225
|
+
padding: 0.75rem 1.5rem;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.band {
|
|
229
|
+
padding-top: clamp(2rem, 5vw, 3.5rem);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.band h2 {
|
|
233
|
+
font-family: var(--chakra-fonts-heading);
|
|
234
|
+
font-size: clamp(1.4rem, 3vw, 2rem);
|
|
235
|
+
margin: 0 0 1.25rem;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.grid {
|
|
239
|
+
display: grid;
|
|
240
|
+
gap: 1rem;
|
|
241
|
+
grid-template-columns: repeat(2, 1fr);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
@media (min-width: 800px) {
|
|
245
|
+
.grid {
|
|
246
|
+
grid-template-columns: repeat(4, 1fr);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.card {
|
|
251
|
+
display: flex;
|
|
252
|
+
flex-direction: column;
|
|
253
|
+
gap: 0.5rem;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.card-media {
|
|
257
|
+
aspect-ratio: 1;
|
|
258
|
+
background: #fff;
|
|
259
|
+
border: 1px solid #e6e2da;
|
|
260
|
+
border-radius: 0.9rem;
|
|
261
|
+
overflow: hidden;
|
|
262
|
+
position: relative;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.card-media img {
|
|
266
|
+
height: 100%;
|
|
267
|
+
object-fit: cover;
|
|
268
|
+
width: 100%;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.badge {
|
|
272
|
+
background: #d9f24e;
|
|
273
|
+
border-radius: 999px;
|
|
274
|
+
font-size: 0.7rem;
|
|
275
|
+
font-weight: 700;
|
|
276
|
+
left: 0.6rem;
|
|
277
|
+
padding: 0.2rem 0.55rem;
|
|
278
|
+
position: absolute;
|
|
279
|
+
text-transform: uppercase;
|
|
280
|
+
top: 0.6rem;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.card-name {
|
|
284
|
+
font-weight: 600;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.card-price,
|
|
288
|
+
.price {
|
|
289
|
+
align-items: baseline;
|
|
290
|
+
display: flex;
|
|
291
|
+
gap: 0.5rem;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.card-price s,
|
|
295
|
+
.price s {
|
|
296
|
+
color: #6f6a60;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.product {
|
|
300
|
+
display: grid;
|
|
301
|
+
gap: 2.5rem;
|
|
302
|
+
padding-top: 2rem;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
@media (min-width: 900px) {
|
|
306
|
+
.product {
|
|
307
|
+
grid-template-columns: 1.1fr 1fr;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.gallery {
|
|
312
|
+
display: grid;
|
|
313
|
+
gap: 0.75rem;
|
|
314
|
+
grid-template-columns: 1fr 1fr;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.gallery img {
|
|
318
|
+
aspect-ratio: 1;
|
|
319
|
+
border-radius: 0.9rem;
|
|
320
|
+
object-fit: cover;
|
|
321
|
+
width: 100%;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.product-info {
|
|
325
|
+
align-self: start;
|
|
326
|
+
display: flex;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
gap: 0.9rem;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.product-info h1 {
|
|
332
|
+
font-family: var(--chakra-fonts-heading);
|
|
333
|
+
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
|
334
|
+
margin: 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.description {
|
|
338
|
+
color: #6f6a60;
|
|
339
|
+
line-height: 1.75;
|
|
340
|
+
white-space: pre-line;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.sold-out {
|
|
344
|
+
color: #b42318;
|
|
345
|
+
font-weight: 600;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.listing-head {
|
|
349
|
+
padding-top: 2.5rem;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.listing-head h1 {
|
|
353
|
+
font-family: var(--chakra-fonts-heading);
|
|
354
|
+
font-size: clamp(1.9rem, 4vw, 3rem);
|
|
355
|
+
margin: 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.count {
|
|
359
|
+
color: #6f6a60;
|
|
360
|
+
font-size: 0.9rem;
|
|
361
|
+
margin: 1.5rem 0 1rem;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.pager {
|
|
365
|
+
display: flex;
|
|
366
|
+
gap: 0.5rem;
|
|
367
|
+
justify-content: center;
|
|
368
|
+
padding-top: 2rem;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.pager a,
|
|
372
|
+
.pager span {
|
|
373
|
+
border: 1px solid #e6e2da;
|
|
374
|
+
border-radius: 0.5rem;
|
|
375
|
+
padding: 0.4rem 0.75rem;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.pager .current {
|
|
379
|
+
background: #171512;
|
|
380
|
+
color: #faf9f6;
|
|
381
|
+
}
|
|
382
|
+
`,L0=`# Theme project
|
|
383
|
+
|
|
384
|
+
A storefront theme for Pesanle, authored in the compiled template language —
|
|
385
|
+
HTML-first and Liquid-shaped. Every page is a file:
|
|
386
|
+
|
|
387
|
+
layout/theme.tpl the shell around every page of the shop
|
|
388
|
+
templates/index.tpl home
|
|
389
|
+
templates/product.tpl a product page
|
|
390
|
+
templates/catalog.tpl the catalogue
|
|
391
|
+
templates/pages/about.tpl a page of the theme's own, at /about
|
|
392
|
+
snippets/product-card.tpl a partial
|
|
393
|
+
assets/theme.css styles, scoped under .theme
|
|
394
|
+
|
|
395
|
+
Host slots draw the interactive pieces the platform owns, so a theme ships no
|
|
396
|
+
JavaScript: {% slot 'productPurchase' %}, {% slot 'productReviews' %},
|
|
397
|
+
{% slot 'pageBody' %}, {% slot 'cartTrigger' %}, {% slot 'cartDrawer' %} and
|
|
398
|
+
{% slot 'orderTracking' %}. A wrong filter, an unknown drop property or a
|
|
399
|
+
missing snippet fails \`push\` with the file and line — never a shopper's page.
|
|
400
|
+
|
|
401
|
+
The same theme can be edited in the dashboard: Online store → Themes → Code
|
|
402
|
+
theme, with a file tree, an editor and a live preview of the draft.
|
|
403
|
+
|
|
404
|
+
\`\`\`bash
|
|
405
|
+
pesanle-theme push # compile and write the shop's draft
|
|
406
|
+
pesanle-theme publish # make the draft live
|
|
407
|
+
pesanle-theme preview # a signed link that draws the draft
|
|
408
|
+
pesanle-theme pull # write the live theme back into this folder
|
|
409
|
+
pesanle-theme status # the shop, its engine, and both versions
|
|
410
|
+
\`\`\`
|
|
411
|
+
|
|
412
|
+
The shop and theme name live in \`.pesanle.json\`; \`pesanle-theme login\`
|
|
413
|
+
stores the token for this machine in \`~/.pesanle/credentials.json\`.
|
|
414
|
+
`;function _0(){return{"layout/theme.tpl":G0,"templates/index.tpl":M0,"templates/product.tpl":q0,"templates/catalog.tpl":I0,"snippets/product-card.tpl":R0,"templates/pages/about.tpl":O0,"assets/theme.css":D0,"README.md":L0}}function c($){let K=[],N=[];for(let[W,b]of Object.entries(_0())){let Q=J0($.dir,W);if(B0(Q)&&!$.force){N.push(W);continue}X0(H0(Q),{recursive:!0}),Y0(Q,b),K.push(W)}return{written:K,skipped:N}}function U0($){let K={command:$[0]??"",dir:"",shop:"",name:"",api:"",email:"",engine:"",force:!1,draft:!1,push:!1,noEngine:!1},N=$.slice(1);for(let W=0;W<N.length;W+=1){let b=N[W],[Q,B]=b.startsWith("--")?b.split("=",2):["",""],Z=()=>B??N[++W]??"";switch(Q){case"--dir":K.dir=Z();break;case"--shop":K.shop=Z();break;case"--name":K.name=Z();break;case"--api":K.api=Z();break;case"--email":K.email=Z();break;case"--force":K.force=!0;break;case"--draft":K.draft=!0;break;case"--push":K.push=!0;break;case"--no-engine":K.noEngine=!0;break;case"":if(!K.dir)K.dir=b;else if(!K.engine&&P(b))K.engine=b;break;default:throw Error(`Unknown flag "${b}".`)}}if(K.command==="engine"&&!K.engine&&P(K.dir))K.engine=K.dir,K.dir="";return K}function P($){return $==="template"||$==="page-builder"}function j0($){let K=M();if(!K)throw Error('Not signed in. Run "pesanle-theme login" first.');return new H({api:O($)||K.api,token:K.token})}function J($){let K=d($.dir||"."),N=D(K),W=$.shop||N?.shop||"";if(!W)throw Error('No shop given: pass --shop <slug>, or run "pesanle-theme init --shop <slug>" to write .pesanle.json.');return{dir:K,shop:W,name:$.name||N?.name||w(K),api:j0($.api)}}function P0($){for(let K of $.diagnostics){let N=K.file?`${K.file}${K.loc?.start?.line?`:${K.loc.start.line}:${K.loc.start.column??1}`:""} `:"";console.error(` ${N}${K.code?`${K.code}: `:""}${K.message}`)}}async function w0($){let K=O($.api),N=$.email||process.env.PESANLE_EMAIL||await h("Email");if(!N)throw Error("An email is required.");let W=await p("Password (hidden)","PESANLE_PASSWORD"),b=$.name||`pesanle-theme on ${w(process.env.HOME??"this machine")}`;X("Sign in");let B=await new H({api:K}).login(N,W,b);v({api:K,token:B.token,email:B.user.email,name:b}),V(`Signed in as ${B.user.name} <${B.user.email}>`),z(`API: ${K}`),z("Next: pesanle-theme init ./my-theme --shop <shop>")}async function A0(){let $=M();if(X("Sign out"),!$){z("Not signed in.");return}try{await new H({api:$.api,token:$.token}).logout(),V("Token revoked")}catch(K){R(K.message)}k(),V("Signed out on this machine")}async function C0($){let K=M();if(!K)throw Error('Not signed in. Run "pesanle-theme login" first.');let W=await new H({api:O($.api)||K.api,token:K.token}).me();V(`${W.name} <${W.email}>`),z(`API: ${K.api}`)}async function S0($){let K=d($.dir||".");j(K,{recursive:!0});let N=$.name||w(K);X("Scaffold");let W=c({dir:K,name:N,force:$.force});V(`${W.written.length} files written${W.skipped.length?`, ${W.skipped.length} kept`:""}`);for(let b of W.skipped)z(`kept ${b}`);if($.shop)L(K,{shop:$.shop,name:N}),V(`.pesanle.json points at "${$.shop}"`);else R("No --shop given: add .pesanle.json (or pass --shop) before pushing.");console.log(`
|
|
415
|
+
${N} is ready.`),z(`cd ${$.dir||"."} && pesanle-theme push`)}async function o($){let K=J($);X("Compile & push");let N=m(K.dir);if(Object.keys(N).length===0)throw Error(`No theme files under ${K.dir}. A theme needs at least templates/index.tpl \u2014 run "pesanle-theme init" first.`);let W=await K.api.push(K.shop,N,K.name);if(V(`${Object.keys(N).length} files \u2192 version ${W.version}`),W.warnings.length>0){R(`${W.warnings.length} warning(s)`);for(let b of W.warnings)z(`${b.file??""}${b.file?": ":""}${b.message}`)}if(!$.noEngine)V("Shop is on the template engine");z("Make it live with: pesanle-theme publish")}async function E0($){if($.push)await o($);let K=J($);X("Publish");let N=await K.api.publish(K.shop);V(`Live \u2014 version ${N.version}`),z(N.url)}async function y0($){let K=J($);X("Preview");let N=await K.api.preview(K.shop);V(`Draft version ${N.version}`),console.log(`
|
|
416
|
+
${T("Open:")} ${N.url}
|
|
417
|
+
`),z(`The link (and its cookie) lasts ${Math.round(N.expiresInSeconds/60)} minutes.`)}async function x0($){let K=J($);X("Pull");let N=await K.api.files(K.shop,$.draft);j(K.dir,{recursive:!0});let W=0,b=0;for(let[Q,B]of Object.entries(N.files)){let Z=l(K.dir,Q);if(F0(Z)&&!$.force){b+=1;continue}j(l(Z,".."),{recursive:!0}),T0(Z,B),W+=1}if(V(`${W} files written, ${b} kept (--force overwrites)`),!D(K.dir))L(K.dir,{shop:K.shop,name:K.name}),V(".pesanle.json written");z(`Pulled version ${N.version}`)}async function v0($){let K=J($);X("Shop");let N=await K.api.theme(K.shop);if(V(`${N.shop.name} (${N.shop.slug})`),z(`Engine: ${N.engine}`),X("Theme"),!N.draft&&!N.published){z("No template theme has been pushed to this shop.");return}if(N.draft)V(`Draft: ${N.draft.version} \xB7 compiled ${N.draft.compiledAt} \xB7 ${N.draft.files} files`),z(`Routes: ${N.draft.routes.join(", ")}`);else z("Draft: none");z(N.published?`Published: ${N.published.version} \xB7 ${N.published.publishedAt??"unknown date"}`:"Published: none")}async function k0($){if(!P($.engine))throw Error("Usage: pesanle-theme engine <template|page-builder>");let K=J($),N=await K.api.engine(K.shop,$.engine);V(`${K.shop} now uses the ${N.engine} engine`)}var g=`pesanle-theme \u2014 compile and push a storefront theme to Pesanle
|
|
418
|
+
|
|
419
|
+
login [--api url] [--email you@example.com]
|
|
420
|
+
logout
|
|
421
|
+
whoami
|
|
422
|
+
init [dir] --shop <slug> [--name "My theme"] [--force]
|
|
423
|
+
push [dir] [--shop <slug>] [--name "My theme"] [--no-engine]
|
|
424
|
+
publish [dir] [--shop <slug>] [--push]
|
|
425
|
+
preview [dir] [--shop <slug>]
|
|
426
|
+
pull [dir] [--shop <slug>] [--draft] [--force]
|
|
427
|
+
status [dir] [--shop <slug>]
|
|
428
|
+
engine <template|page-builder> [--shop <slug>]
|
|
429
|
+
|
|
430
|
+
Sign in once per machine; the shop lives in the project's .pesanle.json.
|
|
431
|
+
Self-hosted Pesanle: pass --api (default ${I}) or set PESANLE_API_URL.`;async function m0(){let $=U0(process.argv.slice(2));switch(f(),$.command){case"login":return w0($);case"logout":return A0();case"whoami":return C0($);case"init":return S0($);case"push":return o($);case"publish":return E0($);case"preview":return y0($);case"pull":return x0($);case"status":return v0($);case"engine":return k0($);case"help":case"--help":case"-h":case"":console.log(g);return;default:throw Error(`Unknown command "${$.command}".
|
|
432
|
+
|
|
433
|
+
${g}`)}}m0().catch(($)=>{if($ instanceof G){if(U($.message),P0($),$.status===401)z('Run "pesanle-theme login" to sign in again.');if($.diagnostics.length>0)z("Nothing was pushed; the shop keeps serving its current theme.")}else U($.message);process.exitCode=1});
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pesanle-theme",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Compile and push a storefront theme to a Pesanle shop: scaffold, draft, publish, preview.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"pesanle-theme": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20.19.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pesanle",
|
|
20
|
+
"theme",
|
|
21
|
+
"storefront",
|
|
22
|
+
"ecommerce",
|
|
23
|
+
"cli",
|
|
24
|
+
"template"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://pesanle.id",
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "bun run scripts/build.ts",
|
|
32
|
+
"prepublishOnly": "bun run build",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "bun test"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@repo/typescript-config": "workspace:*",
|
|
38
|
+
"@types/bun": "latest",
|
|
39
|
+
"typescript": "^5.9.3"
|
|
40
|
+
}
|
|
41
|
+
}
|