Tucker McKnight <tucker@pangolin.lan> | Mon Nov 10 2025
Switching some more things over to the new Repository type Also removed anything that prints out commits in order, as that needs to be re-thought for the new format. So I left some todos in there.
92 93 94 95 96 97
);
eleventyConfig.addFilter("getDirectoryContents", (repo, branch, dirPath) => {
return reposData[repo].branches[branch].files.filter(file => file.startsWith(dirPath) && file !== dirPath)
})
eleventyConfig.addFilter("getRelativePath", (currentDir, fullFilePath) => {92 93 94 95 96 97
);
eleventyConfig.addFilter("getDirectoryContents", (repo, branch, dirPath) => {
return reposData.find(current => current.name === repo).branches[branch].fileList.filter(file => file.startsWith(dirPath) && file !== dirPath)
})
eleventyConfig.addFilter("getRelativePath", (currentDir, fullFilePath) => {109 110 111 112 113 114
return lineNumbers
})
eleventyConfig.addFilter("highlightCode", (code, language) => {
const highlighter = eleventyConfig?.javascript?.functions?.highlight
if (highlighter) {
return highlighter(language, code)109 110 111 112 113 114
return lineNumbers
})
eleventyConfig.addFilter("highlightCode", (code: string, language: string) => {
const highlighter = eleventyConfig?.javascript?.functions?.highlight
if (highlighter) {
return highlighter(language, code)185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
return repoOperations[config._type].getFileLastTouchInfo(repo, branch, filename, location)
})
eleventyConfig.addFilter("isDirectory", (filename, repoName, branchName) => {
const files = reposData[repoName].branches[branchName].files
const isDirectory = files.some((testFile) => {
return testFile.startsWith(filename + '/') && (testFile !== filename)
})
return isDirectory
})
eleventyConfig.addAsyncFilter("getFileContents", async (repo, branch, filename) => {
const location = getLocation(reposConfiguration, branch, repo)
const config = reposConfiguration.repos[repo]
const command = `git show ${branch}:${filename}`
const res = await exec(`(cd ${location} && ${command})`)
return res.stdout185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
return repoOperations[config._type].getFileLastTouchInfo(repo, branch, filename, location)
})
eleventyConfig.addFilter("isDirectory", (filename: string, repoName: string, branchName: string) => {
const repo = reposData.find(repo => repo.name === repoName)
const files = repo.branches.find(branch => branch.name === branchName).fileList
const isDirectory = files.some((testFile) => {
return testFile.startsWith(filename + '/') && (testFile !== filename)
})
return isDirectory
})
eleventyConfig.addAsyncFilter("getFileContents", async (repo: string, branch: string, filename: string) => {
const location = getLocation(reposConfiguration, branch, repo)
const command = `git show ${branch}:${filename}`
const res = await exec(`(cd ${location} && ${command})`)
return res.stdout215 216 217 218 219 220
eleventyConfig.addAsyncFilter("getReadMe", async (repoName, branchName) => {
const location = getLocation(reposConfiguration, branchName, repoName)
const config = reposConfiguration.repos[repoName]
const command = `git show ${branchName}:README.md`
try {
const res = await exec(`(cd ${location} && ${command})`)215 216 217 218 219
eleventyConfig.addAsyncFilter("getReadMe", async (repoName, branchName) => {
const location = getLocation(reposConfiguration, branchName, repoName)
const command = `git show ${branchName}:README.md`
try {
const res = await exec(`(cd ${location} && ${command})`)335 336 337 338 339 340
nav: {
repoName: (data) => data.branchInfo.repoName,
branchName: (data) => data.branchInfo.branchName,
}
},
navTab: "files",
}335 336 337 338 339 340 341 342 343 344 345 346 347 348
nav: {
repoName: (data) => data.branchInfo.repoName,
branchName: (data) => data.branchInfo.branchName,
},
currentRepo: (data) => reposData.find(repo => {
return repo.name === data.branchInfo.repoName
}),
currentBranch: (data) => reposData.find(repo => {
return repo.name === data.branchInfo.repoName
}).branches.find(branch => {
return branch.name === data.branchInfo.branchName
})
},
navTab: "files",
}361 362 363 364 365 366
nav: {
repoName: (data) => data.branch.repoName,
branchName: (data) => data.branch.branchName,
}
},
navTab: "landing",
}361 362 363 364 365 366 367 368 369 370 371 372 373 374
nav: {
repoName: (data) => data.branch.repoName,
branchName: (data) => data.branch.branchName,
},
currentRepo: (data) => reposData.find(repo => {
return repo.name === data.branch.repoName
}),
currentBranch: (data) => reposData.find(repo => {
return repo.name === data.branch.repoName
}).branches.find(branch => {
return branch.name === data.branch.branchName
}),
},
navTab: "landing",
}397 398 399 400 401 402
// PATCH.NJK
const patchTemplate = fsImport.readFileSync(`${import.meta.dirname}/templates/patch.njk`).toString()
const flatPatchesData = await flatPatches(reposConfiguration)
eleventyConfig.addTemplate(
`repos/patch.njk`,
topLayoutPartial + patchTemplate + bottomLayoutPartial,397 398 399 400 401 402
// PATCH.NJK
const patchTemplate = fsImport.readFileSync(`${import.meta.dirname}/templates/patch.njk`).toString()
const flatPatchesData = await flatPatches(reposData)
eleventyConfig.addTemplate(
`repos/patch.njk`,
topLayoutPartial + patchTemplate + bottomLayoutPartial,428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
// htmlBaseUrl is a function defined by the 11ty RSS plugin.
// Skip this virtual template if the 11ty RSS plugin is not being used.
let rssAvailable = false
if (eleventyConfig?.javascript?.functions?.htmlBaseUrl) {
rssAvailable = true
const feedTemplate = fsImport.readFileSync(`${import.meta.dirname}/templates/feed.njk`).toString()
eleventyConfig.addTemplate(
`repos/feed.njk`,
feedTemplate,
{
pagination: {
data: "branches",
size: 1,
alias: "branch",
},
permalink: (data) => {
const repoName = data.branch.repoName
const branchName = data.branch.branchName
return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/branches/${eleventyConfig.getFilter("slugify")(branchName)}/patches.xml`
},
eleventyExcludeFromCollections: true,
}
)
}
// This is used to show/hide the RSS feed link on the landing page.
eleventyConfig.addGlobalData('rssAvailable', rssAvailable)428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463
// htmlBaseUrl is a function defined by the 11ty RSS plugin.
// Skip this virtual template if the 11ty RSS plugin is not being used.
let rssAvailable = false
// if (eleventyConfig?.javascript?.functions?.htmlBaseUrl) {
// rssAvailable = true
// const feedTemplate = fsImport.readFileSync(`${import.meta.dirname}/templates/feed.njk`).toString()
// eleventyConfig.addTemplate(
// `repos/feed.njk`,
// feedTemplate,
// {
// pagination: {
// data: "branches",
// size: 1,
// alias: "branch",
// },
// permalink: (data) => {
// const repoName = data.branch.repoName
// const branchName = data.branch.branchName
// return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/branches/${eleventyConfig.getFilter("slugify")(branchName)}/patches.xml`
// },
// eleventyComputed: {
// currentRepo: (data) => reposData.find(repo => {
// return repo.name === data.branch.repoName
// }),
// currentBranch: (data) => reposData.find(repo => {
// return repo.name === data.branch.repoName
// }).branches.find(branch => {
// return branch.name === data.branch.branchName
// }),
// },
// eleventyExcludeFromCollections: true,
// }
// )
// }
// This is used to show/hide the RSS feed link on the landing page.
eleventyConfig.addGlobalData('rssAvailable', rssAvailable)82 83 84 85 86 87 88 89 90
<div class="input-group input-group-sm">
<span class="input-group-text">Branch</span>
<select class="form-select" onchange="selectBranch(this)" aria-label="Repository branch selector">
{% for branch in branches %}
{% if branch.repoName == nav.repoName %}
<option value="{{branch.repoName | slugify}},{{branch.branchName | slugify}},{{nav.path}}" {% if branch.branchName == nav.branchName %}selected{% endif %}>{{branch.branchName}}</option>
{% endif %}
{% endfor %}
</select>
</div>82 83 84 85 86 87 88
<div class="input-group input-group-sm">
<span class="input-group-text">Branch</span>
<select class="form-select" onchange="selectBranch(this)" aria-label="Repository branch selector">
{% for branch in repos[nav.repoName].branches %}
<option value="{{branch.repoName | slugify}},{{branch.branchName | slugify}},{{nav.path}}" {% if branch.branchName == nav.branchName %}selected{% endif %}>{{branch.branchName}}</option>
{% endfor %}
</select>
</div>33 34 35 36 37 38
},
"branchesToPull": {
"items": {
"type": "string"
},
"type": "array"
},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
},
"branchesToPull": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"max": {
"type": "number"
},
"regex": {
"type": "string"
}
},
"required": [
"regex",
"max"
],
"type": "object"
}
]
},
"type": "array"
},51 52 53 54 55
},
"location": {
"type": "string"
}
},
"required": [51 52 53 54 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
},
"location": {
"type": "string"
},
"tags": {
"items": {
"anyOf": [
{
"type": "string"
},
{
"additionalProperties": false,
"properties": {
"max": {
"type": "number"
},
"regex": {
"type": "string"
}
},
"required": [
"regex",
"max"
],
"type": "object"
}
]
},
"type": "array"
}
},
"required": [0 1 2 3 4 5 6 7 8 9 10 11
let cachedBranches = null
export default (repos) => {
if (cachedBranches !== null) { return cachedBranches }
cachedBranches = Object.keys(repos).flatMap((repoName) => {
return Object.keys(repos[repoName].branches).map((branchName) => {
return {
branchName,
repoName,
}
})
})0 1 2 3 4 5 6 7 8 9 10 11 12 13
import { type Repository } from "./dataTypes.ts"
let cachedBranches = null
export default (repos: Array<Repository>) => {
if (cachedBranches !== null) { return cachedBranches }
cachedBranches = repos.flatMap((repo) => {
return repo.branches.map((branch) => {
return {
branchName: branch.name,
repoName: repo.name,
}
})
})50 51 52 53 54 55
location: string,
description?: string,
defaultBranch: string,
branchesToPull: Array<string>,
languageExtensions?: {
[fileExtension: string]: string
},50 51 52 53 54 55 56
location: string,
description?: string,
defaultBranch: string,
branchesToPull: Array<string | {regex: string, max: number}>,
tags?: Array<string | {regex: string, max: number}>,
languageExtensions?: {
[fileExtension: string]: string
},4 5 6 7 8 9 10 11 12 13 14 15 16
defaultBranch: string,
branches: Array<{
name: string,
description?: string,
head: string,
}>,
tags: Array<{
name: string,
sha: string,
}>,
commits: Map<string, {
message: string,
author: string,
date: Date,4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
defaultBranch: string,
branches: Array<{
name: string,
head: string,
fileList: Array<string>,
}>,
tags: Array<{
name: string,
sha: string,
fileList: Array<string>,
}>,
commits: Map<string, {
hash: string,
message: string,
author: string,
date: Date,24 25 26 27 28 29 30
}>
}>,
}
export type BranchInfo = {
files: Array<string>,
patches: Array<any>
}24 25
}>
}>,
}0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
import repos from './repos.ts'
let cachedFlatFiles = null
export default (repos) => {
if (cachedFlatFiles !== null) { return cachedFlatFiles }
cachedFlatFiles = Object.keys(repos).flatMap((repoName) => {
return Object.keys(repos[repoName].branches).flatMap((branchName) => {
return repos[repoName].branches[branchName].files.map((file) => {
return {
file,
branchName,
repoName,
}
})
})0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
import { type Repository } from "./dataTypes.ts"
let cachedFlatFiles = null
export default (repos: Array<Repository>) => {
if (cachedFlatFiles !== null) { return cachedFlatFiles }
cachedFlatFiles = repos.flatMap((repo) => {
return repo.branches.flatMap((branch) => {
return branch.fileList.map((file) => {
return {
file,
branchName: branch.name,
repoName: repo.name,
}
})
})0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
let cachedFlatPatches = null
export default async (repos) => {
if (cachedFlatPatches !== null) { return cachedFlatPatches }
cachedFlatPatches = Object.keys(repos).flatMap((repoName) => {
return Object.keys(repos[repoName].branches).flatMap((branchName) => {
return repos[repoName].branches[branchName].patches.map((patch) => {
return {
patch,
branchName,
repoName,
}
})
})
})
0 1 2 3 4 5 6 7 8 9 10 11
import { type Repository } from "./dataTypes.ts"
let cachedFlatPatches = null
export default async (repos: Array<Repository>) => {
if (cachedFlatPatches !== null) { return cachedFlatPatches }
cachedFlatPatches = repos.flatMap((repo) => {
return repo.branches.flatMap((branch) => {
return [] // todo implement this with new commits format
})
})
0 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 {
type GitConfig,
} from './configTypes.ts'
import {type BranchInfo} from './dataTypes.ts'
import repoOperations from './vcses/operations.ts'
import { getBranchInfo } from './vcses/git/operations.ts'
import { getLocation} from './helpers.ts'
import repoHelpers from './vcses/helpers.ts'
type BranchInfoTuple = [string, BranchInfo]
type BranchObject = {
[key: string]: BranchInfo
}
type RepoObjectTuple = [string, BranchObject]
type RepoObject = {
[key: string]: {
branches: BranchObject,
cloneUrl: string,
}
}
const getBranchNames = (repoConfig: GitConfig): Array<string> => {
return repoConfig.branchesToPull
}
let cachedRepos = null
const repos: (reposConfig: any) => Promise<RepoObject> = async (reposConfig) => {
if (cachedRepos !== null) { return cachedRepos }
const repoNames = Object.keys(reposConfig.repos)
const reposTuples: RepoObjectTuple[] = await Promise.all(repoNames.map(async (repoName): Promise<RepoObjectTuple> => {
const vcs = reposConfig.repos[repoName]._type
const branchNames = getBranchNames(reposConfig.repos[repoName])
const branchTuples: BranchInfoTuple[] = await Promise.all(branchNames.map(async (branchName): Promise<BranchInfoTuple> => {
const repoLocation = getLocation(reposConfig, branchName, repoName)
const files = await repoOperations[vcs].getFileList(repoName, branchName, repoLocation)
const patches = await getBranchInfo(branchName, repoLocation)
return [branchName, {
files,
patches,
}]
}))
const branchesObject: BranchObject = {}
for (let branchTuple of branchTuples) {
branchesObject[branchTuple[0]] = branchTuple[1]
}
return [repoName, branchesObject]
}))
const reposObject: RepoObject = {}
for (let repoTuple of reposTuples) {
const repoName = repoTuple[0]
const repoType = reposConfig.repos[repoName]._type
reposObject[repoName] = {
branches: repoTuple[1],
cloneUrl: repoHelpers[repoType].cloneUrl(reposConfig.baseUrl + (reposConfig.path || ""), repoName)
}
}
cachedRepos = reposObject
return reposObject
}
export default repos0 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
import {
type ReposConfiguration,
type GitConfig,
} from './configTypes.ts'
import { type Repository} from './dataTypes.ts'
import { addBranchToCommitsMap } from './vcses/git/operations.ts'
import { getLocation} from './helpers.ts'
const getBranchNames = (repoConfig: GitConfig): Array<string> => {
return repoConfig.branchesToPull.map((branch) => {
if (typeof branch === "string") {
return branch
}
else {
return "" // todo
}
})
}
let cachedRepos = null
const repos: (reposConfig: ReposConfiguration) => Promise<Array<Repository>> = async (reposConfig) => {
if (cachedRepos !== null) { return cachedRepos }
const repoNames = Object.keys(reposConfig.repos)
const repos: Array<Repository> = []
for (const repoName of repoNames) {
const branchNames = getBranchNames(reposConfig.repos[repoName])
const commits: Repository['commits'] = new Map()
for (const branchName of branchNames) {
const repoLocation = getLocation(reposConfig, branchName, repoName)
await addBranchToCommitsMap(branchName, repoLocation, commits)
}
repos.push({
name: repoName,
branches: branchNames.map((branchName) => {
return {
name: branchName,
head: "", // todo
fileList: [], //todo
}
}),
cloneUrl: "",
defaultBranch: "main",
tags: [],
commits,
})
}
return repos
}
export default repos37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
return Array.from(fileSet)
}
export const getBranchInfo = async (branchName: string, repoLocation: string) => {
const patches: Map<string, {
name: string,
description: string,
author: string,
date: string,
hash: string,
diffs: ReturnType<Repository['commits']['get']>['diffs'],
}> = new Map()
const totalPatchesCountRes = await exec(`(cd ${repoLocation} && git rev-list --count ${branchName})`)
const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
for (let i = 0; i < totalPatchesCount; i = i + 10) {37 38 39 40 41 42
return Array.from(fileSet)
}
export const addBranchToCommitsMap = async(branchName: string, repoLocation: string, commits: Repository['commits']): Promise<void> => {
const totalPatchesCountRes = await exec(`(cd ${repoLocation} && git rev-list --count ${branchName})`)
const totalPatchesCount = parseInt(totalPatchesCountRes.stdout)
for (let i = 0; i < totalPatchesCount; i = i + 10) {61 62 63 64 65
gitLogSubset = gitLogSubset.slice(nextPatchStart)
const hash = currentPatch[0].replace("commit ", "").trim()
let author: string, date: string
[1, 2, 3].forEach((lineNumber) => {
if (currentPatch[lineNumber].startsWith("Author")) {61 62 63 64 65 66 67 68 69 70 71
gitLogSubset = gitLogSubset.slice(nextPatchStart)
const hash = currentPatch[0].replace("commit ", "").trim()
// exit early if the commits map already includes this hash
if (commits.has(hash)) {
break
}
let author: string, date: string
[1, 2, 3].forEach((lineNumber) => {
if (currentPatch[lineNumber].startsWith("Author")) {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 99 100 101
return line.startsWith("diff ")
})
// Git log is indent four spaces by default -- remove those.
const commitMessage = currentPatch.slice(4, diffStart).map(str => str.replace(" ", ""))
const name = commitMessage[0].trim()
const description = commitMessage.slice(1, commitMessage.length - 1).filter((line) => {
// git log --porcelain output adds these "Ignore-this:" lines and I'm not sure what they are
return !line.startsWith('Ignore-this: ')
}).join("\n").trim()
const diffs = getGitDiffsFromPatchText(currentPatch.slice(diffStart).join("\n"))
patches.set(hash, {
name,
description,
author,
date,
hash,
diffs,
})
} while (gitLogSubset.length > 1)
}
return Array.from(patches.values())
}
export const getFileLastTouchInfo = async (repo, branch, filename, repoLocation) => {
const regex = RegExp(".* [0-9]+ [0-9]+")
const command = `git blame --porcelain ${branch} ${filename}`
const res = await exec(`(cd ${repoLocation} && ${command})`)74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
return line.startsWith("diff ")
})
// Git log is indent four spaces by default -- remove those.
const commitMessage = currentPatch.slice(4, diffStart).map(str => str.replace(" ", "")).filter((line) => {
// git log --porcelain output adds these "Ignore-this:" lines and I'm not sure what they are
return !line.startsWith('Ignore-this: ')
}).join("\n").trim()
const diffs = getGitDiffsFromPatchText(currentPatch.slice(diffStart).join("\n"))
commits.set(hash, {
hash,
message: commitMessage,
author,
date: new Date(date),
diffs,
parent: "todo",
})
} while (gitLogSubset.length > 1)
}
}
export const getFileLastTouchInfo = async (repo: string, branch: string, filename: string, repoLocation: string) => {
const regex = RegExp(".* [0-9]+ [0-9]+")
const command = `git blame --porcelain ${branch} ${filename}`
const res = await exec(`(cd ${repoLocation} && ${command})`)1 2 3 4 5 6 7 8 9 10
getFileList as getGitFileList,
getFileLastTouchInfo as getGitFileLastTouchInfo,
} from './git/operations.ts'
import {type BranchInfo} from '../dataTypes.ts'
type RepoOperationsType = {
[vcs: string]: {
getFileList: (repoName: string, branchName: string, repoLocation: string) => Promise<BranchInfo['files']>,
getFileLastTouchInfo: (repoName: string, branchName: string, filename: string, repoLocation: string) => Promise<Array<{sha: string, author: string}>>,
}
}1 2 3 4 5 6 7 8 9
getFileList as getGitFileList,
getFileLastTouchInfo as getGitFileLastTouchInfo,
} from './git/operations.ts'
type RepoOperationsType = {
[vcs: string]: {
getFileList: (repoName: string, branchName: string, repoLocation: string) => Promise<Array<string>>,
getFileLastTouchInfo: (repoName: string, branchName: string, filename: string, repoLocation: string) => Promise<Array<{sha: string, author: string}>>,
}
}0 1 2 3 4 5
<div class="row">
<div class="col">
<ul class="list-group">
{% set files = repos[branchInfo.repoName].branches[branchInfo.branchName].files | topLevelFilesOnly('') %}
{% for file in files %}
<li class="list-group-item">
{% if file.isDirectory %}0 1 2 3 4 5
<div class="row">
<div class="col">
<ul class="list-group">
{% set files = currentBranch.fileList | topLevelFilesOnly('') %}
{% for file in files %}
<li class="list-group-item">
{% if file.isDirectory %}0 1 2 3
<ul>
{% for repoName, options in repos %}
<li><a href="{{reposPath}}/{{repoName | slugify}}/branches/{{reposConfig.repos[repoName].defaultBranch}}">{{repoName}}</a></li>
{% endfor %}
</ul>0 1 2 3
<ul>
{% for repo in repos %}
<li><a href="{{reposPath}}/{{repo.name | slugify}}/branches/{{reposConfig.repos[repo.name].defaultBranch}}">{{repo.name}}</a></li>
{% endfor %}
</ul>14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
</div>
{% endif %}
</div>
{% for patch in repos[branch.repoName].branches[branch.branchName].patches | batch(3) | first %}
<div class="card mt-2 mb-4">
<div class="card-body">
<a href="{{reposPath}}/{{branch.repoName | slugify}}/branches/{{branch.branchName | slugify}}/patches/{{patch.hash}}" class="text-primary d-inline-block card-title fs-5">{{patch.name}}</a>
<p class="card-subtitle fs-6 mb-2 text-body-secondary">{{patch.date}}</p>
<p class="card-subtitle fs-6 mb-2 text-body-secondary">{{patch.author}}</p>
<p class="card-text">{{patch.description | truncate(150)}}</p>
</div>
<div class="card-footer">
<button data-hash="{{patch.hash}}" data-vcs="git" class="copy-btn btn btn-sm btn-outline-primary">
{{patch.hash | truncate(8, true, "")}} <i class="bi-copy bi me-1"></i>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>14 15 16 17 18
</div>
{% endif %}
</div>
</div>
</div>
</div>