Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Sign in / Register
Toggle navigation
W
WebAgent
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
xuchentao
WebAgent
Commits
bb9d04f4
Commit
bb9d04f4
authored
Jul 22, 2026
by
xuchentao
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
优化文章双版本管理流程
parent
467e65cb
Changes
12
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
305 additions
and
49 deletions
+305
-49
article-service.test.ts
backend/src/articles/article-service.test.ts
+23
-5
article-service.ts
backend/src/articles/article-service.ts
+41
-8
article-storage.ts
backend/src/articles/article-storage.ts
+51
-0
domain-deployment-service.ts
backend/src/domains/domain-deployment-service.ts
+1
-1
server.ts
backend/src/server.ts
+3
-0
create-site.ts
backend/src/sites/create-site.ts
+2
-1
site-repository.ts
backend/src/sites/site-repository.ts
+26
-0
site-version-service.ts
backend/src/sites/site-version-service.ts
+2
-0
App.tsx
frontend/src/App.tsx
+149
-34
api.ts
frontend/src/api.ts
+1
-0
styles.css
frontend/src/styles.css
+2
-0
index.ts
shared/src/index.ts
+4
-0
No files found.
backend/src/articles/article-service.test.ts
View file @
bb9d04f4
...
...
@@ -8,6 +8,7 @@ import type { BuildManager, SiteBuildContext } from "../build/build-manager.js";
import
type
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
type
{
SiteRepository
}
from
"../sites/site-repository.js"
;
import
{
ArticleService
}
from
"./article-service.js"
;
import
{
syncPublishedArticles
}
from
"./article-storage.js"
;
const
exists
=
(
target
:
string
)
=>
access
(
target
).
then
(()
=>
true
).
catch
(()
=>
false
);
...
...
@@ -15,6 +16,7 @@ test("article mutations use external content storage without creating Git-manage
const
root
=
await
mkdtemp
(
path
.
join
(
os
.
tmpdir
(),
"webagent-articles-"
));
const
project
=
path
.
join
(
root
,
"project"
);
const
articles
=
path
.
join
(
root
,
"content"
,
"articles"
);
const
liveArticles
=
path
.
join
(
root
,
"content"
,
"live-articles"
);
const
images
=
path
.
join
(
root
,
"content"
,
"images"
);
const
articleState
=
path
.
join
(
root
,
"metadata"
,
"articles.json"
);
const
dist
=
path
.
join
(
root
,
"dist"
);
...
...
@@ -38,6 +40,7 @@ test("article mutations use external content storage without creating Git-manage
getProjectPath
:
()
=>
project
,
getDraftPath
:
()
=>
path
.
join
(
root
,
"draft"
),
getArticlesPath
:
()
=>
articles
,
getLiveArticlesPath
:
()
=>
liveArticles
,
getArticleImagesPath
:
()
=>
images
,
getArticleStatePath
:
()
=>
articleState
,
markArticlesCompiled
:
async
()
=>
{
...
...
@@ -69,16 +72,31 @@ test("article mutations use external content storage without creating Git-manage
assert
.
equal
(
await
exists
(
path
.
join
(
articles
,
"hello.md"
)),
true
);
assert
.
equal
(
await
exists
(
path
.
join
(
project
,
"src"
,
"content"
,
"articles"
)),
false
);
assert
.
equal
(
buildCalls
,
0
);
assert
.
equal
((
await
new
ArticleService
(
sites
,
builds
,
previews
).
list
(
"tenant_test"
,
"site_test"
)).
pendingBuild
,
true
);
const
published
=
await
new
ArticleService
(
sites
,
builds
,
previews
).
update
(
"tenant_test"
,
"site_test"
,
"hello"
,
{
...
article
,
status
:
"published"
});
const
service
=
new
ArticleService
(
sites
,
builds
,
previews
);
const
initialList
=
await
service
.
list
(
"tenant_test"
,
"site_test"
);
assert
.
equal
(
initialList
.
pendingBuild
,
true
);
assert
.
equal
(
initialList
.
articles
[
0
]?.
liveState
,
"draft"
);
const
published
=
await
service
.
update
(
"tenant_test"
,
"site_test"
,
"hello"
,
{
...
article
,
status
:
"published"
});
assert
.
equal
(
published
.
article
?.
status
,
"published"
);
assert
.
match
(
await
readFile
(
path
.
join
(
articles
,
"hello.md"
),
"utf8"
),
/status: published/
);
assert
.
equal
(
buildCalls
,
0
);
await
new
ArticleService
(
sites
,
builds
,
previews
)
.
compilePreview
(
"tenant_test"
,
"site_test"
);
await
service
.
compilePreview
(
"tenant_test"
,
"site_test"
);
assert
.
equal
(
buildCalls
,
1
);
assert
.
equal
(
buildArticlesDirectory
,
articles
);
assert
.
equal
((
await
new
ArticleService
(
sites
,
builds
,
previews
).
list
(
"tenant_test"
,
"site_test"
)).
pendingBuild
,
false
);
const
uploaded
=
await
new
ArticleService
(
sites
,
builds
,
previews
).
uploadImage
(
"tenant_test"
,
"site_test"
,
{
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
pendingBuild
,
false
);
await
syncPublishedArticles
(
articles
,
liveArticles
);
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"live"
);
await
service
.
update
(
"tenant_test"
,
"site_test"
,
"hello"
,
{
...
article
,
body
:
"Updated draft"
,
status
:
"published"
});
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"modified"
);
await
service
.
restoreLive
(
"tenant_test"
,
"site_test"
,
"hello"
);
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"live"
);
const
pendingRemoval
=
await
service
.
remove
(
"tenant_test"
,
"site_test"
,
"hello"
);
assert
.
equal
(
pendingRemoval
.
article
?.
liveState
,
"pending-removal"
);
assert
.
equal
(
await
exists
(
path
.
join
(
articles
,
"hello.md"
)),
true
);
await
syncPublishedArticles
(
articles
,
liveArticles
);
assert
.
equal
(
await
exists
(
path
.
join
(
liveArticles
,
"hello.md"
)),
false
);
assert
.
equal
((
await
service
.
list
(
"tenant_test"
,
"site_test"
)).
articles
[
0
]?.
liveState
,
"draft"
);
const
uploaded
=
await
service
.
uploadImage
(
"tenant_test"
,
"site_test"
,
{
filename
:
"pixel.png"
,
dataUrl
:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
,
});
...
...
backend/src/articles/article-service.ts
View file @
bb9d04f4
...
...
@@ -8,9 +8,11 @@ import { getPublicPreviewUrl } from "../config.js";
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
articleInputSchema
}
from
"../schemas.js"
;
import
{
SiteRepository
}
from
"../sites/site-repository.js"
;
import
{
articleLiveState
}
from
"./article-storage.js"
;
type
Frontmatter
=
Record
<
string
,
unknown
>
;
type
ArticleBuildState
=
{
pendingSlugs
:
string
[];
lastCompiledAt
?:
string
};
type
StoredArticle
=
Omit
<
ArticleDocument
,
"liveState"
>
;
export
class
ArticleService
{
private
readonly
queues
=
new
Map
<
string
,
Promise
<
unknown
>>
();
...
...
@@ -23,13 +25,19 @@ export class ArticleService {
async
list
(
tenantId
:
string
,
siteId
:
string
):
Promise
<
ArticleListResult
>
{
const
site
=
await
this
.
sites
.
get
(
tenantId
,
siteId
);
if
(
!
await
this
.
supported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
)))
return
{
supported
:
false
,
articles
:
[],
pendingBuild
:
false
,
pendingCount
:
0
};
if
(
!
await
this
.
supported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
)))
return
{
supported
:
false
,
articles
:
[],
pendingBuild
:
false
,
pendingCount
:
0
,
pendingLiveCount
:
0
};
const
directory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
await
mkdir
(
directory
,
{
recursive
:
true
});
const
liveDirectory
=
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
);
await
Promise
.
all
([
mkdir
(
directory
,
{
recursive
:
true
}),
mkdir
(
liveDirectory
,
{
recursive
:
true
})]);
const
files
=
(
await
readdir
(
directory
,
{
withFileTypes
:
true
}))
.
filter
((
entry
)
=>
entry
.
isFile
()
&&
!
entry
.
name
.
startsWith
(
"_"
)
&&
/
\.
md$/i
.
test
(
entry
.
name
))
.
map
((
entry
)
=>
entry
.
name
);
const
articles
=
await
Promise
.
all
(
files
.
map
((
file
)
=>
this
.
readArticleFile
(
path
.
join
(
directory
,
file
),
file
.
replace
(
/
\.
md$/i
,
""
))));
const
articles
=
await
Promise
.
all
(
files
.
map
(
async
(
file
)
=>
{
const
slug
=
file
.
replace
(
/
\.
md$/i
,
""
);
const
draftFile
=
path
.
join
(
directory
,
file
);
const
article
=
await
this
.
readArticleFile
(
draftFile
,
slug
);
return
{
...
article
,
liveState
:
await
articleLiveState
(
draftFile
,
this
.
articlePath
(
liveDirectory
,
slug
),
article
.
status
)
};
}));
const
state
=
await
this
.
readState
(
tenantId
,
siteId
);
const
pending
=
new
Set
(
state
.
pendingSlugs
);
return
{
...
...
@@ -37,6 +45,7 @@ export class ArticleService {
pendingBuild
:
pending
.
size
>
0
,
pendingCount
:
pending
.
size
,
lastCompiledAt
:
state
.
lastCompiledAt
,
pendingLiveCount
:
articles
.
filter
((
article
)
=>
article
.
liveState
===
"modified"
||
article
.
liveState
===
"pending-removal"
||
(
article
.
liveState
===
"draft"
&&
article
.
status
===
"published"
)).
length
,
articles
:
articles
.
map
(({
body
:
_body
,
...
article
})
=>
({
...
article
,
pendingBuild
:
pending
.
has
(
article
.
slug
)
})).
sort
((
a
,
b
)
=>
{
const
left
=
a
.
updatedAt
||
a
.
publishedAt
||
""
;
const
right
=
b
.
updatedAt
||
b
.
publishedAt
||
""
;
...
...
@@ -49,7 +58,9 @@ export class ArticleService {
this
.
assertSlug
(
slug
);
await
this
.
sites
.
get
(
tenantId
,
siteId
);
await
this
.
assertSupported
(
this
.
sites
.
getProjectPath
(
tenantId
,
siteId
));
return
this
.
readArticleFile
(
this
.
articlePath
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
slug
),
slug
);
const
draftFile
=
this
.
articlePath
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
slug
);
const
article
=
await
this
.
readArticleFile
(
draftFile
,
slug
);
return
{
...
article
,
liveState
:
await
articleLiveState
(
draftFile
,
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
),
article
.
status
)
};
}
async
create
(
tenantId
:
string
,
siteId
:
string
,
input
:
ArticleInput
):
Promise
<
ArticleMutationResult
>
{
...
...
@@ -86,11 +97,29 @@ export class ArticleService {
const
source
=
this
.
articlePath
(
directory
,
slug
);
if
(
!
await
this
.
exists
(
source
))
throw
Object
.
assign
(
new
Error
(
"文章不存在"
),
{
statusCode
:
404
});
const
article
=
await
this
.
readArticleFile
(
source
,
slug
);
const
liveFile
=
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
);
if
(
await
this
.
exists
(
liveFile
))
{
const
next
=
{
...
article
,
status
:
"draft"
as
const
};
await
writeFile
(
source
,
serializeArticle
(
next
),
"utf8"
);
return
{
article
:
next
,
changedSlugs
:
[
slug
]
};
}
await
rm
(
source
,
{
force
:
true
});
return
{
deletedSlug
:
slug
,
changedSlugs
:
[
slug
]
};
});
}
async
restoreLive
(
tenantId
:
string
,
siteId
:
string
,
slug
:
string
):
Promise
<
ArticleMutationResult
>
{
this
.
assertSlug
(
slug
);
return
this
.
mutate
(
tenantId
,
siteId
,
async
(
directory
)
=>
{
const
liveFile
=
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
);
if
(
!
await
this
.
exists
(
liveFile
))
throw
Object
.
assign
(
new
Error
(
"文章还没有线上版本"
),
{
statusCode
:
409
});
const
destination
=
this
.
articlePath
(
directory
,
slug
);
await
cp
(
liveFile
,
destination
);
const
{
wordCount
:
_wordCount
,
...
article
}
=
await
this
.
readArticleFile
(
liveFile
,
slug
);
return
{
article
,
changedSlugs
:
[
slug
]
};
});
}
async
importMarkdown
(
tenantId
:
string
,
siteId
:
string
,
filename
:
string
,
markdown
:
string
):
Promise
<
ArticleMutationResult
>
{
const
parsed
=
importedArticle
(
filename
,
markdown
);
return
this
.
create
(
tenantId
,
siteId
,
articleInputSchema
.
parse
(
parsed
));
...
...
@@ -178,9 +207,7 @@ export class ArticleService {
swapped
=
true
;
const
state
=
await
this
.
readState
(
tenantId
,
siteId
);
await
this
.
writeState
(
tenantId
,
siteId
,
{
...
state
,
pendingSlugs
:
[...
new
Set
([...
state
.
pendingSlugs
,
...
changed
.
changedSlugs
])]
});
const
article
=
changed
.
article
?
await
this
.
readArticleFile
(
this
.
articlePath
(
articlesDirectory
,
changed
.
article
.
slug
),
changed
.
article
.
slug
)
:
undefined
;
const
article
=
changed
.
article
?
await
this
.
articleDocument
(
tenantId
,
siteId
,
changed
.
article
.
slug
)
:
undefined
;
return
{
article
,
deletedSlug
:
changed
.
deletedSlug
,
previewUrl
:
site
.
previewUrl
,
pendingBuild
:
true
};
}
catch
(
error
)
{
if
(
swapped
)
{
...
...
@@ -204,7 +231,13 @@ export class ArticleService {
return
draft
;
}
private
async
readArticleFile
(
file
:
string
,
slug
:
string
):
Promise
<
ArticleDocument
>
{
private
async
articleDocument
(
tenantId
:
string
,
siteId
:
string
,
slug
:
string
):
Promise
<
ArticleDocument
>
{
const
draftFile
=
this
.
articlePath
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
slug
);
const
article
=
await
this
.
readArticleFile
(
draftFile
,
slug
);
return
{
...
article
,
liveState
:
await
articleLiveState
(
draftFile
,
this
.
articlePath
(
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
),
slug
),
article
.
status
)
};
}
private
async
readArticleFile
(
file
:
string
,
slug
:
string
):
Promise
<
StoredArticle
>
{
try
{
const
parsed
=
parseMarkdown
(
await
readFile
(
file
,
"utf8"
));
const
input
=
articleInputSchema
.
parse
({
slug
,
...
parsed
.
data
,
body
:
parsed
.
body
});
...
...
backend/src/articles/article-storage.ts
0 → 100644
View file @
bb9d04f4
import
crypto
from
"node:crypto"
;
import
path
from
"node:path"
;
import
{
access
,
cp
,
mkdir
,
readFile
,
readdir
,
rename
,
rm
}
from
"node:fs/promises"
;
import
{
parse
as
parseYaml
}
from
"yaml"
;
import
type
{
ArticleLiveState
,
ArticleStatus
}
from
"@webagent/shared"
;
export
async
function
articleLiveState
(
draftFile
:
string
,
liveFile
:
string
,
status
:
ArticleStatus
):
Promise
<
ArticleLiveState
>
{
if
(
!
await
exists
(
liveFile
))
return
"draft"
;
if
(
status
!==
"published"
)
return
"pending-removal"
;
const
[
draft
,
live
]
=
await
Promise
.
all
([
readFile
(
draftFile
,
"utf8"
),
readFile
(
liveFile
,
"utf8"
)]);
return
draft
===
live
?
"live"
:
"modified"
;
}
export
async
function
syncPublishedArticles
(
draftDirectory
:
string
,
liveDirectory
:
string
):
Promise
<
void
>
{
const
suffix
=
crypto
.
randomBytes
(
5
).
toString
(
"hex"
);
const
staging
=
path
.
join
(
path
.
dirname
(
liveDirectory
),
`.live-articles-next-
${
suffix
}
`
);
const
previous
=
path
.
join
(
path
.
dirname
(
liveDirectory
),
`.live-articles-previous-
${
suffix
}
`
);
await
mkdir
(
draftDirectory
,
{
recursive
:
true
});
await
mkdir
(
staging
,
{
recursive
:
true
});
const
entries
=
await
readdir
(
draftDirectory
,
{
withFileTypes
:
true
});
for
(
const
entry
of
entries
)
{
if
(
!
entry
.
isFile
()
||
entry
.
name
.
startsWith
(
"_"
)
||
!
/
\.
md$/i
.
test
(
entry
.
name
))
continue
;
const
source
=
path
.
join
(
draftDirectory
,
entry
.
name
);
if
(
articleStatus
(
await
readFile
(
source
,
"utf8"
))
!==
"published"
)
continue
;
await
cp
(
source
,
path
.
join
(
staging
,
entry
.
name
));
}
const
hasCurrent
=
await
exists
(
liveDirectory
);
if
(
hasCurrent
)
await
rename
(
liveDirectory
,
previous
);
try
{
await
rename
(
staging
,
liveDirectory
);
}
catch
(
error
)
{
if
(
hasCurrent
)
await
rename
(
previous
,
liveDirectory
).
catch
(()
=>
undefined
);
await
rm
(
staging
,
{
recursive
:
true
,
force
:
true
});
throw
error
;
}
await
rm
(
previous
,
{
recursive
:
true
,
force
:
true
});
}
function
articleStatus
(
markdown
:
string
):
ArticleStatus
{
const
normalized
=
markdown
.
replaceAll
(
"
\
r
\n
"
,
"
\n
"
);
if
(
!
normalized
.
startsWith
(
"---
\n
"
))
return
"draft"
;
const
boundary
=
normalized
.
indexOf
(
"
\n
---"
,
4
);
if
(
boundary
<
0
)
return
"draft"
;
const
data
=
parseYaml
(
normalized
.
slice
(
4
,
boundary
));
return
data
&&
typeof
data
===
"object"
&&
"status"
in
data
&&
data
.
status
===
"published"
?
"published"
:
"draft"
;
}
function
exists
(
target
:
string
):
Promise
<
boolean
>
{
return
access
(
target
).
then
(()
=>
true
).
catch
(()
=>
false
);
}
backend/src/domains/domain-deployment-service.ts
View file @
bb9d04f4
...
...
@@ -45,7 +45,7 @@ export class DomainDeploymentService {
||
bindings
.
find
((
domain
)
=>
domain
.
ownershipStatus
===
"verified"
);
const
dist
=
await
this
.
builds
.
build
(
workspace
,
taskId
,
{
basePath
:
"/"
,
publicOrigin
:
primary
?
`https://
${
primary
.
hostname
}
`
:
undefined
,
indexable
:
true
,
articlesDirectory
:
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
articlesDirectory
:
this
.
sites
.
get
Live
ArticlesPath
(
tenantId
,
siteId
),
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
await
this
.
builds
.
publishCustomDomain
(
tenantId
,
siteId
,
dist
);
...
...
backend/src/server.ts
View file @
bb9d04f4
...
...
@@ -181,6 +181,9 @@ app.put<{ Params: { siteId: string; slug: string } }>("/api/sites/:siteId/articl
app
.
delete
<
{
Params
:
{
siteId
:
string
;
slug
:
string
}
}
>
(
"/api/sites/:siteId/articles/:slug"
,
async
(
request
)
=>
(
articles
.
remove
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
request
.
params
.
slug
)
));
app
.
post
<
{
Params
:
{
siteId
:
string
;
slug
:
string
}
}
>
(
"/api/sites/:siteId/articles/:slug/restore-live"
,
async
(
request
)
=>
(
articles
.
restoreLive
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
request
.
params
.
slug
)
));
app
.
post
<
{
Params
:
{
siteId
:
string
}
}
>
(
"/api/sites/:siteId/articles/import"
,
async
(
request
,
reply
)
=>
{
const
body
=
articleImportSchema
.
parse
(
request
.
body
);
return
reply
.
code
(
201
).
send
(
await
articles
.
importMarkdown
(
requireTenant
(
request
.
auth
),
request
.
params
.
siteId
,
body
.
filename
,
body
.
markdown
));
...
...
backend/src/sites/create-site.ts
View file @
bb9d04f4
...
...
@@ -28,8 +28,9 @@ export class CreateSiteService {
createdAt
:
now
,
updatedAt
:
now
,
};
const
articlesDirectory
=
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
);
const
liveArticlesDirectory
=
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
);
const
articleImagesDirectory
=
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
);
await
Promise
.
all
([
mkdir
(
projectPath
,
{
recursive
:
true
}),
mkdir
(
articlesDirectory
,
{
recursive
:
true
}),
mkdir
(
articleImagesDirectory
,
{
recursive
:
true
})]);
await
Promise
.
all
([
mkdir
(
projectPath
,
{
recursive
:
true
}),
mkdir
(
articlesDirectory
,
{
recursive
:
true
}),
mkdir
(
liveArticlesDirectory
,
{
recursive
:
true
}),
mkdir
(
articleImagesDirectory
,
{
recursive
:
true
})]);
await
this
.
sites
.
save
(
site
);
try
{
await
cp
(
config
.
templateDir
,
projectPath
,
{
recursive
:
true
,
filter
:
(
source
)
=>
!
[
"node_modules"
,
"dist"
,
".astro"
,
".git"
,
"pnpm-lock.yaml"
,
"examples"
].
includes
(
path
.
basename
(
source
))
});
...
...
backend/src/sites/site-repository.ts
View file @
bb9d04f4
...
...
@@ -2,6 +2,7 @@ import path from "node:path";
import
{
cp
,
mkdir
,
readFile
,
readdir
,
rename
,
rm
,
stat
,
writeFile
}
from
"node:fs/promises"
;
import
type
{
SiteInfo
}
from
"@webagent/shared"
;
import
{
config
,
getPublicPreviewUrl
,
getPublicProductionUrl
,
runtimePaths
}
from
"../config.js"
;
import
{
syncPublishedArticles
}
from
"../articles/article-storage.js"
;
export
class
SiteRepository
{
private
runtimeReady
?:
Promise
<
void
>
;
...
...
@@ -40,6 +41,10 @@ export class SiteRepository {
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"articles"
);
}
getLiveArticlesPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"live-articles"
);
}
getArticleImagesPath
(
tenantId
:
string
,
siteId
:
string
):
string
{
return
path
.
join
(
this
.
getSiteRoot
(
tenantId
,
siteId
),
"content"
,
"images"
);
}
...
...
@@ -155,6 +160,7 @@ export class SiteRepository {
]);
await
this
.
migrateLegacySites
();
await
this
.
migrateFlatSites
();
await
this
.
migrateArticleLiveSlots
();
await
Promise
.
all
([
this
.
migrateLegacyPreviews
(),
this
.
migrateFlatArtifacts
(
config
.
previewsDir
),
...
...
@@ -163,6 +169,26 @@ export class SiteRepository {
]);
}
private
async
migrateArticleLiveSlots
():
Promise
<
void
>
{
const
tenants
=
await
readdir
(
runtimePaths
.
sites
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
for
(
const
tenant
of
tenants
)
{
if
(
!
tenant
.
isDirectory
()
||
!
/^tenant_
[
a-z0-9_
]
+$/
.
test
(
tenant
.
name
))
continue
;
const
tenantRoot
=
path
.
join
(
runtimePaths
.
sites
,
tenant
.
name
);
const
siteEntries
=
await
readdir
(
tenantRoot
,
{
withFileTypes
:
true
}).
catch
(()
=>
[]);
for
(
const
entry
of
siteEntries
)
{
if
(
!
entry
.
isDirectory
()
||
!
/^site_
[
a-z0-9
]
+$/
.
test
(
entry
.
name
))
continue
;
const
siteRoot
=
path
.
join
(
tenantRoot
,
entry
.
name
);
const
metadata
=
await
readFile
(
path
.
join
(
siteRoot
,
"metadata"
,
"site.json"
),
"utf8"
)
.
then
((
raw
)
=>
JSON
.
parse
(
raw
)
as
SiteInfo
)
.
catch
(()
=>
undefined
);
if
(
!
metadata
?.
publishedCommit
)
continue
;
const
liveDirectory
=
path
.
join
(
siteRoot
,
"content"
,
"live-articles"
);
if
(
await
stat
(
liveDirectory
).
then
(()
=>
true
).
catch
(()
=>
false
))
continue
;
await
syncPublishedArticles
(
path
.
join
(
siteRoot
,
"content"
,
"articles"
),
liveDirectory
);
}
}
}
private
async
migrateLegacySites
():
Promise
<
void
>
{
const
legacySitesDir
=
path
.
join
(
config
.
runtimeDir
,
"sites"
);
if
(
path
.
resolve
(
legacySitesDir
)
===
path
.
resolve
(
runtimePaths
.
sites
))
return
;
...
...
backend/src/sites/site-version-service.ts
View file @
bb9d04f4
...
...
@@ -8,6 +8,7 @@ import { GitManager } from "../git/git-manager.js";
import
{
PreviewProcessManager
}
from
"../preview/preview-process-manager.js"
;
import
{
ensureEnvironmentConfig
}
from
"./ensure-environment-config.js"
;
import
{
SiteRepository
}
from
"./site-repository.js"
;
import
{
syncPublishedArticles
}
from
"../articles/article-storage.js"
;
export
class
SiteVersionService
{
constructor
(
...
...
@@ -180,6 +181,7 @@ export class SiteVersionService {
articleImagesDirectory
:
this
.
sites
.
getArticleImagesPath
(
tenantId
,
siteId
),
});
await
this
.
builds
.
publishProduction
(
tenantId
,
siteId
,
dist
);
await
syncPublishedArticles
(
this
.
sites
.
getArticlesPath
(
tenantId
,
siteId
),
this
.
sites
.
getLiveArticlesPath
(
tenantId
,
siteId
));
const
publishedAt
=
new
Date
().
toISOString
();
const
productionUrl
=
getPublicProductionUrl
(
tenantId
,
siteId
);
await
this
.
sites
.
update
(
tenantId
,
siteId
,
{
...
...
frontend/src/App.tsx
View file @
bb9d04f4
This diff is collapsed.
Click to expand it.
frontend/src/api.ts
View file @
bb9d04f4
...
...
@@ -57,6 +57,7 @@ export const api = {
createArticle
:
(
siteId
:
string
,
input
:
ArticleInput
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles`
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
(
input
)
}),
updateArticle
:
(
siteId
:
string
,
slug
:
string
,
input
:
ArticleInput
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
`
,
{
method
:
"PUT"
,
body
:
JSON
.
stringify
(
input
)
}),
deleteArticle
:
(
siteId
:
string
,
slug
:
string
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
`
,
{
method
:
"DELETE"
}),
restoreLiveArticle
:
(
siteId
:
string
,
slug
:
string
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/
${
slug
}
/restore-live`
,
{
method
:
"POST"
}),
importArticle
:
(
siteId
:
string
,
filename
:
string
,
markdown
:
string
)
=>
request
<
ArticleMutationResult
>
(
`/api/sites/
${
siteId
}
/articles/import`
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
filename
,
markdown
})
}),
compileArticles
:
(
siteId
:
string
)
=>
request
<
ArticleCompileResult
>
(
`/api/sites/
${
siteId
}
/articles/compile`
,
{
method
:
"POST"
}),
uploadArticleImage
:
(
siteId
:
string
,
filename
:
string
,
dataUrl
:
string
)
=>
request
<
ArticleImageUploadResult
>
(
`/api/sites/
${
siteId
}
/article-images`
,
{
method
:
"POST"
,
body
:
JSON
.
stringify
({
filename
,
dataUrl
})
}),
...
...
frontend/src/styles.css
View file @
bb9d04f4
...
...
@@ -86,3 +86,5 @@
@media
(
max-width
:
800px
){
.article-workspace
{
display
:
none
}
.workspace.articles-mode
.control-panel
{
display
:
none
}
.workspace.articles-mode
.article-workspace
{
display
:
flex
;
grid-column
:
2
}
.article-layout
{
grid-template-columns
:
170px
minmax
(
0
,
1
fr
)}
.article-header
{
padding
:
0
12px
}
.article-editor
{
padding
:
14px
}
.article-editor-heading
{
align-items
:
flex-start
;
flex-direction
:
column
}
.article-editor-heading
>
div
:last-child
{
flex-wrap
:
wrap
}}
.article-editor-heading
>
div
:first-child
{
flex-wrap
:
wrap
}
.article-status-badge
{
margin-left
:
8px
;
padding
:
4px
7px
;
border-radius
:
6px
;
font-size
:
7px
;
font-style
:
normal
;
font-weight
:
800
}
.article-status-badge.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-status-badge.published
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-editor-heading
.article-save-draft
{
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border-color
:
#d9ceff
}
.article-publish-help
{
display
:
flex
;
flex-direction
:
column
;
justify-content
:
center
;
gap
:
5px
;
padding
:
8px
10px
;
background
:
#fafafd
;
border
:
1px
solid
var
(
--border
);
border-radius
:
8px
}
.article-publish-help
>
span
{
color
:
var
(
--text-2
);
font-size
:
8px
;
font-weight
:
750
}
.article-publish-help
strong
{
font-size
:
10px
}
.article-publish-help
small
{
color
:
var
(
--muted
);
font-size
:
8px
}
.article-editor-only
{
display
:
block
;
overflow
:
auto
}
.article-editor-only
.article-editor
{
width
:
min
(
980px
,
100%
);
margin
:
0
auto
}
.article-side-panel
{
min-height
:
0
;
display
:
flex
;
flex-direction
:
column
;
padding
:
17px
13px
;
background
:
linear-gradient
(
180deg
,
#fff
,
#faf9ff
)}
.article-side-intro
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
padding
:
0
4px
}
.article-side-intro
h2
{
margin
:
0
;
font-size
:
15px
}
.article-side-intro
p
{
margin
:
4px
0
0
;
color
:
var
(
--muted
);
font-size
:
8px
;
line-height
:
1.5
}
.article-side-intro
.domain-side-icon
{
width
:
35px
;
height
:
35px
;
flex
:
0
0
auto
}
.article-flow-guide
{
margin-top
:
13px
;
padding
:
11px
;
background
:
#f7f5ff
;
border
:
1px
solid
#e2dcf8
;
border-radius
:
11px
}
.article-flow-guide
>
strong
{
color
:
var
(
--primary
);
font-size
:
8px
}
.article-flow-guide
ol
{
display
:
flex
;
flex-direction
:
column
;
gap
:
7px
;
margin
:
9px
0
0
;
padding
:
0
;
list-style
:
none
}
.article-flow-guide
li
{
display
:
flex
;
align-items
:
flex-start
;
gap
:
7px
}
.article-flow-guide
li
>
b
{
display
:
grid
;
place-items
:
center
;
width
:
18px
;
height
:
18px
;
flex
:
0
0
auto
;
color
:
#fff
;
background
:
var
(
--primary
);
border-radius
:
6px
;
font-size
:
7px
}
.article-flow-guide
li
span
{
font-size
:
8px
;
font-weight
:
750
}
.article-flow-guide
li
small
{
display
:
block
;
margin-top
:
2px
;
color
:
var
(
--muted
);
font-size
:
7px
;
font-weight
:
400
;
line-height
:
1.4
}
.article-batch-card
{
display
:
grid
;
grid-template-columns
:
1
fr
auto
;
gap
:
7px
;
margin-top
:
9px
;
padding
:
10px
;
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
11px
}
.article-batch-card.pending
{
border-color
:
#decf9a
;
background
:
#fffdf5
}
.article-batch-card
>
div
span
,
.article-batch-card
>
div
small
{
display
:
block
}
.article-batch-card
>
div
span
{
font-size
:
9px
;
font-weight
:
800
}
.article-batch-card
>
div
small
{
margin-top
:
4px
;
color
:
var
(
--muted
);
font-size
:
7px
}
.article-batch-card
button
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
padding
:
0
8px
;
color
:
var
(
--primary
);
background
:
var
(
--soft
);
border
:
1px
solid
#ddd4ff
;
border-radius
:
7px
;
font-size
:
7px
;
font-weight
:
800
}
.article-batch-card
button
:disabled
{
opacity
:
.5
}
.article-batch-card
.article-go-live
{
grid-column
:
1
/
-1
;
color
:
#fff
;
background
:
var
(
--gradient
);
border
:
0
}
.article-side-notice
{
margin-top
:
8px
;
padding
:
7px
9px
;
color
:
#13765b
;
background
:
#e9f9f3
;
border-radius
:
7px
;
font-size
:
7px
}
.article-side-notice.error
{
color
:
#b42318
;
background
:
#fff0ee
}
.article-side-tools
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
6px
;
margin-top
:
10px
}
.article-side-tools
button
,
.article-side-tools
label
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
color
:
var
(
--text-2
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
;
font-size
:
7px
;
font-weight
:
750
;
cursor
:
pointer
}
.article-side-tools
input
{
display
:
none
}
.article-side-list-heading
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
margin
:
13px
3px
7px
}
.article-side-list-heading
strong
{
font-size
:
9px
}
.article-side-list-heading
span
{
color
:
var
(
--muted
);
font-size
:
7px
}
.article-side-list
{
min-height
:
0
;
overflow
:
auto
;
display
:
flex
;
flex-direction
:
column
;
gap
:
4px
}
.article-side-list
>
button
{
width
:
100%
;
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
)
auto
;
gap
:
4px
;
padding
:
8px
;
color
:
var
(
--text
);
text-align
:
left
;
background
:
#fff
;
border
:
1px
solid
transparent
;
border-radius
:
8px
}
.article-side-list
>
button
:hover
,
.article-side-list
>
button
.active
{
background
:
#f5f2ff
;
border-color
:
#ddd4ff
}
.article-side-list
>
button
>
span
{
min-width
:
0
}
.article-side-list
strong
,
.article-side-list
small
{
display
:
block
;
overflow
:
hidden
;
white-space
:
nowrap
;
text-overflow
:
ellipsis
}
.article-side-list
strong
{
font-size
:
8px
}
.article-side-list
small
{
margin-top
:
3px
;
color
:
var
(
--muted
);
font-size
:
6px
}
.article-side-list
em
{
padding
:
3px
4px
;
border-radius
:
4px
;
font-size
:
6px
;
font-style
:
normal
;
font-weight
:
800
}
.article-side-list
em
.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-side-list
em
.published
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-side-list
i
{
grid-column
:
1
/
-1
;
width
:
max-content
;
color
:
#8c6200
;
font-size
:
6px
;
font-style
:
normal
;
font-weight
:
800
}
.article-editor-heading
{
position
:
sticky
;
z-index
:
5
;
top
:
0
;
margin
:
-10px
-10px
14px
;
padding
:
10px
;
background
:
rgba
(
244
,
245
,
249
,
.96
);
border-bottom
:
1px
solid
rgba
(
224
,
222
,
230
,
.8
);
backdrop-filter
:
blur
(
10px
)}
.article-editor-heading
button
:disabled
{
opacity
:
.45
;
cursor
:
not-allowed
}
.article-status-badge.pending
,
.article-side-list
em
.pending
{
color
:
#8c6200
;
background
:
#fff1c9
}
.article-dirty
,
.article-saved
,
.article-unsaved
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
margin-left
:
3px
;
font-size
:
7px
;
font-weight
:
750
}
.article-dirty
{
color
:
#9a6500
}
.article-dirty
i
{
width
:
6px
;
height
:
6px
;
background
:
#e2a322
;
border-radius
:
50%
}
.article-saved
{
color
:
#18805f
}
.article-unsaved
{
color
:
var
(
--muted
)}
.article-field-help
{
color
:
var
(
--muted
);
font-size
:
7px
;
line-height
:
1.45
}
.article-list-controls
{
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
)
80px
;
gap
:
5px
;
margin-bottom
:
7px
}
.article-list-controls
label
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
gap
:
5px
;
padding
:
0
7px
;
color
:
var
(
--muted
);
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
}
.article-list-controls
label
:focus-within
{
color
:
var
(
--primary
);
border-color
:
#cfc0ff
}
.article-list-controls
input
,
.article-list-controls
select
{
min-width
:
0
;
width
:
100%
;
height
:
100%
;
padding
:
0
;
color
:
var
(
--text
);
background
:
transparent
;
border
:
0
;
outline
:
0
;
font-size
:
7px
}
.article-list-controls
select
{
padding
:
0
5px
;
background
:
#fff
;
border
:
1px
solid
var
(
--border
);
border-radius
:
7px
}
.article-publish-blocker
{
grid-column
:
1
/
-1
;
margin
:
0
!important
;
padding
:
0
2px
;
color
:
#9a6500
!important
;
font-size
:
6px
!important
;
line-height
:
1.4
}
.article-batch-card
.article-go-live
[
aria-disabled
=
"true"
]
{
color
:
#8f8b97
;
background
:
#efeff4
;
border
:
1px
solid
#e0dde5
;
cursor
:
not-allowed
}
.article-empty
{
min-height
:
100px
;
height
:
auto
}
.article-batch-card.staged
{
border-color
:
#cfc1fa
;
background
:
#faf8ff
}
.article-preview-link
{
height
:
29px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
gap
:
5px
;
padding
:
0
8px
;
color
:
var
(
--primary
);
background
:
#fff
;
border
:
1px
solid
#d9ceff
;
border-radius
:
7px
;
font-size
:
7px
;
font-weight
:
800
;
text-decoration
:
none
}
.article-sync-check
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
color
:
#16805f
;
font-size
:
7px
;
font-weight
:
750
}
.article-sync-check.pending
{
color
:
#9a6500
}
.article-side-list
>
button
>
span
i
{
display
:
block
;
margin-top
:
4px
;
color
:
var
(
--muted
);
font-size
:
6px
;
font-style
:
normal
;
font-weight
:
650
}
.article-side-list
em
.live
,
.article-status-badge.live
{
color
:
#13765b
;
background
:
#e7f8f2
}
.article-side-list
em
.modified
,
.article-status-badge.modified
{
color
:
#7040c3
;
background
:
#eee8ff
}
.article-side-list
em
.pending-removal
,
.article-status-badge.pending-removal
{
color
:
#a54242
;
background
:
#fff0f0
}
.article-status-badge.draft
{
color
:
#8c6200
;
background
:
#fff7df
}
.article-editor-heading
.article-restore
{
color
:
#6845c5
}
.article-fields
input
:disabled
{
color
:
#8c8994
;
background
:
#f0eff3
;
cursor
:
not-allowed
}
shared/src/index.ts
View file @
bb9d04f4
...
...
@@ -197,6 +197,7 @@ export interface DraftPreviewResult {
}
export
type
ArticleStatus
=
"draft"
|
"published"
;
export
type
ArticleLiveState
=
"draft"
|
"live"
|
"modified"
|
"pending-removal"
;
export
interface
ArticleSeoInput
{
title
?:
string
;
...
...
@@ -223,10 +224,12 @@ export interface ArticleInput {
export
interface
ArticleSummary
extends
Omit
<
ArticleInput
,
"body"
>
{
wordCount
:
number
;
pendingBuild
:
boolean
;
liveState
:
ArticleLiveState
;
}
export
interface
ArticleDocument
extends
ArticleInput
{
wordCount
:
number
;
liveState
:
ArticleLiveState
;
}
export
interface
ArticleListResult
{
...
...
@@ -234,6 +237,7 @@ export interface ArticleListResult {
articles
:
ArticleSummary
[];
pendingBuild
:
boolean
;
pendingCount
:
number
;
pendingLiveCount
:
number
;
lastCompiledAt
?:
string
;
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment