Make all pages have both /branch and /tag URLs

2a54fe9bc0a1940383f0414150c8c36577508e1f

Tucker McKnight <tmcknight@instructure.com> | Sun May 17 2026

Make all pages have both /branch and /tag URLs

Make the "branches" dropdown menu also have tags, rename it to
"rel dropdown."

Also use navhelper for links in more places

Note: still has a "todo" for filling in the file contents in
      src/vcses/git/operations.ts.
frontend/main.js:1
Before
0
1
2
import branchesListItems from '../dist/js_templates/common/branchesListItems.js'

const setCheckbox = window.setCheckbox
const currentTheme = window.currentTheme
After
0
1
2
import {branchesAndTagsResults} from '../dist/js_templates/common/branchesListItems.js'

const setCheckbox = window.setCheckbox
const currentTheme = window.currentTheme
frontend/main.js:151
Before
150
151
152
153
154
155
156
  }
})

showBranchesResults(searchBox.value)

const pageLoadLastTouch = document.getElementById("showLastTouch")
if (pageLoadLastTouch) {
  setShowLastTouch(pageLoadLastTouch.checked)
After
150
151
152


153
154
  }
})

⁣
⁣
const pageLoadLastTouch = document.getElementById("showLastTouch")
if (pageLoadLastTouch) {
  setShowLastTouch(pageLoadLastTouch.checked)
js_templates/commits.ts:49
Before
48
49
50
51
52
53
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${data.reposPath}/${slugify(data.patchPage.repoName)}/${data.patchPage.type}/${data.patchPage.relName}/commits/page${pageObj.pageNumber}`
            }, pageObj.pageNumber)
          )
        })
After
48
49
50
51
52
53
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${nav.commits()}/page${pageObj.pageNumber}`,
            }, pageObj.pageNumber)
          )
        })
js_templates/commits.ts:63
Before
62
63
64
65
66
67
        commit.isMerge ? m('span', {class: 'badge rounded-pill bg-primary me-1'}, 'merge') : null,
        m('a', {
          class: "fs-5",
          href: `${data.reposPath}/${slugify(data.patchPage.repoName)}/${data.patchPage.type}/${slugify(data.patchPage.relName)}/commits/${commit.hash}`,
        }, commit.message.split('\n').slice(0, 1)),
        m('br'),
        m('span', date(commit.date)),
After
62
63
64
65
66
67
        commit.isMerge ? m('span', {class: 'badge rounded-pill bg-primary me-1'}, 'merge') : null,
        m('a', {
          class: "fs-5",
          href: nav.commit(commit.hash),
        }, commit.message.split('\n').slice(0, 1)),
        m('br'),
        m('span', date(commit.date)),
js_templates/commits.ts:83
Before
82
83
84
85
86
87
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${data.reposPath}/${slugify(data.patchPage.repoName)}/${data.patchPage.type}/${data.patchPage.relName}/commits/page${pageObj.pageNumber}`
            }, pageObj.pageNumber)
          )
        })
After
82
83
84
85
86
87
          return m('li', {class: "page-item"},
            m('a', {
              class: `page-link ${pageObj.pageNumber === data.patchPage.pageNumber ? 'active' : ''}`,
              href: `${nav.commits()}/page${pageObj.pageNumber}`,
            }, pageObj.pageNumber)
          )
        })
js_templates/common/branchesListItems.ts:1
Before
0

1


2
3

4
5
6
7
8
export default (
⁣
  branches: Array<{name: string, href: string, date: string}>,
⁣
⁣
  defaultBranch: string,
  currentBranch: string,
⁣
  sortBy: 'name' | 'date',
): string => {
  return branches.sort((a, b) => {
    let comparison = 0
    if (a[sortBy] < b[sortBy]) { comparison = -1 }
    if (a[sortBy] > b[sortBy]) { comparison = 1 }
After
0
1
2
3
4
5
6
7
8
9
10
11
12
export const branchesAndTagsResults = (
  rels: {
    branches: Array<{name: string, href: string, date: string}>,
    tags: Array<{name: string, href: string, date: string}>,
  },
  defaultBranch: string,
  currentRef: string,
  currentRefType: "branch" | "tag",
  sortBy: 'name' | 'date',
): string => {
  return `<div class="branch">` + rels.branches.sort((a, b) => {
    let comparison = 0
    if (a[sortBy] < b[sortBy]) { comparison = -1 }
    if (a[sortBy] > b[sortBy]) { comparison = 1 }
js_templates/common/branchesListItems.ts:13
Before
12
13
14
15
16
17

    return comparison
  }).map((branch) => {
    const currentBadge = currentBranch === branch.name
      ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>'
      : ''
After
12
13
14
15
16
17

    return comparison
  }).map((branch) => {
    const currentBadge = currentRef === branch.name && currentRefType === "branch"
      ? '<div class="badge rounded-pill bg-secondary mx-1">current</div>'
      : ''
js_templates/common/htmlPage.ts:2
Before
1
2
3
4
5
6
import { type ReposConfiguration } from '../../src/configTypes.ts'
import { type Repository } from '../../src/dataTypes.ts'
import { NavHelper } from '../helpers/nav.ts'
import branchesListItems from './branchesListItems.ts'

export default async (reposConfig: ReposConfiguration, eleventyConfig: any, data: any, pageContent: any) => {
  // this still necessary?
After
1
2
3
4
5
6
import { type ReposConfiguration } from '../../src/configTypes.ts'
import { type Repository } from '../../src/dataTypes.ts'
import { NavHelper } from '../helpers/nav.ts'
import relDropDown from './relDropDown.ts'

export default async (reposConfig: ReposConfiguration, eleventyConfig: any, data: any, pageContent: any) => {
  // this still necessary?
js_templates/common/htmlPage.ts:22
Before
21
22
23

24
25
26
27
28
29

30
31
32
33
34
35
36
37

38
    currentRefType: data.currentRefType,
  })

⁣
  const branchesWithHrefs = repo.branches.map((branch) => {
    return {
      name: branch.name,
      href: nav.refHome({refName: branch.name, refType: 'branch'}) + '/' + data.nav.path,
      date: repo.commits.get(branch.sha).date.toISOString(),
    }
⁣
  }).concat(repo.tags.map((tag) => {
    return {
      name: tag.name,
      href: nav.refHome({refName: tag.name, refType: 'tag'}) + '/' + data.nav.path,
      date: repo.commits.get(tag.sha).date.toISOString(),
    }
  }))

⁣
  return {
    rootPath: nav.rootPath(),
After
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
    currentRefType: data.currentRefType,
  })

  const branchesWithHrefs = {
    branches: repo.branches.map((branch) => {
      return {
        name: branch.name,
        href: nav.refHome({refName: branch.name, refType: 'branch'}) + '/' + data.nav.path,
        date: repo.commits.get(branch.sha).date.toISOString(),
      }
    }),
    tags: (repo.tags.map((tag) => {
      return {
        name: tag.name,
        href: nav.refHome({refName: tag.name, refType: 'tag'}) + '/' + data.nav.path,
        date: repo.commits.get(tag.sha).date.toISOString(),
      }
    }))
  }

  return {
    rootPath: nav.rootPath(),
js_templates/file.ts:23
Before
22
23
24
25
26
27
      m('div', {class: "col"},
        m('p', [
          'Files snapshot from ',
          m('span', {class: "font-monospace"}, fileInfo.branchName)
        ])
      )
    ),
After
22
23
24
25
26
27
      m('div', {class: "col"},
        m('p', [
          'Files snapshot from ',
          m('span', {class: "font-monospace"}, fileInfo.refName)
        ])
      )
    ),
js_templates/file.ts:31
Before
30
31
32
33
34
35
      m('div', {class: "col"},
        m('h3', [
          m('span', {class: "bezel-gray p-1 my-1 d-inline-block"},
            m('a', {href: `${data.reposPath}/${fileInfo.repoName}/branch/${fileInfo.branchName}/files`}, './')
          ),
          fileInfo.file.split('/').map((dir, index, arr) => {
            if (index === arr.length - 1) {
After
30
31
32
33
34
35
      m('div', {class: "col"},
        m('h3', [
          m('span', {class: "bezel-gray p-1 my-1 d-inline-block"},
            m('a', {href: `${data.reposPath}/${fileInfo.repoName}/${fileInfo.type}/${fileInfo.refName}/files`}, './')
          ),
          fileInfo.file.split('/').map((dir, index, arr) => {
            if (index === arr.length - 1) {
js_templates/file.ts:71
Before
70
71
72
73
74
75
        m('div', {class: "row my-3"},
          m('div', {class: "col"},
            m('span', m('a', {
              href: `${data.reposPath}/${slugify(fileInfo.repoName)}/branch/${slugify(fileInfo.branchName)}/raw/${fileInfo.file.split('.').map(filePart => slugify(filePart)).join('.')}`}, 'View raw file'))
          )
        ),
        (fileInfo.file.endsWith(".md") ?
After
70
71
72
73
74
75
        m('div', {class: "row my-3"},
          m('div', {class: "col"},
            m('span', m('a', {
              href: `${data.reposPath}/${slugify(fileInfo.repoName)}/${fileInfo.type}/${slugify(fileInfo.refName)}/raw/${fileInfo.file.split('.').map(filePart => slugify(filePart)).join('.')}`}, 'View raw file'))
          )
        ),
        (fileInfo.file.endsWith(".md") ?
js_templates/file.ts:126
Before
125
126
127
128
129
130
                m('code', {style: "white-space: pre;"}, [
                  m('pre', {class: "language-text"}, m.trust(
                    currentRef.fileList.get(fileInfo.file).fileInfo.blameLines.map((annotation) => {
                      return `<a href="${data.reposPath}/${slugify(fileInfo.repoName)}/branch/${slugify(fileInfo.branchName)}/commits/${annotation.sha}">${annotation.sha.substr(0, 6)}</a> ${annotation.author}`
                    }).join('\n')))
                ])
              ),
After
125
126
127
128
129
130
                m('code', {style: "white-space: pre;"}, [
                  m('pre', {class: "language-text"}, m.trust(
                    currentRef.fileList.get(fileInfo.file).fileInfo.blameLines.map((annotation) => {
                      return `<a href="${data.reposPath}/${slugify(fileInfo.repoName)}/${fileInfo.type}/${slugify(fileInfo.refName)}/commits/${annotation.sha}">${annotation.sha.substr(0, 6)}</a> ${annotation.author}`
                    }).join('\n')))
                ])
              ),
js_templates/files.ts:1
Before
0
1
2
3

4
5
6
7
8








9
10
import m from 'mithril'
import { type SortedFileList, type Repository } from "../src/dataTypes.ts"
import htmlPage from './common/htmlPage.ts'

⁣
export default async (reposConfig: any, eleventyConfig: any, data: any) => {
  const branch: Repository['branches'][0] = data.currentRef
  const topLevelFilesOnly = eleventyConfig.getFilter("topLevelFilesOnly")
  const slugify = eleventyConfig.getFilter("slugify")

⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
  const files: SortedFileList = topLevelFilesOnly(Array.from(branch.fileList.keys()), '')

  const pageContent = [
After
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import m from 'mithril'
import { type SortedFileList, type Repository } from "../src/dataTypes.ts"
import htmlPage from './common/htmlPage.ts'
import {NavHelper} from './helpers/nav.ts'

export default async (reposConfig: any, eleventyConfig: any, data: any) => {
  const branch: Repository['branches'][0] = data.currentRef
  const topLevelFilesOnly = eleventyConfig.getFilter("topLevelFilesOnly")
  const slugify = eleventyConfig.getFilter("slugify")

  const nav = NavHelper({
    reposConfig,
    slugify,
    currentRepoName: data.currentRepo.name,
    currentRefName: data.currentRef.name,
    currentRefType: data.currentRefType,
  })

  const files: SortedFileList = topLevelFilesOnly(Array.from(branch.fileList.keys()), '')

  const pageContent = [
js_templates/files.ts:27
Before
26
27
28
29
30
31
32
33
34
35
36
37
      return m('li', {class: 'list-group-item'}, [
        file.isDirectory ? m.trust('<span>&#x1F4C1;&nbsp;</span>') : null,
        m('a', {
          href: `${data.reposPath}/${slugify(data.flatRef.repoName)}/branch/${slugify(data.flatRef.relName)}/files/${
            file.fullPath.split('/')
            .map((pathPart) => {
              return pathPart.split('.').map((subPart) => {
                return slugify(subPart)
              }).join('.')
            }).join('/')}.html`
        }, file.name)
      ])
    }))
After
26
27
28

29





30
31
      return m('li', {class: 'list-group-item'}, [
        file.isDirectory ? m.trust('<span>&#x1F4C1;&nbsp;</span>') : null,
        m('a', {
⁣
          href: nav.file(file)
⁣
⁣
⁣
⁣
⁣
        }, file.name)
      ])
    }))
js_templates/helpers/nav.ts:1
Before
0
1
i⁣
mport { type ReposConfiguration } from "../../src/configTypes.ts"

/**
After
0
1
2
import { type SortedFileList } from '../../src/dataTypes.ts'
import { type ReposConfiguration } from "../../src/configTypes.ts"

/**
js_templates/helpers/nav.ts:50
Before
49
50
51











52
53
               ? repoBasePath
               : refPath(refName, refType)
    },
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
⁣
    files: (newRef: RefArgument = null) => {
      const refName = newRef?.refName || args.currentRefName
      const refType = newRef?.refType || args.currentRefType
After
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
               ? repoBasePath
               : refPath(refName, refType)
    },
    file: (file: SortedFileList[0], newRef: RefArgument = null) => {
      const refName = newRef?.refName || args.currentRefName
      const refType = newRef?.refType || args.currentRefType

      return `${refPath(refName, refType)}/files/${file.fullPath.split('/')
        .map((pathPart) => {
          return pathPart.split('.').map((subPart) => {
            return args.slugify(subPart)
          }).join('.')
        }).join('/')}.html`
    },
    files: (newRef: RefArgument = null) => {
      const refName = newRef?.refName || args.currentRefName
      const refType = newRef?.refType || args.currentRefType
js_templates/index.ts:16
Before
15
16
17
18
19
20
21
22
23
24
25
26
27
          return (
            m('div', {class: "m-2 card bezel-gray flex-grow-1", style: "flex-basis: 20rem;"},
              m('div', {class: "card-header"},
                m('a', {class: "card-title fs-5", href: `${data.reposPath}/${slugify(repo.name)}/branch/${repo.defaultBranch}`}, repo.name)
              ),
              m('div', {class: "card-body"},
                repo.description ? m('p', {class: "card-text"}, repo.description) : null
              ),
              m('div', {class: "card-footer"}, [
                m('a', {
                  href: `${data.reposPath}/${slugify(repo.name)}/branch/${repo.defaultBranch}`,
                  class: "ms-0 me-2 my-2 btn btn-primary text-white"
                }, 'Go to site'),
                m('button', {
After
15
16
17
18
19
20
21
22
23
24
25
26
27
          return (
            m('div', {class: "m-2 card bezel-gray flex-grow-1", style: "flex-basis: 20rem;"},
              m('div', {class: "card-header"},
                m('a', {class: "card-title fs-5", href: `${data.reposPath}/${slugify(repo.name)}`}, repo.name)
              ),
              m('div', {class: "card-body"},
                repo.description ? m('p', {class: "card-text"}, repo.description) : null
              ),
              m('div', {class: "card-footer"}, [
                m('a', {
                  href: `${data.reposPath}/${slugify(repo.name)}`,
                  class: "ms-0 me-2 my-2 btn btn-primary text-white"
                }, 'Go to site'),
                m('button', {
js_templates/repo.ts:18
Before
17
18
19
20
21
22
    reposConfig,
    slugify,
    currentRepoName: repo.name,
    currentRefName: data.flatRef.name,
    currentRefType: data.flatRef.type,
  })
After
17
18
19
20
21
22
    reposConfig,
    slugify,
    currentRepoName: repo.name,
    currentRefName: data.currentRef.name,
    currentRefType: data.flatRef.type,
  })
main.ts:149
Before
148
149
150
151

152
153
154
155
156
157
    }
  )

  eleventyConfig.addFilter("getDirectoryContents", (repo: string, branch: string, dirPath: string) => {
⁣
    const fileList = reposData.find(
      current => current.name === repo
    ).branches.find(
      current => current.name === branch
    ).fileList

    return Array.from(fileList.keys()).filter(
After
148
149
150
151
152
153
154
155
156
157
158
    }
  )

  eleventyConfig.addFilter("getDirectoryContents", (repo: string, refName: string, refType: 'branch' | 'tag', dirPath: string) => {
    const refKey = refType === 'branch' ? 'branches' : 'tags'
    const fileList = reposData.find(
      current => current.name === repo
    )[refKey].find(
      current => current.name === refName
    ).fileList

    return Array.from(fileList.keys()).filter(
main.ts:245
Before
244
245
246
247
248

249
250
251
    return sortedByDirectory
  })

  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 = Array.from(files.keys()).some((testFile) => {
        return testFile.startsWith(filename + '/') && (testFile !== filename)
      })
After
244
245
246
247
248
249
250
251
252
    return sortedByDirectory
  })

  eleventyConfig.addFilter("isDirectory", (filename: string, repoName: string, refName: string, refType: 'branch' | 'tag') => {
    const repo = reposData.find(repo => repo.name === repoName)
    const refKey = refType === 'branch' ? 'branches' : 'tags'
    const files = repo[refKey].find(ref => ref.name === refName).fileList
    const isDirectory = Array.from(files.keys()).some((testFile) => {
        return testFile.startsWith(filename + '/') && (testFile !== filename)
      })
main.ts:384
Before
After
main.ts:422
Before
421
422
423
424
425
426
427
428



429
430
431
432

433
434
435
436
      flatFiles: flatFilesData,
      permalink: (data) => {
        const repoName = data.fileInfo.repoName
        const branchName = data.fileInfo.branchName
        const refType = data.fileInfo.type
        return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/${refType}/${eleventyConfig.getFilter("slugify")(branchName)}/raw/${data.fileInfo.file.split('.').map(filePart => eleventyConfig.getFilter("slugify")(filePart)).join('.')}`
      },
      eleventyComputed: {
⁣
⁣
⁣
        currentRef: (data) => reposData.find(repo => {
          return repo.name === data.fileInfo.repoName
        }).branches.find(branch => {
          return branch.name === data.fileInfo.branchName        }),
        currentRefType: (data) => data.fileInfo.refType,
      }
    }
  )
