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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/dist/cli.js +433 -0
  4. 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
- # Temporary Holding Version
1
+ # pesanle-theme
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }