Sun Oct 12 2025
Tucker McKnight <tucker@pangolin.lan>
I'd like to come back to darcs later, when I've come up with a way of making some kind of plugin system for making this work with multiple VCSes. In the meantime, development will be faster if I just focus on git.
2376512fc3745b47f8cb9446f92c85d3fe25488b
62
63
64
const isDarcs = event.target.dataset.vcs === "darcs"
const copiedPrefix = isDarcs ? `darcs pull ${jsVars.baseUrl} -h ` : ""
navigator.clipboard.writeText(`${copiedPrefix}${hash}`).then(() => {
62
navigator.clipboard.writeText(hash).then(() => {
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
if (repoConfig._type === "darcs") {
for (let branch in repoConfig.branches) {
const repoPath = eleventyConfig.dir.output + reposPath + "/" + eleventyConfig.getFilter("slugify")(repoName) + "/branches/" + branch
const originalLocation = cwd + "/" + repoConfig.branches[branch].location
// If it is there, do darcs pull
if (fsImport.existsSync(repoPath + "/_darcs")) {
await exec(`(cd ${repoPath} && darcs pull --no-interactive)`)
} else {
// If it is not there, do darcs clone
await exec(`(cd ${repoPath} && mkdir -p temp; darcs clone ${originalLocation} temp/${branch} --no-working-dir; mv temp/${branch}/_darcs .; rm -R temp)`)
}
}
} else if (repoConfig._type === "git") {
const repoPath = eleventyConfig.dir.output + reposPath + "/" + eleventyConfig.getFilter("slugify")(repoName)
const gitRepoName = eleventyConfig.getFilter("slugify")(repoName) + ".git"
// If it is there, do git pull
if (fsImport.existsSync(repoPath + ".git")) {
// git repos are just in the repos folder, not in their subdir
// create string of commands saying 'git fetch origin branch:branch' for each branch
const fetchCommands = repoConfig.branchesToPull.map(branch => `git fetch origin ${branch}:${branch}`).join('; ')
await exec(`(cd ${eleventyConfig.dir.output + reposPath + "/" + gitRepoName} && ${fetchCommands}; git update-server-info)`)
} else {
// If it is not there, do git clone
const originalLocation = cwd + "/" + repoConfig.location
await exec(`(cd ${eleventyConfig.dir.output + reposPath + "/"} && git clone ${originalLocation} ${gitRepoName} --bare)`)
await exec(`(cd ${eleventyConfig.dir.output + reposPath + "/" + gitRepoName} && git update-server-info)`)
}
if (typeof repoConfig.artifactSteps !== 'undefined') {
// make a temp directory for things to run in
const tempDirName = `temp_${Math.floor(Math.random() * 10000).toString()}`
const tempDir = `${directories.output}${reposPath}${tempDirName}`
const tempDirRepoPath = `${tempDir}/${eleventyConfig.getFilter("slugify")(repoName)}`
const mkdirresult = await exec(`mkdir ${tempDir}`)
await exec(`git clone -s ${directories.output}${eleventyConfig.getFilter("slugify")(repoName)}.git ${tempDirRepoPath}`)
for (let branch of repoConfig.branchesToPull) {
await exec(`(cd ${tempDirRepoPath} && git checkout ${branch})`)
for (let artifactStep of repoConfig.artifactSteps) {
// Run the command for each step in each branch
await exec(`(cd ${tempDirRepoPath} && ${artifactStep.command})`)
// Copy the specified folders from the "from" to the "to" dir
await exec(`cp -r --remove-destination ${tempDirRepoPath}/${artifactStep.copyFrom} ${directories.output}${eleventyConfig.getFilter("slugify")(repoName)}/branches/${eleventyConfig.getFilter("slugify")(branch)}/${artifactStep.copyTo}`)
}
// delete the temp dirs
await exec(`rm -r ${tempDir}`)
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
const repoPath = eleventyConfig.dir.output + reposPath + "/" + eleventyConfig.getFilter("slugify")(repoName)
const gitRepoName = eleventyConfig.getFilter("slugify")(repoName) + ".git"
// If it is there, do git pull
if (fsImport.existsSync(repoPath + ".git")) {
// git repos are just in the repos folder, not in their subdir
// create string of commands saying 'git fetch origin branch:branch' for each branch
const fetchCommands = repoConfig.branchesToPull.map(branch => `git fetch origin ${branch}:${branch}`).join('; ')
await exec(`(cd ${eleventyConfig.dir.output + reposPath + "/" + gitRepoName} && ${fetchCommands}; git update-server-info)`)
} else {
// If it is not there, do git clone
const originalLocation = cwd + "/" + repoConfig.location
await exec(`(cd ${eleventyConfig.dir.output + reposPath + "/"} && git clone ${originalLocation} ${gitRepoName} --bare)`)
await exec(`(cd ${eleventyConfig.dir.output + reposPath + "/" + gitRepoName} && git update-server-info)`)
}
if (typeof repoConfig.artifactSteps !== 'undefined') {
// make a temp directory for things to run in
const tempDirName = `temp_${Math.floor(Math.random() * 10000).toString()}`
const tempDir = `${directories.output}${reposPath}${tempDirName}`
const tempDirRepoPath = `${tempDir}/${eleventyConfig.getFilter("slugify")(repoName)}`
const mkdirresult = await exec(`mkdir ${tempDir}`)
await exec(`git clone -s ${directories.output}${eleventyConfig.getFilter("slugify")(repoName)}.git ${tempDirRepoPath}`)
for (let branch of repoConfig.branchesToPull) {
await exec(`(cd ${tempDirRepoPath} && git checkout ${branch})`)
for (let artifactStep of repoConfig.artifactSteps) {
// Run the command for each step in each branch
await exec(`(cd ${tempDirRepoPath} && ${artifactStep.command})`)
// Copy the specified folders from the "from" to the "to" dir
await exec(`cp -r --remove-destination ${tempDirRepoPath}/${artifactStep.copyFrom} ${directories.output}${eleventyConfig.getFilter("slugify")(repoName)}/branches/${eleventyConfig.getFilter("slugify")(branch)}/${artifactStep.copyTo}`)
// delete the temp dirs
await exec(`rm -r ${tempDir}`)
210
211
212
213
214
215
216
let command = ''
if (config._type === "git") {
command = `git show ${branch}:${filename}`
}
else if (config._type === "darcs") {
command = `darcs show contents ${filename}`
}
210
const command = `git show ${branch}:${filename}`
237
238
239
240
241
242
243
let command = ''
if (config._type === "git") {
command = `git show ${branchName}:README.md`
}
else if (config._type === "darcs") {
command = `darcs show contents README.md`
}
237
const command = `git show ${branchName}:README.md`
10
11
12
13
14
15
16
17
18
19
window.jsVars['cloneDiv'] = `{%- if reposConfig.repos[nav.repoName]._type == "darcs" -%}{% set url = repos[nav.repoName].cloneUrl + (nav.branchName | slugify) %}
<label class="form-label">HTTPS URL</label>
<div class="input-group d-flex flex-nowrap">
<span class="clone overflow-hidden input-group-text">
{{ url }}
</span>
<button data-clone-url="{{url}}" class="btn btn-primary" id="clone-button">Copy</button>
</div>{%- elif reposConfig.repos[nav.repoName]._type == "git" -%}<label class="form-label">HTTPS URL</label>
</div>
{%- endif -%}`;
10
11
window.jsVars['cloneDiv'] = `<label class="form-label">HTTPS URL</label>
</div>`;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
"DarcsConfig": {
"additionalProperties": false,
"description": "A configuration object for a darcs repository.",
"properties": {
"_type": {
"const": "darcs",
"description": "Must be set to `\"darcs\"`.",
"type": "string"
},
"artifactSteps": {
"items": {
"additionalProperties": false,
"properties": {
"command": {
"type": "string"
},
"copyFrom": {
"type": "string"
},
"copyTo": {
"type": "string"
}
},
"required": [
"command",
"copyFrom",
"copyTo"
],
"type": "object"
},
"type": "array"
},
"branches": {
"additionalProperties": {
"additionalProperties": false,
"description": "Each key in this object will be the name that is used for that branch.",
"properties": {
"description": {
"description": "A description of this branch. You may want to clarify what this branch is used for here.",
"type": "string"
},
"location": {
"description": "The absolute path to the repository for this branch.",
"type": "string"
}
},
"required": [
"location"
],
"type": "object"
},
"description": "The branches of this repository to generate pages for. Since darcs doesn't have branches in the same way that other VCSs do, a \"branch\" in this case is an entirely separate repository. So, these \"branches\" are more like repos that are grouped under the same project name. That is why you need to specify a separate location for each branch.",
"type": "object"
},
"defaultBranch": {
"description": "The name of the default branch. Should match one of the keys in {@link DarcsConfig.branches } .",
"type": "string"
},
"languageExtensions": {
"additionalProperties": {
"type": "string"
},
"description": "If your repository has any uncommon file extensions that should be treated like a different type of file, list them here. If you include `{njk: \"html\"}` here, that will tell the syntax highlighter to highlight an `njk` file like an `html` file. The key is the file extension in your code, and the value is the file extension that the syntax highlighter will know about.",
"type": "object"
}
},
"required": [
"_type",
"defaultBranch",
"branches"
],
"type": "object"
},
2
137
"description": "The ReposConfiguration object contains information about your local repositories, like their name and location on your local filesystem. Add repositories to this configuration object to make a static site for them.\n\nThis static site generator works with both git and darcs repositories. Because of the differences between these two version control systems, the configuration for them looks a little different. Both types of configuration objects can be nested underneath the {@link ReposConfiguration.repos } key.\n\nYou will also need to set the {@link ReposConfiguration.baseUrl } to the URL of your live website.",
137
"description": "The ReposConfiguration object contains information about your local repositories, like their name and location on your local filesystem. Add repositories to this configuration object to make a static site for them.\n\nYou will also need to set the {@link ReposConfiguration.baseUrl } to the URL of your live website.",
149
150
151
152
153
154
155
156
157
"anyOf": [
{
"$ref": "#/definitions/GitConfig"
},
{
"$ref": "#/definitions/DarcsConfig"
}
],
"description": "An object containing the configuration for your repositories. Each key in this object is a repository name, and the value has several config options for that repository. The required config options describe the path to the repository and which branches should be pulled. See the specific definitions of {@link GitConfig } and {@link DarcsConfig } for more details about what goes in these configuration objects."
149
150
"$ref": "#/definitions/GitConfig",
"description": "An object containing the configuration for your repositories. Each key in this object is a repository name, and the value has several config options for that repository. The required config options describe the path to the repository and which branches should be pulled. See the specific definition of {@link GitConfig } for more details about what goes in these configuration objects."
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
* This static site generator works with both git and darcs repositories. Because of the
* differences between these two version control systems, the configuration for them
* looks a little different. Both types of configuration objects can be nested underneath
* the {@link ReposConfiguration.repos} key.
*
* "My Darcs Project": {
* _type: "darcs",
* branches: {
* 'main': {
* location: "/home/alice/projects/my_darcs_project",
* description: "Main branch of this project."
* },
* 'drafts': {
* location: "/home/alice/projects/my_darcs_project_drafts/",
* description: "Some things that are a work in progress",
* },
* },
* languageExtensions: {
* "njk": "html",
* },
3
4
5
* "My Git Project": {
* _type: "git",
* branches: ['main', 'develop']
40
41
* definitions of {@link GitConfig} and {@link DarcsConfig} for more details
[repoName: string]: GitConfig | DarcsConfig
40
41
* definition of {@link GitConfig} for more details
[repoName: string]: GitConfig
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
/**
* A configuration object for a darcs repository.
*
* @example
* "My Darcs Project": {
* _type: "darcs",
* baseUrl: "https://repos.tuckerm.us",
* defaultBranch: 'main',
* branches: {
* 'main': {
* location: "/home/alice/projects/my_darcs_project",
* description: "Main branch of this project."
* },
* 'drafts': {
* location: "/home/alice/projects/my_darcs_project_drafts/",
* description: "Some things that are a work in progress",
* },
* },
* languageExtensions: {
* "njk": "html",
* },
* }
*/
export type DarcsConfig = {
/** Must be set to `"darcs"`. */
_type: "darcs",
/**
* The name of the default branch. Should match one of the keys in {@link DarcsConfig.branches}.
* @example defaultBranch: "main"
* */
defaultBranch: string,
/** The branches of this repository to generate pages for. Since darcs doesn't have
* branches in the same way that other VCSs do, a "branch" in this case is an entirely
* separate repository. So, these "branches" are more like repos that are grouped under
* the same project name. That is why you need to specify a separate location for each
* branch.
* @example
* branches: {
* main: {
* location: "/home/alice/projects/my-project/main",
* description: "The main release branch of this project."
* },
* drafts: {
* location: "/home/alice/projects/my-project/drafts",
* description: "Undeployed works-in-progress for this project."
* }
* }
*/
branches: {
/**
* Each key in this object will be the name that is used for that branch.
*/
[branchName: string]: {
/**
* The absolute path to the repository for this branch.
* @example location: "/home/alice/projects/darcs_repo_main"
*/
location: string,
/**
* A description of this branch. You may want to clarify what this branch is used for here.
*/
description?: string,
}
},
/**
* If your repository has any uncommon file extensions that should be treated like a different
* type of file, list them here. If you include `{njk: "html"}` here, that will tell the
* syntax highlighter to highlight an `njk` file like an `html` file. The key is the file
* extension in your code, and the value is the file extension that the syntax highlighter
* will know about.
* @example
* languageExtensions: {
* njk: "html",
* jss: "js",
* madeupformat: "txt"
* }
*/
languageExtensions?: {
[fileExtension: string]: string
},
artifactSteps?: {
command: string,
copyFrom: string,
copyTo: string,
}[]
}
78
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/** @hidden */
const getDarcsDiffsFromPatchText = (patchText: string): Array<DiffInfo> => {
const lines = patchText.split("\n")
const hunks: Array<Hunk> = []
let previousHunk = -1
lines.forEach((line, index) => {
if (line.startsWith("hunk") || index === lines.length - 1) {
if (previousHunk === -1) {
previousHunk = index
return
}
// get diff from previous hunk to this next one
const lastHunk = lines.slice(previousHunk, index + 1) // slice is non-inclusive for the end argument
let lastHunkBefore = lastHunk.filter(line => line.startsWith("-")).map(str => str.replace("-", "")).join("\n")
let lastHunkAfter = lastHunk.filter(line => line.startsWith("+")).map(str => str.replace("+", "")).join("\n")
lastHunkBefore = _.escape(lastHunkBefore)
lastHunkAfter = _.escape(lastHunkAfter)
let filename = lines[previousHunk].replace("hunk ./", "")
const changeObject = Diff.diffWords(lastHunkBefore, lastHunkAfter)
let previousText = ""
let afterText = ""
changeObject.forEach((obj) => {
if (!obj.added && !obj.removed) {
previousText = previousText + obj.value
afterText = afterText + obj.value
}
if (obj.added) {
afterText = afterText + "<mark>" + obj.value + "</mark>"
}
if (obj.removed) {
previousText = previousText + "<mark>" + obj.value + "</mark>"
}
})
const regex = RegExp(/(.*) ([0-9]+)$/)
const matches = filename.match(regex)
const file = matches[1]
const lineNumber: number = parseInt(matches[2])
hunks.push({
file,
lineNumber,
previousText,
afterText,
})
previousHunk = index
}
})
return hunks
}
if (config._type === "darcs") {
return config.branches[branchName].location
}
else if (config._type === "git") {
return config.location
}
getDarcsDiffsFromPatchText,
85
return config.location
1
type DarcsConfig,
1
20
21
22
23
24
25
26
const getBranchNames = (repoConfig: DarcsConfig | GitConfig): Array<string> => {
if (repoConfig._type === 'darcs') {
return Object.keys(repoConfig.branches)
}
else if (repoConfig._type === 'git') {
return repoConfig.branchesToPull
}
20
21
const getBranchNames = (repoConfig: GitConfig): Array<string> => {
return repoConfig.branchesToPull
1
2
3
4
5
export default {
cloneUrl: (baseUrl: string, repoName: string) => {
return `${baseUrl}/${repoName.toLowerCase().replaceAll(" ", "-")}/branches/`
}
}
1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import util from 'util'
import childProcess from 'child_process'
const exec = util.promisify(childProcess.exec)
import {getDarcsDiffsFromPatchText} from '../../helpers.ts'
export const getFileList = async(repoName: string, branchName: string, repoLocation: string) => {
const command = "darcs show files"
const result = await exec(`(cd ${repoLocation} && ${command})`)
const files = result.stdout.split("\n").filter(item => item.length > 0 && item != ".")
return files
}
export const getBranchInfo = async (repoName: string, branchName: string, repoLocation: string) => {
const patches = new Map()
const totalPatchesCountRes = await exec(`(cd ${repoLocation} && darcs log --count)`)
const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
let hunkRegex = RegExp(/^ *hunk /)
// Get 100 patches at a time and parse those
for (let i = 1; i <= totalPatchesCount; i = i + 100) {
let patchesSubsetRes = await exec(`(cd ${repoLocation} && darcs log --index=${i}-${i+100} -v)`)
let patchesSubset = patchesSubsetRes.stdout.split("\n")
do {
const nextPatchStart = patchesSubset.findIndex((line, index) => {
return (index > 0 && line.startsWith("patch ")) || index === patchesSubset.length - 1
})
const isEndOfFile = nextPatchStart === patchesSubset.length - 1
const currentPatch = patchesSubset.slice(0, isEndOfFile ? nextPatchStart : nextPatchStart - 1)
patchesSubset = patchesSubset.slice(nextPatchStart)
const hash = currentPatch[0].replace("patch ", "").trim()
const author = currentPatch[1].replace("Author: ", "").trim()
const date = currentPatch[2].replace("Date: ", "").trim()
const name = currentPatch[3].replace(" * ", "").trim()
const diffStart = currentPatch.findIndex((line) => {
return line.match(hunkRegex)
})
const description = currentPatch.slice(5, diffStart).map(str => str.replace(" ", "")).join("\n").trim()
const diffs = getDarcsDiffsFromPatchText(currentPatch.slice(diffStart).map(str => str.trimStart()).join("\n"))
patches.set(hash, {
name,
description,
author,
date,
hash,
diffs,
})
} while (patchesSubset.length > 1)
}
return Array.from(patches.values())
}
export const getFileLastTouchInfo = async (repoName: string, branchName: string, filename: string, repoLocation: string) => {
const command = `darcs annotate --machine-readable ${filename}`
const res = await exec(`(cd ${repoLocation} && ${command})`)
const output = res.stdout
const outputLines = output.split("\n").map((line) => {
return line.split(' ')[0]
})
return outputLines.map((line) => {
return {sha: line, author: ''}
})
}
1
1
2
import darcsHelpers from './darcs/helpers.ts'
darcs: darcsHelpers
1
3
4
5
6
7
import {
getBranchInfo as getDarcsBranchInfo,
getFileList as getDarcsFileList,
getFileLastTouchInfo as getDarcsFileLastTouchInfo,
} from './darcs/operations.ts'
3
24
25
26
27
28
darcs: {
getBranchInfo: getDarcsBranchInfo,
getFileList: getDarcsFileList,
getFileLastTouchInfo: getDarcsFileLastTouchInfo,
}
24
10
11
12
13
14
15
16
17
18
{% if reposConfig.repos[patchInfo.repoName]._type == "darcs" %}
<div class="input-group mb-3 d-flex flex-nowrap">
<span id="clone-command" class="clone input-group-text overflow-hidden">
{% set url = [reposConfig.baseUrl, reposPath, "/", patchInfo.repoName | slugify, "/branches/", patchInfo.branchName | slugify] | join | url %}
darcs pull {{ url }} -h {{patchInfo.patch.hash}}
</span>
<button class="btn btn-primary" id="clone-button" onclick="copyCommand()">Copy</button>
</div>
{% endif %}
10
24
25
26
27
28
29
{% if reposConfig.repos[branch.repoName]._type == "darcs" %}
<button data-hash="{{patch.hash}}" data-vcs="darcs" class="copy-btn btn btn-sm btn-outline-primary ms-2">
<i class="bi-copy bi me-1"></i>darcs pull {{patch.hash | truncate(6, true, "")}}
</button>
{% elif reposConfig.repos[branch.repoName]._type == "git" %}
{% endif %}
24