After
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
      flatFiles: flatFilesData,
      permalink: (data) => {
        const repoName = data.fileInfo.repoName
        const refName = data.fileInfo.refName
        const refType = data.fileInfo.type
        return `${reposPath}/${eleventyConfig.getFilter("slugify")(repoName)}/${refType}/${eleventyConfig.getFilter("slugify")(refName)}/raw/${data.fileInfo.file.split('.').map(filePart => eleventyConfig.getFilter("slugify")(filePart)).join('.')}`
      },
      eleventyComputed: {
        currentRef: (data) => {
          const refKey = data.fileInfo.type === 'branch' ? 'branches' : 'tags'

          return reposData.find(repo => {
            return repo.name === data.fileInfo.repoName
          })[refKey].find(ref => {
            return ref.name === data.fileInfo.refName
          })
        },
        currentRefType: (data) => data.fileInfo.type,
      }
    }
  )
scss/design-board.scss:175
Before
174
175
176







177
178
  font-size: 1rem;
}

⁣
⁣
⁣
⁣
⁣
⁣
⁣
.dropdown-branches {
  list-style: none;
  padding: 0;
After
174
175
176
177
178
179
180
181
182
183
184
185
  font-size: 1rem;
}

.rel-dropdown[data-selected=branch] .tag {
  display: none;
}
.rel-dropdown[data-selected=tag] .branch {
  display: none;
}

.dropdown-branches {
  list-style: none;
  padding: 0;
src/vcses/git/operations.ts:37
Before
36
37
38
39
40
41
      filesMap.set(path, {
        get fileInfo() {
          return {
            contents: "",
            lastModified: new Date(),
            blameLines: []
          }
After
36
37
38
39
40
41
      filesMap.set(path, {
        get fileInfo() {
          return {
            contents: "", // TODO: implement this part
            lastModified: new Date(),
            blameLines: []
          }