• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

scriptype / writ-cms / 18160146944

01 Oct 2025 10:55AM UTC coverage: 72.83% (-2.7%) from 75.488%
18160146944

push

github

scriptype
[CM2] Introduce facets

An interactive rebase of months worth of thinking, doing and not doing.

Faceting is similar to the earlier tag indexing. With facets, posts can be indexed by any fields, not just tags.

With this, tag indexing is no longer a special case. There's no need for modelling or rendering tags.

Facets are created based on 'facetKeys' of a contentType. A simple array of keys works.

Now, each collection has a new route: '/by' which is listing all available facet views in the collection.

And each facet view has a route: /by/facet-name. E.g. /by/author, /by/date, /by/tags. In that page, possible values for the facet can be listed.

And each possible value for the facet has the route: /by/facet-name/value. E.g. /by/author/enes, /by/date/1970-11-23, /by/tags/css. There, posts matching the criteria can be shown.

These new render targets should be implemented inside the theme, and that's what should come soon. So far, working with a local laboratory that has a custom theme.

A notable side-effect of turning a field into a facet is that the field becomes an object in the shape of: { value, facetPermalink }. So, if 'date' field is a facet, templates should do: {{date.value}} to display its value and {{date.facetPermalink}} to render a link to the facet value page in the collection. The name 'facetPermalink' is so ugly tho.

If a facet field links to another entry, then the field's value, which is populated with the linked entry, just gets a 'facetPermalink', to make it possible to render links both to the linked entry and to the facet value page.

Facets work across deep category tree. So, all of the mentioned routes exist at each stop.

E.g.
site.com/photos/by/subject,
site.com/photos/astro/by/subject,
site.com/photos/astro/deep-space/by-subject

An 'afterEffects' mechanism is invented for models. Facets needed to be collected after the tree-building and linking are finished. So, all the way from the root, each model does whatever side-eff... (continued)

495 of 810 branches covered (61.11%)

Branch coverage included in aggregate %.

45 of 144 new or added lines in 8 files covered. (31.25%)

8 existing lines in 3 files now uncovered.

2282 of 3003 relevant lines covered (75.99%)

242.7 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

45.83
/src/compiler/contentModel2/index.js
1
const { resolve } = require('path')
7✔
2
const _ = require('lodash')
7✔
3
const frontMatter = require('front-matter')
7✔
4
const ImmutableStack = require('../../lib/ImmutableStack')
7✔
5
const { isTemplateFile } = require('./helpers')
7✔
6
const models = {
7✔
7
  homepage: require('./models/homepage'),
8
  subpage: require('./models/subpage'),
9
  collection: require('./models/collection'),
10
  asset: require('./models/asset')
11
}
12

13
const linkEntries = (contentModel) => {
7✔
14
  contentModel.collections.forEach(collection => {
7✔
15
    collection.posts.forEach(post => {
7✔
16
      const fields = Object.keys(post)
49✔
17
      const linkFields = fields
49✔
18
        .map(key => {
19
          const match = key.match(/(.+){(.+)}/)
924✔
20
          if (!match) {
924!
21
            return
924✔
22
          }
23
          const [, entrySlug, categorySlug] = post[key].match(/([^(\s]+)(?:\s*\(([^)]+)\))?/)
×
24
          return {
×
25
            key: match[1],
26
            collectionSlug: match[2],
27
            categorySlug,
28
            entrySlug
29
          }
30
        })
31
        .filter(Boolean)
32
      linkFields.forEach(link => {
49✔
33
        const collection = contentModel.collections.find(c => c.slug.match(new RegExp(link.collectionSlug, 'i')))
×
NEW
34
        const container = link.categorySlug ? // TODO: Handle subcategories
×
35
          collection.categories.find(c => c.slug.match(new RegExp(link.categorySlug, 'i'))) || collection :
×
36
          collection
37
        const entry = container.posts.find(p => p.slug.match(new RegExp(link.entrySlug, 'i')))
×
NEW
38
        post[link.key] = Object.assign({}, entry)
×
39
        entry.links = entry.links || {}
×
40
        entry.links.relations = entry.links.relations || []
×
41
        const relation = entry.links.relations.find(r => r.key === link.key)
×
42
        if (relation) {
×
43
          relation.entries.push(post)
×
44
        } else {
45
          entry.links.relations.push({
×
46
            key: link.key,
47
            entries: [post]
48
          })
49
        }
50
      })
51
    })
52
  })
53
}
54

55
const defaultContentModelSettings = {
7✔
56
  permalinkPrefix: '/',
57
  out: resolve('.'),
58
  defaultCategoryName: 'Unclassified',
59
  assetsDirectory: 'assets',
60
  pagesDirectory: 'pages',
61
  homepageDirectory: 'homepage',
62
  debug: false,
63
  site: {
64
    title: '',
65
    description: ''
66
  }
67
}
68
class ContentModel {
69
  constructor(contentModelSettings = defaultContentModelSettings, contentTypes = []) {
7!
70
    this.settings = {
7✔
71
      ...defaultContentModelSettings,
72
      ...contentModelSettings
73
    }
74
    this.contentTypes = contentTypes
7✔
75
  }
76

77
  create(fileSystemTree) {
78
    const indexFileNameOptions = ['root']
7✔
79

80
    const isRootIndexFile = (node) => {
7✔
81
      return isTemplateFile(node) && node.name.match(
14✔
82
        new RegExp(`^(${indexFileNameOptions.join('|')})\\..+$`)
83
      )
84
    }
85

86
    const indexFile = fileSystemTree.find(isRootIndexFile)
7✔
87
    const indexProps = indexFile ? frontMatter(indexFile.content) : {}
7!
88

89
    const context = new ImmutableStack([{
7✔
90
      key: 'root',
91
      outputPath: this.settings.out,
92
      permalink: this.settings.permalinkPrefix
93
    }])
94

95
    this.models = {
7✔
96
      collection: models.collection({
97
        defaultCategoryName: this.settings.defaultCategoryName,
98
        collectionAliases: [
99
          ...this.contentTypes
100
            .filter(ct => ct.model === 'collection')
×
101
            .map(ct => ct.collectionAlias),
×
102
          ...(indexProps.attributes?.collectionAliases || [])
14✔
103
        ]
104
      }, this.contentTypes),
105

106
      subpage: models.subpage({
107
        pagesDirectory: this.settings.pagesDirectory
108
      }),
109

110
      homepage: models.homepage({
111
        homepageDirectory: this.settings.homepageDirectory
112
      }),
113

114
      asset: models.asset({
115
        assetsDirectory: this.settings.assetsDirectory
116
      })
117
    }
118

119
    this.contentModel = {
7✔
120
      homepage: this.models.homepage.create({
121
        name: 'index',
122
        extension: 'md',
123
        content: ''
124
      }, context),
125
      subpages: [],
126
      collections: [],
127
      assets: []
128
    }
129

130
    fileSystemTree.forEach(node => {
7✔
131
      if (this.models.homepage.match(node)) {
14✔
132
        this.contentModel.homepage = this.models.homepage.create(node, context)
7✔
133
        return
7✔
134
      }
135

136
      if (this.models.subpage.match(node)) {
7!
137
        return this.contentModel.subpages.push(
×
138
          this.models.subpage.create(node, context)
139
        )
140
      }
141

142
      if (this.models.subpage.matchPagesDirectory(node)) {
7!
143
        return node.children.forEach(childNode => {
×
144
          if (this.models.subpage.match(childNode)) {
×
145
            this.contentModel.subpages.push(
×
146
              this.models.subpage.create(childNode, context)
147
            )
148
          } else if (this.models.asset.match(childNode)) {
×
149
            this.contentModel.assets.push(
×
150
              this.models.asset.create(childNode, context)
151
            )
152
          }
153
        })
154
      }
155

156
      if (this.models.collection.match(node)) {
7!
157
        return this.contentModel.collections.push(
7✔
158
          this.models.collection.create(node, context)
159
        )
160
      }
161

162
      if (this.models.asset.matchAssetsDirectory(node)) {
×
163
        return this.contentModel.assets.push(
×
164
          ...node.children.map(childNode => {
165
            return this.models.asset.create(childNode, context)
×
166
          })
167
        )
168
      }
169

170
      if (this.models.asset.match(node)) {
×
171
        return this.contentModel.assets.push(
×
172
          this.models.asset.create(node, context)
173
        )
174
      }
175
    })
176

177
    this.afterEffects()
7✔
178
    return this.contentModel
7✔
179
  }
180

181
  afterEffects() {
182
    linkEntries(this.contentModel)
7✔
183

184
    this.contentModel.collections.forEach(collection => {
7✔
185
      this.models.collection.afterEffects(this.contentModel, collection)
7✔
186
    })
187

188
    this.contentModel.subpages.forEach(subpage => {
7✔
NEW
189
      this.models.subpage.afterEffects(this.contentModel, subpage)
×
190
    })
191

192
    this.models.homepage.afterEffects(this.contentModel, this.contentModel.homepage)
7✔
193

194
    this.contentModel.assets.forEach(asset => {
7✔
NEW
195
      this.models.asset.afterEffects(this.contentModel, asset)
×
196
    })
197
  }
198

199
  render(renderer) {
200
    const renderHomepage = () => {
×
201
      return this.models.homepage.render(renderer, this.contentModel.homepage, {
×
202
        contentModel: this.contentModel,
203
        settings: this.settings,
204
        debug: this.settings.debug
205
      })
206
    }
207

208
    const renderCollections = () => {
×
209
      return Promise.all(
×
210
        this.contentModel.collections.map(collection => {
211
          return this.models.collection.render( renderer, collection, {
×
212
            contentModel: this.contentModel,
213
            settings: this.settings,
214
            debug: this.settings.debug
215
          })
216
        })
217
      )
218
    }
219

220
    const renderSubpages = () => {
×
221
      return Promise.all(
×
222
        this.contentModel.subpages.map(subpage => {
223
          return this.models.subpage.render(renderer, subpage, {
×
224
            contentModel: this.contentModel,
225
            settings: this.settings,
226
            debug: this.settings.debug
227
          })
228
        })
229
      )
230
    }
231

232
    const renderAssets = () => {
×
233
      return Promise.all(
×
234
        this.contentModel.assets.map(asset => {
235
          return this.models.asset.render(renderer, asset, {
×
236
            contentModel: this.contentModel,
237
            settings: this.settings,
238
            debug: this.settings.debug
239
          })
240
        })
241
      )
242
    }
243

244
    return renderHomepage()
×
245
      .then(() =>
246
        Promise.all([
×
247
          renderCollections(),
248
          renderSubpages(),
249
          renderAssets()
250
        ])
251
      )
252
  }
253
}
254

255
module.exports = ContentModel
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc