@minamocms/framework 0.0.0-stage → 0.10.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/CHANGELOG.md +234 -0
- package/LICENSE +105 -0
- package/README.md +133 -2
- package/dist/boot.d.ts +19 -0
- package/dist/boot.js +22 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +44 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +12 -0
- package/dist/create-site/generate-site.js +168 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/AGENTS.md +473 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +23 -0
- package/dist/create-site/template/content/pages/about/careers.json +22 -0
- package/dist/create-site/template/content/pages/about/team.json +22 -0
- package/dist/create-site/template/content/pages/about.json +22 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
- package/dist/create-site/template/content/pages/docs.json +22 -0
- package/dist/create-site/template/content/pages/index.json +182 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1223 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/config/site_settings.json +11 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +48 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +2 -0
- package/dist/media/filename.js +54 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/media/seed-media-cli.d.ts +2 -0
- package/dist/media/seed-media-cli.js +22 -0
- package/dist/media/seed-media.d.ts +11 -0
- package/dist/media/seed-media.js +76 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +83 -0
- package/dist/pull-site/cli.d.ts +2 -0
- package/dist/pull-site/cli.js +78 -0
- package/dist/pull-site/prompt-token.d.ts +16 -0
- package/dist/pull-site/prompt-token.js +69 -0
- package/dist/pull-site/prompts.d.ts +22 -0
- package/dist/pull-site/prompts.js +127 -0
- package/dist/pull-site/pull-site.d.ts +23 -0
- package/dist/pull-site/pull-site.js +211 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-cache.d.ts +11 -0
- package/dist/renderer/render-cache.js +11 -0
- package/dist/renderer/render-page.d.ts +34 -0
- package/dist/renderer/render-page.js +209 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/admin-redirect.d.ts +5 -0
- package/dist/routes/admin-redirect.js +26 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +6 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +169 -0
- package/dist/routes/deleted-pages.d.ts +8 -0
- package/dist/routes/deleted-pages.js +9 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +12 -0
- package/dist/routes/index.js +86 -0
- package/dist/routes/links.d.ts +8 -0
- package/dist/routes/links.js +18 -0
- package/dist/routes/media-public.d.ts +11 -0
- package/dist/routes/media-public.js +125 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +80 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +148 -0
- package/dist/routes/preview-revision.d.ts +10 -0
- package/dist/routes/preview-revision.js +96 -0
- package/dist/routes/preview.d.ts +10 -0
- package/dist/routes/preview.js +56 -0
- package/dist/routes/public.d.ts +10 -0
- package/dist/routes/public.js +123 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +139 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search-public.d.ts +6 -0
- package/dist/routes/search-public.js +104 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +15 -0
- package/dist/routes/settings.d.ts +10 -0
- package/dist/routes/settings.js +68 -0
- package/dist/routes/sitemap.d.ts +7 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +116 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +24 -0
- package/dist/schemas/page.schema.json +26 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-content.d.ts +32 -0
- package/dist/search/query-content.js +207 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +3 -0
- package/dist/search/rebuild-index.js +320 -0
- package/dist/server-config.d.ts +23 -0
- package/dist/server-config.js +237 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +235 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +145 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +151 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +166 -0
- package/dist/services/deleted-pages.d.ts +11 -0
- package/dist/services/deleted-pages.js +83 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +16 -0
- package/dist/services/drafts.js +164 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/links.d.ts +22 -0
- package/dist/services/links.js +169 -0
- package/dist/services/manage-menus.d.ts +15 -0
- package/dist/services/manage-menus.js +138 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +156 -0
- package/dist/services/menu-references.d.ts +3 -0
- package/dist/services/menu-references.js +38 -0
- package/dist/services/menus.d.ts +9 -0
- package/dist/services/menus.js +43 -0
- package/dist/services/migration-runner.d.ts +16 -0
- package/dist/services/migration-runner.js +169 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +43 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +236 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/pid-file.d.ts +13 -0
- package/dist/services/pid-file.js +54 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +15 -0
- package/dist/services/publish.js +313 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +22 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/reindex-on-write.d.ts +3 -0
- package/dist/services/reindex-on-write.js +30 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/site-settings.d.ts +28 -0
- package/dist/services/site-settings.js +116 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +6 -0
- package/dist/services/theme-component-file.js +39 -0
- package/dist/services/theme-files.d.ts +33 -0
- package/dist/services/theme-files.js +148 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +137 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +26 -0
- package/dist/services/validation.js +195 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- package/dist/site-check/cli.d.ts +2 -0
- package/dist/site-check/cli.js +48 -0
- package/dist/site-check/run-check.d.ts +12 -0
- package/dist/site-check/run-check.js +188 -0
- package/dist/site-sync/cli-helpers.d.ts +23 -0
- package/dist/site-sync/cli-helpers.js +75 -0
- package/dist/site-sync/deploy.d.ts +36 -0
- package/dist/site-sync/deploy.js +124 -0
- package/dist/site-sync/live-content.d.ts +30 -0
- package/dist/site-sync/live-content.js +68 -0
- package/dist/site-sync/prompts.d.ts +30 -0
- package/dist/site-sync/prompts.js +237 -0
- package/dist/site-sync/pull-cli.d.ts +2 -0
- package/dist/site-sync/pull-cli.js +75 -0
- package/dist/site-sync/pull-site.d.ts +28 -0
- package/dist/site-sync/pull-site.js +171 -0
- package/dist/site-sync/push-cli.d.ts +2 -0
- package/dist/site-sync/push-cli.js +268 -0
- package/dist/site-sync/push-site.d.ts +96 -0
- package/dist/site-sync/push-site.js +376 -0
- package/dist/site-sync/remote-site.d.ts +38 -0
- package/dist/site-sync/remote-site.js +122 -0
- package/dist/site-sync/sync-record.d.ts +26 -0
- package/dist/site-sync/sync-record.js +76 -0
- package/dist/site-sync/theme-sync.d.ts +34 -0
- package/dist/site-sync/theme-sync.js +128 -0
- package/dist/stop-site/cli.d.ts +2 -0
- package/dist/stop-site/cli.js +28 -0
- package/dist/stop-site/stop-site.d.ts +26 -0
- package/dist/stop-site/stop-site.js +56 -0
- package/dist/theme-state.d.ts +20 -0
- package/dist/theme-state.js +33 -0
- package/dist/upgrade/changelog.d.ts +8 -0
- package/dist/upgrade/changelog.js +46 -0
- package/dist/upgrade/finish.d.ts +9 -0
- package/dist/upgrade/finish.js +30 -0
- package/dist/upgrade/owned-files.d.ts +11 -0
- package/dist/upgrade/owned-files.js +42 -0
- package/dist/upgrade/package-names.d.ts +4 -0
- package/dist/upgrade/package-names.js +26 -0
- package/dist/upgrade/upgrade-cli.d.ts +2 -0
- package/dist/upgrade/upgrade-cli.js +170 -0
- package/package.json +64 -4
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<span class="logo">
|
|
2
|
+
<svg class="logo__mark" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
3
|
+
<path d="M16 2 L29 9 V23 L16 30 L3 23 V9 Z" fill="currentColor" fill-opacity="0.08" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/>
|
|
4
|
+
<path d="M16 2 L16 30 M3 9 L16 15 L29 9 M3 23 L16 15" stroke="var(--accent)" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/>
|
|
5
|
+
</svg>
|
|
6
|
+
<span>Minamo</span>
|
|
7
|
+
</span>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{%- comment -%}
|
|
2
|
+
responsive-media - the only way a content image or video should reach a page.
|
|
3
|
+
|
|
4
|
+
Renders a `format: "image"` or `format: "video"` setting, and marks the
|
|
5
|
+
result as a drag-and-drop target so an editor can drag a file from the
|
|
6
|
+
admin's media library straight onto it in the live preview.
|
|
7
|
+
|
|
8
|
+
{% render 'responsive-media',
|
|
9
|
+
media: section.settings.image, the whole setting object, not .url
|
|
10
|
+
kind: 'image', 'image' or 'video'
|
|
11
|
+
field: 'image', REQUIRED - the settings key this
|
|
12
|
+
renders, which is what a drop writes
|
|
13
|
+
back to
|
|
14
|
+
alt: 'What is actually in the picture',
|
|
15
|
+
ratio: '16 / 9', any CSS aspect-ratio value
|
|
16
|
+
loading: 'eager', 'eager' above the fold, else lazy
|
|
17
|
+
priority: true, adds fetchpriority=high
|
|
18
|
+
need: 'Wide shot of the still house', shown in the empty slot
|
|
19
|
+
class: 'extra-class' %}
|
|
20
|
+
|
|
21
|
+
Why `field` matters
|
|
22
|
+
-------------------
|
|
23
|
+
The admin finds a drop target by looking for data-cms-media, and uses its
|
|
24
|
+
value to know which setting to write the new URL into. Get it wrong and the
|
|
25
|
+
drop silently writes to the wrong field; omit it and the image simply is not
|
|
26
|
+
droppable. It must match the schema property name exactly.
|
|
27
|
+
|
|
28
|
+
An empty slot is NOT a drop target (decided directly). A theme has no way to
|
|
29
|
+
tell whether it is rendering for the admin preview or for the public site -
|
|
30
|
+
the renderer does not expose that - so an editor-only affordance would leak
|
|
31
|
+
onto the live site as a dashed box in front of real visitors. Guard the call
|
|
32
|
+
instead, exactly as hero.liquid and cta-banner.liquid do:
|
|
33
|
+
|
|
34
|
+
{% if section.settings.image.url %}{% render 'responsive-media', ... %}{% endif %}
|
|
35
|
+
|
|
36
|
+
so a section with nothing set renders nothing at all. Set the first image
|
|
37
|
+
through the Fields panel's own picker; drag-and-drop then replaces it.
|
|
38
|
+
|
|
39
|
+
No srcset, on purpose
|
|
40
|
+
---------------------
|
|
41
|
+
The CMS names uploaded files by a hash of their own bytes, so a resized
|
|
42
|
+
variant gets a completely different name - "photo-480w.webp" is not
|
|
43
|
+
derivable from "photo-3f9a2b7c1e04.jpg". Hand-built variant URLs therefore
|
|
44
|
+
point at files that do not exist, which renders a page that looks perfectly
|
|
45
|
+
fine except at one breakpoint. Do not add a srcset here. One image is one
|
|
46
|
+
file and one URL; let CSS do the responsive work.
|
|
47
|
+
{%- endcomment -%}
|
|
48
|
+
{%- assign media_kind = kind | default: 'image' -%}
|
|
49
|
+
{%- assign media_field = field | default: 'image' -%}
|
|
50
|
+
{%- assign media_url = media.url -%}
|
|
51
|
+
{%- capture ratio_style -%}--media-ratio: {{ ratio | default: '3 / 2' }};{%- endcapture -%}
|
|
52
|
+
|
|
53
|
+
{%- if media_url == nil or media_url == '' -%}
|
|
54
|
+
{%- comment -%}
|
|
55
|
+
Reached only if a caller renders this without guarding on a url - the
|
|
56
|
+
scaffold's own call sites guard, so this never appears on a real page.
|
|
57
|
+
Kept as a graceful fallback for a theme that genuinely wants a visible
|
|
58
|
+
placeholder, and deliberately carrying NO drag attributes: see above.
|
|
59
|
+
{%- endcomment -%}
|
|
60
|
+
<div class="media media--empty {{ class }}"
|
|
61
|
+
style="{{ ratio_style }}"
|
|
62
|
+
role="img"
|
|
63
|
+
aria-label="Placeholder. {{ need | default: alt }}">
|
|
64
|
+
<span class="media__hint">{{ need | default: alt | default: 'Drag a file here' }}</span>
|
|
65
|
+
</div>
|
|
66
|
+
{%- elsif media_kind == 'video' -%}
|
|
67
|
+
{%- comment -%}
|
|
68
|
+
No data-cms-image on a video: an older admin understands only that
|
|
69
|
+
attribute and would write an image-shaped value ({url, focalX, focalY})
|
|
70
|
+
over this field's own {url, poster}. Better it ignores the slot entirely
|
|
71
|
+
than corrupts it.
|
|
72
|
+
|
|
73
|
+
A background loop is muted and looping by definition, so it may autoplay.
|
|
74
|
+
The poster is what shows before the clip loads and wherever it will not
|
|
75
|
+
play at all, which is why it is worth setting rather than optional.
|
|
76
|
+
{%- endcomment -%}
|
|
77
|
+
<div class="media {{ class }}{% if media.poster and media.poster != '' %} media--has-poster{% endif %}"
|
|
78
|
+
style="{{ ratio_style }}{% if media.poster and media.poster != '' %} --media-poster: url('{{ media.poster }}');{% endif %}"
|
|
79
|
+
data-cms-media="{{ media_field }}"
|
|
80
|
+
data-cms-media-kind="video">
|
|
81
|
+
<video
|
|
82
|
+
src="{{ media_url }}"
|
|
83
|
+
{% if media.poster and media.poster != '' %}poster="{{ media.poster }}"{% endif %}
|
|
84
|
+
autoplay
|
|
85
|
+
muted
|
|
86
|
+
loop
|
|
87
|
+
playsinline
|
|
88
|
+
preload="metadata"
|
|
89
|
+
{% if alt and alt != '' %}aria-label="{{ alt }}"{% else %}aria-hidden="true"{% endif %}></video>
|
|
90
|
+
</div>
|
|
91
|
+
{%- else -%}
|
|
92
|
+
{%- assign fx = media.focalX | default: 0.5 | times: 100 | round: 1 -%}
|
|
93
|
+
{%- assign fy = media.focalY | default: 0.5 | times: 100 | round: 1 -%}
|
|
94
|
+
<div class="media {{ class }}"
|
|
95
|
+
style="{{ ratio_style }}"
|
|
96
|
+
data-cms-media="{{ media_field }}"
|
|
97
|
+
data-cms-media-kind="image"
|
|
98
|
+
data-cms-image="{{ media_field }}">
|
|
99
|
+
<img
|
|
100
|
+
src="{{ media_url }}"
|
|
101
|
+
alt="{{ alt }}"
|
|
102
|
+
loading="{{ loading | default: 'lazy' }}"
|
|
103
|
+
decoding="async"
|
|
104
|
+
{% if priority %}fetchpriority="high"{% endif %}
|
|
105
|
+
style="object-position: {{ fx }}% {{ fy }}%;">
|
|
106
|
+
</div>
|
|
107
|
+
{%- endif -%}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<span class="site-name">{{ name }}</span>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generic and site-content-agnostic - this file never needs to change
|
|
2
|
+
# per site. Runs identically via plain `docker build`/`docker run` on a
|
|
3
|
+
# bare VPS, Fly, ECS, or any PaaS - nothing platform-specific is baked
|
|
4
|
+
# in here (see docs/guide-hosting.md for platform-specific deploy config,
|
|
5
|
+
# which always layers on top of this, never inside it).
|
|
6
|
+
#
|
|
7
|
+
# Lives in vhost/, not the site root, alongside the rest of the site's
|
|
8
|
+
# own serving config (package.json, server.js, site.config.json) - the
|
|
9
|
+
# build context is still the site root, so build with:
|
|
10
|
+
# docker build -f vhost/Dockerfile -t my-site .
|
|
11
|
+
FROM node:22-alpine
|
|
12
|
+
|
|
13
|
+
# git is a hard runtime requirement (services/startup-checks.ts fails
|
|
14
|
+
# fast without it) - content, drafts and publishes are all real git
|
|
15
|
+
# operations, not a database.
|
|
16
|
+
RUN apk add --no-cache git
|
|
17
|
+
|
|
18
|
+
# Build-time install happens against the image-baked copy of the site
|
|
19
|
+
# ("/seed"), never directly against "/site" - "/site" is where a
|
|
20
|
+
# persistent volume gets mounted at runtime, and anything written there
|
|
21
|
+
# during the build would simply be shadowed by that mount.
|
|
22
|
+
WORKDIR /seed
|
|
23
|
+
COPY . .
|
|
24
|
+
RUN cd vhost && npm install --omit=dev
|
|
25
|
+
|
|
26
|
+
WORKDIR /site
|
|
27
|
+
COPY vhost/docker-entrypoint.sh /docker-entrypoint.sh
|
|
28
|
+
RUN chmod +x /docker-entrypoint.sh
|
|
29
|
+
|
|
30
|
+
# Must match vhost/site.config.json's own "port" field.
|
|
31
|
+
EXPOSE 3000
|
|
32
|
+
|
|
33
|
+
ENTRYPOINT ["/docker-entrypoint.sh"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
# /seed is the image-baked copy of the site; /site is where the
|
|
5
|
+
# persistent volume is mounted. Overridable only so the test suite can
|
|
6
|
+
# run this exact script against temporary directories.
|
|
7
|
+
SEED="${CMS_SEED_DIR:-/seed}"
|
|
8
|
+
SITE="${CMS_SITE_DIR:-/site}"
|
|
9
|
+
|
|
10
|
+
# First boot against an empty mounted volume: seed it from the
|
|
11
|
+
# image-baked copy at /seed (content, theme, the already-`npm
|
|
12
|
+
# install`ed vhost/node_modules). A later boot finds /site/vhost
|
|
13
|
+
# already populated and skips this, so the same image works
|
|
14
|
+
# identically with a real persistent volume mounted at /site
|
|
15
|
+
# (production) or with nothing mounted at all (a quick local trial,
|
|
16
|
+
# where content just lives in the ephemeral container layer).
|
|
17
|
+
if [ ! -d "$SITE/vhost" ]; then
|
|
18
|
+
cp -r "$SEED/." "$SITE/"
|
|
19
|
+
else
|
|
20
|
+
# Every later boot: refresh the CMS itself from the image, and
|
|
21
|
+
# nothing else. Content, drafts, media and theme on the volume are
|
|
22
|
+
# the live site's own and are never touched - but the installed CMS
|
|
23
|
+
# is code, not content, so without this a redeploy built from a newer
|
|
24
|
+
# version would keep running the old one from the volume forever.
|
|
25
|
+
# Only the dependency manifest, its installed packages and server.js
|
|
26
|
+
# (the CMS's own, which imports it by name: the package was renamed
|
|
27
|
+
# from @o-a/cms-agent to @minamocms/framework) are copied; site.config.json
|
|
28
|
+
# (tokens) stays as it is on the volume.
|
|
29
|
+
#
|
|
30
|
+
# This leaves vhost/package.json (and package-lock.json) showing as
|
|
31
|
+
# modified in the volume's git working tree after an upgrade. That is
|
|
32
|
+
# deliberate: nothing here commits, since publishing is the only
|
|
33
|
+
# routine operation that creates a commit.
|
|
34
|
+
cp "$SEED/vhost/package.json" "$SITE/vhost/package.json"
|
|
35
|
+
if [ -f "$SEED/vhost/package-lock.json" ]; then
|
|
36
|
+
cp "$SEED/vhost/package-lock.json" "$SITE/vhost/package-lock.json"
|
|
37
|
+
fi
|
|
38
|
+
rm -rf "$SITE/vhost/node_modules"
|
|
39
|
+
cp -r "$SEED/vhost/node_modules" "$SITE/vhost/node_modules"
|
|
40
|
+
cp "$SEED/vhost/server.js" "$SITE/vhost/server.js"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# The site root must be a real git repository (services/startup-checks.ts) -
|
|
44
|
+
# a plain `docker build` from a full local checkout preserves .git, but
|
|
45
|
+
# some platforms (Railway's `railway up` included) build from a
|
|
46
|
+
# git-archive-style upload of tracked file contents only, which never
|
|
47
|
+
# includes .git at all. Recovered here rather than assumed away: if
|
|
48
|
+
# it's missing, start a fresh repo over the already-seeded content.
|
|
49
|
+
if [ ! -d "$SITE/.git" ]; then
|
|
50
|
+
git -C "$SITE" init --quiet
|
|
51
|
+
git -C "$SITE" add -A
|
|
52
|
+
GIT_AUTHOR_NAME="Minamo" GIT_AUTHOR_EMAIL="minamo@localhost" \
|
|
53
|
+
GIT_COMMITTER_NAME="Minamo" GIT_COMMITTER_EMAIL="minamo@localhost" \
|
|
54
|
+
git -C "$SITE" commit --quiet -m "chore: initial scaffold (recovered - .git was not part of the deploy upload)"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
cd "$SITE/vhost"
|
|
58
|
+
exec node server.js
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface MediaListEntry {
|
|
2
|
+
name: string;
|
|
3
|
+
size: number;
|
|
4
|
+
mtimeMs: number;
|
|
5
|
+
}
|
|
6
|
+
export interface MediaStorageDriver {
|
|
7
|
+
list(): Promise<MediaListEntry[]>;
|
|
8
|
+
put(filename: string, bytes: Buffer): Promise<void>;
|
|
9
|
+
get(filename: string): Promise<Buffer | null>;
|
|
10
|
+
delete(filename: string): Promise<boolean>;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { PathSafetyError, sanitisePath } from "../../services/path-safety.js";
|
|
4
|
+
// A plain string constant, matching node-sqlite-driver.ts's own
|
|
5
|
+
// DRIVER_NAME convention - safe for the capabilities endpoint to
|
|
6
|
+
// report without needing to import anything fs-touching.
|
|
7
|
+
export const DRIVER_NAME = 'local-fs';
|
|
8
|
+
// The default (and, for this build increment, only) MediaStorageDriver
|
|
9
|
+
// implementation. No driver-containment static-analysis test exists
|
|
10
|
+
// for this yet, unlike search/drivers' own G5 check for node:sqlite -
|
|
11
|
+
// node:fs is a single already-pervasive Node builtin, not a swappable
|
|
12
|
+
// third-party library the way node:sqlite/better-sqlite3 are, so
|
|
13
|
+
// there's no "which alternative" question to fence off yet. The
|
|
14
|
+
// natural trigger for that kind of test is a future object-storage
|
|
15
|
+
// driver's own third-party SDK import.
|
|
16
|
+
//
|
|
17
|
+
// Every method resolves filename through sanitisePath itself before
|
|
18
|
+
// touching disk - this file importing both node:fs and sanitisePath
|
|
19
|
+
// is what satisfies the B7 static-analysis rule with no allowlist
|
|
20
|
+
// entry needed, matching assets.ts/content.ts's own pattern of the
|
|
21
|
+
// fs-touching file being the one that calls it.
|
|
22
|
+
export function openLocalFsMediaDriver(root) {
|
|
23
|
+
return {
|
|
24
|
+
async list() {
|
|
25
|
+
mkdirSync(root, { recursive: true });
|
|
26
|
+
return readdirSync(root, { withFileTypes: true })
|
|
27
|
+
.filter((entry) => entry.isFile())
|
|
28
|
+
.map((entry) => {
|
|
29
|
+
const stats = statSync(sanitisePath(root, entry.name));
|
|
30
|
+
return { name: entry.name, size: stats.size, mtimeMs: stats.mtimeMs };
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
async put(filename, bytes) {
|
|
34
|
+
const resolved = sanitisePath(root, filename);
|
|
35
|
+
// Harmless no-op for a flat, content-addressed namespace (media
|
|
36
|
+
// filenames never contain subdirectories) - matches
|
|
37
|
+
// manage-menus.ts's own defensive precedent of creating the
|
|
38
|
+
// parent directory before a write rather than assuming it
|
|
39
|
+
// already exists.
|
|
40
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
41
|
+
// No existence check first: content-addressed naming means an
|
|
42
|
+
// identical re-upload writes identical bytes over identical
|
|
43
|
+
// bytes - a harmless idempotent overwrite, not something that
|
|
44
|
+
// needs special-casing.
|
|
45
|
+
writeFileSync(resolved, bytes);
|
|
46
|
+
},
|
|
47
|
+
async get(filename) {
|
|
48
|
+
let resolved;
|
|
49
|
+
try {
|
|
50
|
+
resolved = sanitisePath(root, filename);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// An invalid/traversal-shaped filename reads as "not found"
|
|
54
|
+
// from this method's own contract - the route layer is what
|
|
55
|
+
// decides whether that's a 404 or something else, the driver
|
|
56
|
+
// itself stays mechanical.
|
|
57
|
+
if (error instanceof PathSafetyError) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (!existsSync(resolved) || !statSync(resolved).isFile()) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return readFileSync(resolved);
|
|
66
|
+
},
|
|
67
|
+
async delete(filename) {
|
|
68
|
+
// Unlike get(), a malformed delete path is let through
|
|
69
|
+
// uncaught - worth surfacing distinctly rather than silently
|
|
70
|
+
// reading as "already didn't exist", matching
|
|
71
|
+
// manage-redirects.ts's own not-found-is-a-distinct-reason
|
|
72
|
+
// precedent.
|
|
73
|
+
const resolved = sanitisePath(root, filename);
|
|
74
|
+
if (!existsSync(resolved)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
unlinkSync(resolved);
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename, extname } from 'node:path';
|
|
3
|
+
// Content-addressed: the filename is derived from a hash of the
|
|
4
|
+
// file's own bytes, so two uploads can never collide, and an
|
|
5
|
+
// identical re-upload naturally produces the identical filename (a
|
|
6
|
+
// harmless overwrite in local-fs-driver.ts's put(), not a duplicate).
|
|
7
|
+
// No separate manifest/index recording original filenames - this
|
|
8
|
+
// codebase avoids a second source of truth on principle (the same
|
|
9
|
+
// reasoning that keeps the SQLite search index disposable rather than
|
|
10
|
+
// authoritative), so the original name is folded into the filename
|
|
11
|
+
// itself instead of tracked anywhere else.
|
|
12
|
+
//
|
|
13
|
+
// Truncated to 12 hex chars (48 bits), not the full 64-char digest -
|
|
14
|
+
// git itself only shows short hashes for the same reason. At this
|
|
15
|
+
// length a collision between two different files stays astronomically
|
|
16
|
+
// unlikely (well past a 1-in-a-trillion chance even at tens of
|
|
17
|
+
// thousands of uploads to one site), while keeping filenames/URLs/git
|
|
18
|
+
// history actually readable.
|
|
19
|
+
//
|
|
20
|
+
// Slug regex matches generate-site.ts's own sanitisePackageName -
|
|
21
|
+
// lowercase, non [a-z0-9-] runs collapsed to a single "-", trimmed -
|
|
22
|
+
// not a second, independently-invented convention.
|
|
23
|
+
const HASH_LENGTH = 12;
|
|
24
|
+
// Images and short videos - not a general document library. Video was
|
|
25
|
+
// added deliberately (confirmed with the user) for silent looping
|
|
26
|
+
// clips used where a hero image would otherwise go; long-form video
|
|
27
|
+
// belongs on YouTube/Vimeo and needs nothing from here. The upload cap
|
|
28
|
+
// (site.config.json's media.maxUploadBytes, 10MB by default) is what
|
|
29
|
+
// keeps that distinction honest - it comfortably fits a well-compressed
|
|
30
|
+
// 10-15s loop and comfortably rejects a real film.
|
|
31
|
+
//
|
|
32
|
+
// .svg is rejected regardless of this list even though it's
|
|
33
|
+
// technically an image format - docs/cms-build-plan.md's own "SVG
|
|
34
|
+
// rejected outright, not sanitised" decision (a real stored-XSS path
|
|
35
|
+
// otherwise: mime-types.ts maps .svg to a real image/svg+xml content
|
|
36
|
+
// type, and an SVG loaded as a top-level navigation, not just <img>-
|
|
37
|
+
// embedded, can execute a script it carries).
|
|
38
|
+
//
|
|
39
|
+
// Exported from here, not left as routes/media.ts's own private
|
|
40
|
+
// constant, specifically so seed-media.ts (an offline tool with no
|
|
41
|
+
// HTTP request to validate) enforces the exact same rule rather than
|
|
42
|
+
// a second, independently-maintained copy of it.
|
|
43
|
+
export const ALLOWED_UPLOAD_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm']);
|
|
44
|
+
export function buildMediaFilename(originalFilename, bytes) {
|
|
45
|
+
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, HASH_LENGTH);
|
|
46
|
+
const base = basename(originalFilename, extname(originalFilename));
|
|
47
|
+
const cleaned = base.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
48
|
+
const slug = cleaned.length > 0 ? cleaned : 'file';
|
|
49
|
+
const extension = extname(originalFilename).toLowerCase();
|
|
50
|
+
// Slug first, hash last (requested directly) - the readable part of
|
|
51
|
+
// the filename sorts and scans first in a directory listing/URL,
|
|
52
|
+
// with the hash as a suffix rather than something to read past.
|
|
53
|
+
return `${slug}-${hash}${extension}`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
export type ManageMediaReason = 'not-found' | 'invalid-file-type';
|
|
3
|
+
export declare class ManageMediaError extends Error {
|
|
4
|
+
readonly reason: ManageMediaReason;
|
|
5
|
+
constructor(reason: ManageMediaReason, message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface MediaEntry {
|
|
8
|
+
name: string;
|
|
9
|
+
size: number;
|
|
10
|
+
mtimeMs: number;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function listMedia(config: SiteConfig): Promise<MediaEntry[]>;
|
|
14
|
+
export declare function putMedia(config: SiteConfig, originalFilename: string, bytes: Buffer): Promise<{
|
|
15
|
+
name: string;
|
|
16
|
+
size: number;
|
|
17
|
+
url: string;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function deleteMedia(config: SiteConfig, filename: string): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { enqueue } from "../services/write-queue.js";
|
|
2
|
+
import { openLocalFsMediaDriver } from "./drivers/local-fs-driver.js";
|
|
3
|
+
import { buildMediaFilename } from "./filename.js";
|
|
4
|
+
export class ManageMediaError extends Error {
|
|
5
|
+
reason;
|
|
6
|
+
constructor(reason, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'ManageMediaError';
|
|
9
|
+
this.reason = reason;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function mediaUrl(name) {
|
|
13
|
+
return `/media/${name}`;
|
|
14
|
+
}
|
|
15
|
+
// Opens a fresh driver per call rather than threading one long-lived
|
|
16
|
+
// instance through BootedSite - unlike DatabaseSync, plain fs calls
|
|
17
|
+
// have no connection/handle to reuse, so there's no driver-lifecycle
|
|
18
|
+
// wiring needed. The only call site that decides which driver to open
|
|
19
|
+
// is here, hardcoded, matching how rebuild-index.ts hardcodes
|
|
20
|
+
// openNodeSqliteDriver today with no config-driven selection either -
|
|
21
|
+
// this is where a future config-driven driver choice would go.
|
|
22
|
+
function driverFor(config) {
|
|
23
|
+
return openLocalFsMediaDriver(config.mediaRoot);
|
|
24
|
+
}
|
|
25
|
+
// Not enqueued - reads bypass the write queue, matching
|
|
26
|
+
// handleListRedirects' own precedent of only serialising mutations.
|
|
27
|
+
export async function listMedia(config) {
|
|
28
|
+
const entries = await driverFor(config).list();
|
|
29
|
+
return entries.map((entry) => ({ ...entry, url: mediaUrl(entry.name) }));
|
|
30
|
+
}
|
|
31
|
+
export function putMedia(config, originalFilename, bytes) {
|
|
32
|
+
return enqueue(async () => {
|
|
33
|
+
const name = buildMediaFilename(originalFilename, bytes);
|
|
34
|
+
await driverFor(config).put(name, bytes);
|
|
35
|
+
return { name, size: bytes.length, url: mediaUrl(name) };
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function deleteMedia(config, filename) {
|
|
39
|
+
return enqueue(async () => {
|
|
40
|
+
const existed = await driverFor(config).delete(filename);
|
|
41
|
+
if (!existed) {
|
|
42
|
+
throw new ManageMediaError('not-found', `No media file at "${filename}"`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { seedMedia } from "./seed-media.js";
|
|
4
|
+
const [targetArg, ...sourceArgs] = process.argv.slice(2);
|
|
5
|
+
if (!targetArg || sourceArgs.length === 0) {
|
|
6
|
+
console.error('Usage: seed-media <site-directory> <path> [<path> ...]');
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
const siteDir = resolve(process.cwd(), targetArg);
|
|
10
|
+
const sourcePaths = sourceArgs.map((sourceArg) => resolve(process.cwd(), sourceArg));
|
|
11
|
+
const result = seedMedia(siteDir, sourcePaths);
|
|
12
|
+
for (const entry of result.entries) {
|
|
13
|
+
if (entry.status === 'seeded') {
|
|
14
|
+
console.log(`${entry.sourcePath} -> ${entry.url}`);
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
console.log(`${entry.sourcePath}: skipped (${entry.reason})`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface SeedMediaEntry {
|
|
2
|
+
sourcePath: string;
|
|
3
|
+
status: 'seeded' | 'skipped-invalid-type';
|
|
4
|
+
url?: string;
|
|
5
|
+
reason?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SeedMediaResult {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
entries: SeedMediaEntry[];
|
|
10
|
+
}
|
|
11
|
+
export declare function seedMedia(siteRoot: string, sourcePaths: string[]): SeedMediaResult;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, extname, join } from 'node:path';
|
|
3
|
+
import { ALLOWED_UPLOAD_EXTENSIONS, buildMediaFilename } from "./filename.js";
|
|
4
|
+
// A directory argument expands to its own direct child files only -
|
|
5
|
+
// one level, not recursive, matching theme-schemas.ts's own flat-
|
|
6
|
+
// directory convention for exactly the same reason (a predictable,
|
|
7
|
+
// easy-to-reason-about walk, not a surprise deep scan).
|
|
8
|
+
function expandSourcePaths(sourcePaths) {
|
|
9
|
+
const files = [];
|
|
10
|
+
for (const sourcePath of sourcePaths) {
|
|
11
|
+
if (statSync(sourcePath).isDirectory()) {
|
|
12
|
+
for (const entry of readdirSync(sourcePath, { withFileTypes: true })) {
|
|
13
|
+
if (entry.isFile()) {
|
|
14
|
+
files.push(join(sourcePath, entry.name));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
files.push(sourcePath);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return files;
|
|
23
|
+
}
|
|
24
|
+
// Seeds one or more local image files directly into a site's media/,
|
|
25
|
+
// computing the exact same content-addressed filename a real
|
|
26
|
+
// POST /v1/media upload would - see this module's own filename.ts for
|
|
27
|
+
// why that's safe to do offline (a pure function of a filename and the
|
|
28
|
+
// file's own bytes, no server/database involved). For an AI agent (or
|
|
29
|
+
// any offline tool) generating a site's starter content before a
|
|
30
|
+
// server is even running - GET /media/* just serves whatever file
|
|
31
|
+
// exists at the requested path, so the result is indistinguishable
|
|
32
|
+
// from a real upload.
|
|
33
|
+
//
|
|
34
|
+
// siteRoot is an operator/agent-supplied directory, the same category
|
|
35
|
+
// as scaffoldSite/mintToken - not a web request's own :path, so
|
|
36
|
+
// sanitisePath's traversal concern doesn't apply here (see
|
|
37
|
+
// mint-token.ts's own comment on this exact point).
|
|
38
|
+
//
|
|
39
|
+
// Never a git commit - media is never git-tracked (constraint 2,
|
|
40
|
+
// matching manage-media.ts's own "no commit, no author... Media is
|
|
41
|
+
// never git-tracked" comment).
|
|
42
|
+
//
|
|
43
|
+
// A disallowed file type is skipped, not a reason to abort the whole
|
|
44
|
+
// batch - an agent seeding a dozen images should still get the eleven
|
|
45
|
+
// good ones, with the one bad one clearly reported. The allowlist
|
|
46
|
+
// itself is the one thing that must not be relaxed here: it's the
|
|
47
|
+
// same rule the real upload route enforces (ALLOWED_UPLOAD_EXTENSIONS,
|
|
48
|
+
// shared, not a second copy), because skipping it would reopen the
|
|
49
|
+
// real stored-XSS path SVG rejection exists to close, for whoever
|
|
50
|
+
// later visits the live site - not a concern about this tool's own
|
|
51
|
+
// caller, who already has full local trust.
|
|
52
|
+
export function seedMedia(siteRoot, sourcePaths) {
|
|
53
|
+
const mediaRoot = join(siteRoot, 'media');
|
|
54
|
+
mkdirSync(mediaRoot, { recursive: true });
|
|
55
|
+
const entries = [];
|
|
56
|
+
for (const sourcePath of expandSourcePaths(sourcePaths)) {
|
|
57
|
+
const extension = extname(sourcePath).toLowerCase();
|
|
58
|
+
if (!ALLOWED_UPLOAD_EXTENSIONS.has(extension)) {
|
|
59
|
+
entries.push({
|
|
60
|
+
sourcePath,
|
|
61
|
+
status: 'skipped-invalid-type',
|
|
62
|
+
reason: `"${extension}" is not an accepted image type`,
|
|
63
|
+
});
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const bytes = readFileSync(sourcePath);
|
|
67
|
+
const name = buildMediaFilename(basename(sourcePath), bytes);
|
|
68
|
+
// No existence check first: content-addressed naming means
|
|
69
|
+
// re-seeding the same bytes writes identical bytes over identical
|
|
70
|
+
// bytes - a harmless idempotent overwrite, matching
|
|
71
|
+
// local-fs-driver.ts's own put() precedent exactly.
|
|
72
|
+
writeFileSync(join(mediaRoot, name), bytes);
|
|
73
|
+
entries.push({ sourcePath, status: 'seeded', url: `/media/${name}` });
|
|
74
|
+
}
|
|
75
|
+
return { ok: entries.every((entry) => entry.status === 'seeded'), entries };
|
|
76
|
+
}
|