@crombolo/server 0.0.0-stage → 0.10.0
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/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The components the backoffice offers to start from — Umbraco's snippets, with
|
|
3
|
+
* its ids, written as TSX. One is rendered the way any component is:
|
|
4
|
+
* `<Breadcrumb model={model} nav={nav} />`. `Empty` is also the skeleton a new
|
|
5
|
+
* component starts with.
|
|
6
|
+
*/
|
|
7
|
+
import type { Snippet } from '@crombolo/api-management'
|
|
8
|
+
|
|
9
|
+
const IMPORTS = "import type { PageProps } from '@crombolo/crombolo'\n\n"
|
|
10
|
+
|
|
11
|
+
export const COMPONENT_SNIPPETS: readonly Snippet[] = [
|
|
12
|
+
{
|
|
13
|
+
id: 'Empty',
|
|
14
|
+
name: 'Empty',
|
|
15
|
+
content: `${IMPORTS}export default function Partial({ model }: Pick<PageProps, 'model'>) {
|
|
16
|
+
return <div>{model.name}</div>
|
|
17
|
+
}
|
|
18
|
+
`,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'Breadcrumb',
|
|
22
|
+
name: 'Breadcrumb',
|
|
23
|
+
content: `${IMPORTS}export default function Breadcrumb({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
24
|
+
const trail = [...nav.ancestors(model)].reverse()
|
|
25
|
+
return (
|
|
26
|
+
<ul class="breadcrumb">
|
|
27
|
+
{trail.map((page) => (
|
|
28
|
+
<li>
|
|
29
|
+
<a href={page.url}>{page.name}</a>
|
|
30
|
+
</li>
|
|
31
|
+
))}
|
|
32
|
+
<li class="active">{model.name}</li>
|
|
33
|
+
</ul>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'ListAncestorsFromCurrentPage',
|
|
40
|
+
name: 'List Ancestors From Current Page',
|
|
41
|
+
content: `${IMPORTS}export default function Ancestors({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
42
|
+
return (
|
|
43
|
+
<ul>
|
|
44
|
+
{nav.ancestors(model).map((page) => (
|
|
45
|
+
<li>
|
|
46
|
+
<a href={page.url}>{page.name}</a>
|
|
47
|
+
</li>
|
|
48
|
+
))}
|
|
49
|
+
</ul>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'ListChildPagesFromCurrentPage',
|
|
56
|
+
name: 'List Child Pages From Current Page',
|
|
57
|
+
content: `${IMPORTS}export default function ChildPages({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
58
|
+
return (
|
|
59
|
+
<ul>
|
|
60
|
+
{nav.children(model).map((page) => (
|
|
61
|
+
<li>
|
|
62
|
+
<a href={page.url}>{page.name}</a>
|
|
63
|
+
</li>
|
|
64
|
+
))}
|
|
65
|
+
</ul>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
`,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: 'ListChildPagesOrderedByDate',
|
|
72
|
+
name: 'List Child Pages Ordered By Date',
|
|
73
|
+
content: `${IMPORTS}export default function ChildPagesByDate({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
74
|
+
const pages = [...nav.children(model)].sort(
|
|
75
|
+
(a, b) => b.createDate.getTime() - a.createDate.getTime(),
|
|
76
|
+
)
|
|
77
|
+
return (
|
|
78
|
+
<ul>
|
|
79
|
+
{pages.map((page) => (
|
|
80
|
+
<li>
|
|
81
|
+
<a href={page.url}>{page.name}</a>
|
|
82
|
+
</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'ListChildPagesOrderedByName',
|
|
91
|
+
name: 'List Child Pages Ordered By Name',
|
|
92
|
+
content: `${IMPORTS}export default function ChildPagesByName({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
93
|
+
const pages = [...nav.children(model)].sort((a, b) => a.name.localeCompare(b.name))
|
|
94
|
+
return (
|
|
95
|
+
<ul>
|
|
96
|
+
{pages.map((page) => (
|
|
97
|
+
<li>
|
|
98
|
+
<a href={page.url}>{page.name}</a>
|
|
99
|
+
</li>
|
|
100
|
+
))}
|
|
101
|
+
</ul>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
`,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'ListDescendantsFromCurrentPage',
|
|
108
|
+
name: 'List Descendants From Current Page',
|
|
109
|
+
content: `import type { PageProps, PublishedContent } from '@crombolo/crombolo'
|
|
110
|
+
|
|
111
|
+
export default function Descendants({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
112
|
+
const branch = (page: PublishedContent) => (
|
|
113
|
+
<ul>
|
|
114
|
+
{nav.children(page).map((child) => (
|
|
115
|
+
<li>
|
|
116
|
+
<a href={child.url}>{child.name}</a>
|
|
117
|
+
{nav.children(child).length > 0 ? branch(child) : null}
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
</ul>
|
|
121
|
+
)
|
|
122
|
+
return branch(model)
|
|
123
|
+
}
|
|
124
|
+
`,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: 'Navigation',
|
|
128
|
+
name: 'Navigation',
|
|
129
|
+
content: `${IMPORTS}export default function Navigation({ model, nav }: Pick<PageProps, 'model' | 'nav'>) {
|
|
130
|
+
const site = nav.ancestors(model).at(-1) ?? model
|
|
131
|
+
return (
|
|
132
|
+
<ul class="nav">
|
|
133
|
+
{nav.children(site).map((page) => (
|
|
134
|
+
<li class={page.id === model.id ? 'current' : undefined}>
|
|
135
|
+
<a href={page.url}>{page.name}</a>
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
`,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'SiteMap',
|
|
145
|
+
name: 'Site Map',
|
|
146
|
+
content: `import type { PageProps, PublishedContent } from '@crombolo/crombolo'
|
|
147
|
+
|
|
148
|
+
export default function SiteMap({ nav }: Pick<PageProps, 'nav'>) {
|
|
149
|
+
const branch = (pages: PublishedContent[]) => (
|
|
150
|
+
<ul>
|
|
151
|
+
{pages.map((page) => (
|
|
152
|
+
<li>
|
|
153
|
+
<a href={page.url}>{page.name}</a>
|
|
154
|
+
{nav.children(page).length > 0 ? branch(nav.children(page)) : null}
|
|
155
|
+
</li>
|
|
156
|
+
))}
|
|
157
|
+
</ul>
|
|
158
|
+
)
|
|
159
|
+
return <div class="sitemap">{branch(nav.root())}</div>
|
|
160
|
+
}
|
|
161
|
+
`,
|
|
162
|
+
},
|
|
163
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A `.tsx` under the site's `components/` directory.
|
|
6
|
+
*
|
|
7
|
+
* There is one root and one kind of file. What makes a component a *template*
|
|
8
|
+
* is that a content type names it — `components = ["pages/homePage"]` — not
|
|
9
|
+
* where it sits, which is why this carries no kind: the schema already records
|
|
10
|
+
* that, and a directory that restated it could disagree with it.
|
|
11
|
+
*
|
|
12
|
+
* (Umbraco splits these into Templates and Partial Views because Razor needed
|
|
13
|
+
* the distinction for master-template inheritance. A TSX layout is an import,
|
|
14
|
+
* so the split bought nothing and cost a rule about where a file may live.)
|
|
15
|
+
*/
|
|
16
|
+
export interface ComponentFile {
|
|
17
|
+
/**
|
|
18
|
+
* Its path under the root without the extension, slash-separated — which is
|
|
19
|
+
* exactly the alias a content type names.
|
|
20
|
+
*/
|
|
21
|
+
alias: string
|
|
22
|
+
path: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SOURCE = /\.(tsx|jsx)$/
|
|
26
|
+
|
|
27
|
+
/** An alias that stays inside the root: no climbing, no absolute path, no drive. */
|
|
28
|
+
export function isSafeAlias(alias: string): boolean {
|
|
29
|
+
if (alias.length === 0 || alias.startsWith('/') || alias.includes('\\')) return false
|
|
30
|
+
if (/^[a-zA-Z]:/.test(alias)) return false
|
|
31
|
+
return !alias.split('/').some((part) => part === '' || part === '.' || part === '..')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every component on disk, at any depth, in a stable order. */
|
|
35
|
+
export function listComponents(root: string): ComponentFile[] {
|
|
36
|
+
if (!existsSync(root)) return []
|
|
37
|
+
const out: ComponentFile[] = []
|
|
38
|
+
const walk = (dir: string): void => {
|
|
39
|
+
for (const name of readdirSync(dir).sort()) {
|
|
40
|
+
const path = join(dir, name)
|
|
41
|
+
if (statSync(path).isDirectory()) {
|
|
42
|
+
walk(path)
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (!SOURCE.test(name)) continue
|
|
46
|
+
out.push({ alias: aliasOf(root, path), path })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
walk(root)
|
|
50
|
+
return out.sort((a, b) => a.alias.localeCompare(b.alias))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The alias a file answers to: its path under the root, without the extension. */
|
|
54
|
+
export function aliasOf(root: string, path: string): string {
|
|
55
|
+
return relative(root, path).split(sep).join('/').replace(SOURCE, '')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ComponentProblem {
|
|
59
|
+
/** Relative to the site, so the message is one somebody can act on. */
|
|
60
|
+
file: string
|
|
61
|
+
message: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ComponentCheckReport {
|
|
65
|
+
components: ComponentFile[]
|
|
66
|
+
/** The aliases a content type names; the rest are only ever imported. */
|
|
67
|
+
declared: readonly string[]
|
|
68
|
+
/** One that does not compile, or a template a type declares and nothing provides. */
|
|
69
|
+
problems: ComponentProblem[]
|
|
70
|
+
/** Whether types were checked, and what the compiler said. */
|
|
71
|
+
types: { checked: boolean; problems: string[]; note?: string }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compiles every component, and reports the templates the schema expects.
|
|
76
|
+
*
|
|
77
|
+
* Each file is transpiled first, because that gives the line a syntax error is
|
|
78
|
+
* on, and then imported, because that is what a render does: an import that
|
|
79
|
+
* cannot be resolved is as fatal as a syntax error and shows up nowhere else.
|
|
80
|
+
*/
|
|
81
|
+
export async function checkComponents(options: {
|
|
82
|
+
componentsDir: string
|
|
83
|
+
siteDir: string
|
|
84
|
+
/** Component aliases the schema declares, from `allProperties`'s neighbours. */
|
|
85
|
+
declaredComponents?: readonly string[]
|
|
86
|
+
}): Promise<ComponentCheckReport> {
|
|
87
|
+
const components = listComponents(options.componentsDir)
|
|
88
|
+
const problems: ComponentProblem[] = []
|
|
89
|
+
const transpiler = new Bun.Transpiler({ loader: 'tsx' })
|
|
90
|
+
|
|
91
|
+
for (const component of components) {
|
|
92
|
+
const where = relative(options.siteDir, component.path)
|
|
93
|
+
const source = await Bun.file(component.path).text()
|
|
94
|
+
try {
|
|
95
|
+
transpiler.transformSync(source)
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Bun's message is a bare "Syntax Error"; the line it is on hangs off
|
|
98
|
+
// `position`, and without it the report says nothing anybody can act on.
|
|
99
|
+
const at = (error as { position?: { line?: number; column?: number; lineText?: string } })
|
|
100
|
+
.position
|
|
101
|
+
const where_ = at?.line ? `:${at.line}${at.column === undefined ? '' : `:${at.column}`}` : ''
|
|
102
|
+
const text = at?.lineText?.trim()
|
|
103
|
+
problems.push({
|
|
104
|
+
file: `${where}${where_}`,
|
|
105
|
+
message: `${(error as Error).message.trim()}${text ? ` (${text})` : ''}`,
|
|
106
|
+
})
|
|
107
|
+
continue
|
|
108
|
+
}
|
|
109
|
+
// A relative import that climbs out of the root resolves today and breaks
|
|
110
|
+
// the moment the component is rendered from a snapshot, which is a copy of
|
|
111
|
+
// the tree and nothing above it. Refused here, where the message can say why.
|
|
112
|
+
for (const specifier of transpiler.scanImports(source)) {
|
|
113
|
+
if (!specifier.path.startsWith('.')) continue
|
|
114
|
+
const target = resolve(join(component.path, '..'), specifier.path)
|
|
115
|
+
if (!relative(options.componentsDir, target).startsWith('..')) continue
|
|
116
|
+
problems.push({
|
|
117
|
+
file: where,
|
|
118
|
+
message:
|
|
119
|
+
`imports "${specifier.path}", which is outside components/. A component is rendered ` +
|
|
120
|
+
'from a snapshot of the tree, so it can only import what the tree contains.',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// A query does not bust Bun's module cache, so a path imported once in
|
|
126
|
+
// this process answers from the registry. Harmless: the CLI checks each
|
|
127
|
+
// component once and exits, and each test uses a directory of its own.
|
|
128
|
+
await import(component.path)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
problems.push({ file: where, message: (error as Error).message.trim() })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const present = new Set(components.map((component) => component.alias))
|
|
135
|
+
const declared = [...new Set(options.declaredComponents ?? [])]
|
|
136
|
+
for (const alias of declared) {
|
|
137
|
+
if (!isSafeAlias(alias)) {
|
|
138
|
+
problems.push({
|
|
139
|
+
file: relative(options.siteDir, options.componentsDir),
|
|
140
|
+
message: `a content type declares the component "${alias}", which is not a path inside components/`,
|
|
141
|
+
})
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (present.has(alias)) continue
|
|
145
|
+
problems.push({
|
|
146
|
+
file: relative(options.siteDir, componentFileFor(options.componentsDir, alias)),
|
|
147
|
+
message: `a content type declares the component "${alias}", and there is no file for it`,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
components,
|
|
153
|
+
declared: declared.filter((alias) => present.has(alias)),
|
|
154
|
+
problems,
|
|
155
|
+
types: await checkTypes(options.siteDir),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `tsc --noEmit` with the site's own tsconfig, when the site has the compiler.
|
|
161
|
+
*
|
|
162
|
+
* `typescript` is not a dependency of crombolo — a site that has not installed
|
|
163
|
+
* it is told so rather than being made to carry one.
|
|
164
|
+
*/
|
|
165
|
+
async function checkTypes(siteDir: string): Promise<ComponentCheckReport['types']> {
|
|
166
|
+
const tsc = join(siteDir, 'node_modules', 'typescript', 'bin', 'tsc')
|
|
167
|
+
if (!existsSync(tsc))
|
|
168
|
+
return {
|
|
169
|
+
checked: false,
|
|
170
|
+
problems: [],
|
|
171
|
+
note: 'types were not checked: `bun add -d typescript` to check them too',
|
|
172
|
+
}
|
|
173
|
+
if (!existsSync(join(siteDir, 'tsconfig.json')))
|
|
174
|
+
return { checked: false, problems: [], note: 'types were not checked: no tsconfig.json here' }
|
|
175
|
+
|
|
176
|
+
const proc = Bun.spawn(['bun', tsc, '--noEmit', '-p', siteDir], {
|
|
177
|
+
cwd: siteDir,
|
|
178
|
+
stdout: 'pipe',
|
|
179
|
+
stderr: 'pipe',
|
|
180
|
+
})
|
|
181
|
+
const [out, err] = await Promise.all([
|
|
182
|
+
new Response(proc.stdout).text(),
|
|
183
|
+
new Response(proc.stderr).text(),
|
|
184
|
+
])
|
|
185
|
+
await proc.exited
|
|
186
|
+
const lines = `${out}${err}`
|
|
187
|
+
.split('\n')
|
|
188
|
+
.map((line) => line.trim())
|
|
189
|
+
.filter((line) => line.length > 0 && !/^Found \d+ error/.test(line))
|
|
190
|
+
return { checked: true, problems: lines }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Where a component with this alias lives. The alias is the path. */
|
|
194
|
+
export function componentFileFor(root: string, alias: string): string {
|
|
195
|
+
if (!isSafeAlias(alias)) throw new Error(`"${alias}" is not a path inside components/`)
|
|
196
|
+
return join(root, `${alias}.tsx`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What a component looks like: a function, taking whatever imports it hands it. */
|
|
200
|
+
export function componentScaffold(alias: string): string {
|
|
201
|
+
const name = componentName(alias)
|
|
202
|
+
return `import type { PublishedContent } from '@crombolo/crombolo'
|
|
203
|
+
|
|
204
|
+
export function ${name}({ model }: { model: PublishedContent }) {
|
|
205
|
+
return <p>{model.name}</p>
|
|
206
|
+
}
|
|
207
|
+
`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A PascalCase identifier from an alias, which may be a path. */
|
|
211
|
+
export function componentName(alias: string): string {
|
|
212
|
+
const base = alias.split('/').pop() ?? alias
|
|
213
|
+
const cleaned = base.replace(/[^A-Za-z0-9]+(.)?/g, (_, next: string | undefined) =>
|
|
214
|
+
next ? next.toUpperCase() : '',
|
|
215
|
+
)
|
|
216
|
+
const name = cleaned.charAt(0).toUpperCase() + cleaned.slice(1)
|
|
217
|
+
return /^[A-Za-z]/.test(name) ? name : `Component${name}`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export type AssetKind = 'stylesheet' | 'script'
|
|
221
|
+
|
|
222
|
+
export interface AssetFile {
|
|
223
|
+
kind: AssetKind
|
|
224
|
+
name: string
|
|
225
|
+
path: string
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Umbraco's places, which the backoffice's own editors read and write. */
|
|
229
|
+
export function assetDirs(config: {
|
|
230
|
+
stylesheetsDir: string
|
|
231
|
+
scriptsDir: string
|
|
232
|
+
}): Record<AssetKind, { dir: string; extension: string }> {
|
|
233
|
+
return {
|
|
234
|
+
stylesheet: { dir: config.stylesheetsDir, extension: '.css' },
|
|
235
|
+
script: { dir: config.scriptsDir, extension: '.js' },
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function listAssets(config: { stylesheetsDir: string; scriptsDir: string }): AssetFile[] {
|
|
240
|
+
const out: AssetFile[] = []
|
|
241
|
+
for (const [kind, where] of Object.entries(assetDirs(config)) as Array<
|
|
242
|
+
[AssetKind, { dir: string; extension: string }]
|
|
243
|
+
>) {
|
|
244
|
+
if (!existsSync(where.dir)) continue
|
|
245
|
+
const walk = (dir: string): void => {
|
|
246
|
+
for (const name of readdirSync(dir).sort()) {
|
|
247
|
+
const path = join(dir, name)
|
|
248
|
+
if (statSync(path).isDirectory()) {
|
|
249
|
+
walk(path)
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (name.endsWith(where.extension))
|
|
253
|
+
out.push({ kind, name: relative(where.dir, path), path })
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
walk(where.dir)
|
|
257
|
+
}
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The file `assets new` writes: enough to be valid and obviously a starting point. */
|
|
262
|
+
export function assetScaffold(kind: AssetKind, name: string): string {
|
|
263
|
+
return kind === 'stylesheet'
|
|
264
|
+
? `/* ${name} — linked from a view with <link rel="stylesheet" href="/css/${name}"> */\n`
|
|
265
|
+
: `// ${name} — loaded by a view with <script src="/scripts/${name}"></script>\n`
|
|
266
|
+
}
|