[{"data":1,"prerenderedAt":560},["ShallowReactive",2],{"article-\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028":3,"read-next-\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028":167},{"id":4,"title":5,"articleType":6,"categories":7,"date":9,"description":10,"extension":11,"hiddenCategories":12,"meta":14,"paragraphs":15,"path":149,"readingTime":150,"related":151,"sitemap":160,"stem":161,"topics":162,"__hash__":166},"articleEntries\u002Farticles-data\u002Fvale-100-prose-linting-drupal-20261028.json","Vale 1.0.0; prose linting for Drupal","Blog post",[8],"Drupal","2026-10-28T16:20:00+11:00","Vale (for Drupal) holds every validated save to your style guide, from the node form or over JSON:API, and refuses the ones that break it.","json",[13],"Planet Drupal",{},[16,26,32,62,77,95,111,125,135,144],{"type":17,"layout":18,"regions":19},"section","layout_onecol",{"content":20},[21,24],{"type":22,"html":23},"text_formatted","\u003Cp>Drupal is 25 this year, and its code has had linters for a long time. Its words haven't, and with Drupal AI writing straight into the node form, more of them now come from a model. A search of drupal.org turns up readability scores and spellcheckers, but nothing that holds a save to your own style guide.\u003C\u002Fp>",{"type":22,"html":25},"\u003Cp>Vale (for Drupal) does that with the \u003Ca href=\"https:\u002F\u002Fvale.sh\">Vale\u003C\u002Fa> prose linter. Break an error-level rule and the save is refused, or only the publish if drafts should save freely. \u003Ccode>1.0.0\u003C\u002Fcode> is out now for Drupal 10.3, 11 and 12, and needs the Vale binary on your server.\u003C\u002Fp>",{"type":17,"title":27,"layout":18,"regions":28},"Doesn't CI already do this?",{"content":29},[30],{"type":22,"html":31},"\u003Cp>Vale in CI is the right tool for Markdown in a repository. Content in Drupal arrives through a save instead: the node form, a Fully Decoupled frontend over JSON:API, a workspace being published. The module checks there, on the server, whatever editor the author used.\u003C\u002Fp>",{"type":17,"title":33,"layout":18,"regions":34},"Setting it up",{"content":35},[36,38,44,46,51,53,55],{"type":22,"html":37},"\u003Cp>\u003Ccode>drush vale:install\u003C\u002Fcode> puts the binary in \u003Ccode>bin\u002F\u003C\u002Fcode> beside your web root, checked against its published SHA-256:\u003C\u002Fp>",{"type":39,"title":40,"code":41,"language":42,"highlighted":43},"code","Install the module and Vale, on a Composer site with Drush","composer require drupal\u002Fvale\ndrush en vale\ndrush vale:install","bash","\u003Cspan class=\"token function\">composer\u003C\u002Fspan> require drupal\u002Fvale\ndrush en vale\ndrush vale:install",{"type":22,"html":45},"\u003Cp>Write a \u003Ccode>.vale.ini\u003C\u002Fcode>, run \u003Ccode>bin\u002Fvale sync\u003C\u002Fcode> beside it, deploy the \u003Ccode>styles\u003C\u002Fcode> directory with the site, and point \u003Ccode>settings.php\u003C\u002Fcode> at both:\u003C\u002Fp>",{"type":39,"title":47,"language":48,"code":49,"highlighted":50},"settings.php","php","$settings['vale_binary'] = $app_root . '\u002F..\u002Fbin\u002Fvale';\n$settings['vale_config'] = $app_root . '\u002F..\u002F.vale.ini';\n\n\u002F\u002F Optional.\n$config['vale.settings']['publish_only'] = TRUE;\n$config['vale.settings']['workspace_mode'] = 'block';\n\u002F\u002F Keyed by your Markdown text format's machine name.\n$config['vale.settings']['formats'] = ['markdown' => 'md'];","\u003Cspan class=\"token variable\">$settings\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale_binary'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token variable\">$app_root\u003C\u002Fspan> \u003Cspan class=\"token operator\">.\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'\u002F..\u002Fbin\u002Fvale'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$settings\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale_config'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token variable\">$app_root\u003C\u002Fspan> \u003Cspan class=\"token operator\">.\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'\u002F..\u002F.vale.ini'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\n\u003Cspan class=\"token comment\">\u002F\u002F Optional.\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale.settings'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'publish_only'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token constant boolean\">TRUE\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale.settings'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'workspace_mode'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'block'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\u003Cspan class=\"token comment\">\u002F\u002F Keyed by your Markdown text format's machine name.\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale.settings'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'formats'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'markdown'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'md'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>",{"type":22,"html":52},"\u003Cp>Then tick \u003Cem>Check with Vale\u003C\u002Fem> under \u003Cem>Prose checks\u003C\u002Fem> in a text field's settings, and the Status report confirms it.\u003C\u002Fp>",{"type":22,"html":54},"\u003Cp>Each field can set a minimum level, or change any rule's level or turn it off. With publish-only mode on, drafts save freely and the publish is held to the rules, following Content Moderation when it's installed.\u003C\u002Fp>",{"type":56,"alt":57,"caption":58,"width":59,"height":60,"src":61},"media","A Drupal edit form after a refused save. Two warnings ending \"Blocks publishing\" sit above an error that names the ai-tells HedgingPhrases and AICompoundPhrases rules, the line and the matched text.","\u003Cp>On druxtjs.org: a hedge and a cliché in a text paragraph, and the publish refused until they go.\u003C\u002Fp>",1015,455,"\u002Fimages\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028\u002Fvale-druxtjs-publish-refused.png",{"type":17,"title":63,"layout":18,"regions":64},"Writing your own rules",{"content":65},[66,68,73,75],{"type":22,"html":67},"\u003Cp>Published styles are a start, but your own rules matter most: product names, words your organisation avoids, phrases your editors overuse. Each rule is a short YAML file in a folder under \u003Ccode>styles\u003C\u002Fcode>, kept in your codebase and deployed with the site:\u003C\u002Fp>",{"type":39,"title":69,"language":70,"code":71,"highlighted":72},"styles\u002FHouse\u002FTerms.yml","yaml","extends: substitution\nmessage: \"Use '%s' instead of '%s'.\"\nlevel: error\nignorecase: false\nswap:\n  drupal: Drupal\n  headless: Fully Decoupled","\u003Cspan class=\"token key atrule\">extends\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> substitution\n\u003Cspan class=\"token key atrule\">message\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Use '%s' instead of '%s'.\"\u003C\u002Fspan>\n\u003Cspan class=\"token key atrule\">level\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> error\n\u003Cspan class=\"token key atrule\">ignorecase\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean important\">false\u003C\u002Fspan>\n\u003Cspan class=\"token key atrule\">swap\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan>\n  \u003Cspan class=\"token key atrule\">drupal\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> Drupal\n  \u003Cspan class=\"token key atrule\">headless\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> Fully Decoupled",{"type":22,"html":74},"\u003Cp>Add \u003Ccode>House\u003C\u002Fcode> to \u003Ccode>BasedOnStyles\u003C\u002Fcode> in \u003Ccode>.vale.ini\u003C\u002Fcode>, and a save with \"headless drupal\" in it is refused. \u003Ccode>drush vale:check node.article.body \"Some text.\"\u003C\u002Fcode> tries a rule against a field before an editor meets it. Levels are the site builder's call, so start a new rule at warning and promote it once you trust it.\u003C\u002Fp>",{"type":22,"html":76},"\u003Cp>Vale's \u003Ca href=\"https:\u002F\u002Fvale.sh\u002Fdocs\u002Fstyles\">styles documentation\u003C\u002Fa> covers the other checks, from banned words to sentence length, and you can browse published styles in \u003Ca href=\"https:\u002F\u002Fvale.sh\u002Fexplorer\">Vale's explorer\u003C\u002Fa>. The rules this post was held to are in this site's \u003Ca href=\"https:\u002F\u002Fgithub.com\u002FDecipher\u002Fstuar.tc\u002Fblob\u002Fmain\u002F.vale.ini\">\u003Ccode>.vale.ini\u003C\u002Fcode>\u003C\u002Fa>.\u003C\u002Fp>",{"type":17,"title":78,"layout":18,"regions":79},"Over JSON:API",{"content":80},[81,83,87,89,93],{"type":22,"html":82},"\u003Cp>This is the part I built it for. On a Fully Decoupled site, a \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\">Druxt\u003C\u002Fa> or Nuxt frontend writes over JSON:API, and the check runs in the entity's validation, which those writes go through as well as the form. Post a body with a repeated word, with Vale's built-in style on, and you get a 422 that points at the field:\u003C\u002Fp>",{"type":39,"title":84,"code":85,"language":11,"highlighted":86},"POST \u002Fjsonapi\u002Fnode\u002Farticle, with \"Check the the docs.\" in the body","{\n  \"errors\": [\n    {\n      \"title\": \"Unprocessable Content\",\n      \"status\": \"422\",\n      \"detail\": \"body.0.value: Body: Vale.Repetition: 'the' is repeated! (line 1: \\\"the the\\\")\",\n      \"source\": {\n        \"pointer\": \"\u002Fdata\u002Fattributes\u002Fbody\u002Fvalue\"\n      }\n    }\n  ]\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"errors\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"title\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Unprocessable Content\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"status\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"422\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"detail\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"body.0.value: Body: Vale.Repetition: 'the' is repeated! (line 1: \\\"the the\\\")\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"source\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n        \u003Cspan class=\"token property\">\"pointer\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"\u002Fdata\u002Fattributes\u002Fbody\u002Fvalue\"\u003C\u002Fspan>\n      \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":88},"\u003Cp>Several errors in one value share that entry, one per line of \u003Ccode>detail\u003C\u002Fcode>, each naming its field. With publish-only mode on, a refused publish comes back on the entity, at \u003Ccode>\u002Fdata\u003C\u002Fcode>, still naming the field. Warnings and suggestions come back in \u003Ccode>meta.vale\u003C\u002Fcode>, on a refused write or a successful one, with \u003Ccode>checked\u003C\u002Fcode> false if Vale couldn't run:\u003C\u002Fp>",{"type":39,"title":90,"language":11,"code":91,"highlighted":92},"meta.vale on a 201, with a warning","{\n  \"meta\": {\n    \"vale\": {\n      \"checked\": true,\n      \"alerts\": [\n        {\n          \"entity_type\": \"node\",\n          \"bundle\": \"article\",\n          \"id\": \"a22048fc-b0ec-4a9b-a80b-14be85498d81\",\n          \"field\": \"body\",\n          \"delta\": 0,\n          \"rule\": \"Microsoft.Adverbs\",\n          \"level\": \"warning\",\n          \"message\": \"Remove 'very' if it's not important to the meaning of the statement.\",\n          \"line\": 1,\n          \"start\": 9,\n          \"end\": 12,\n          \"match\": \"very\",\n          \"description\": \"\",\n          \"link\": \"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\n        }\n      ]\n    }\n  }\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"meta\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token property\">\"vale\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"checked\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"alerts\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n        \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"entity_type\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"node\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"bundle\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"article\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"id\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"a22048fc-b0ec-4a9b-a80b-14be85498d81\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"field\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"body\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"delta\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">0\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"rule\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Microsoft.Adverbs\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"level\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"warning\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"message\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Remove 'very' if it's not important to the meaning of the statement.\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"line\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">1\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"start\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">9\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"end\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">12\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"match\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"very\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"description\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"link\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\u003C\u002Fspan>\n        \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n      \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":94},"\u003Cp>So a Fully Decoupled editor gets the same feedback as the node form, without rebuilding the rules in JavaScript.\u003C\u002Fp>",{"type":17,"title":96,"layout":18,"regions":97},"Checking before the save",{"content":98},[99,101,105,109],{"type":22,"html":100},"\u003Cp>\u003Ccode>POST \u002Fvale\u002Fcheck\u003C\u002Fcode> returns the alerts a field would get, with the sentence around each, and saves nothing. It's built for an editor that checks as you type, in Druxt or anywhere else.\u003C\u002Fp>",{"type":39,"title":102,"language":11,"code":103,"highlighted":104},"POST \u002Fvale\u002Fcheck","{\n  \"entity_type\": \"node\",\n  \"bundle\": \"article\",\n  \"field\": \"body\",\n  \"format\": \"basic_html\",\n  \"text\": \"\u003Cp>Check the the docs. This step is very easy to miss.\u003C\u002Fp>\"\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"entity_type\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"node\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"bundle\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"article\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"field\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"body\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"format\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"basic_html\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"text\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"&lt;p>Check the the docs. This step is very easy to miss.&lt;\u002Fp>\"\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":39,"title":106,"language":11,"code":107,"highlighted":108},"The response","{\n  \"checked\": true,\n  \"publish_only\": true,\n  \"alerts\": [\n    {\n      \"rule\": \"Vale.Repetition\",\n      \"level\": \"error\",\n      \"message\": \"'the' is repeated!\",\n      \"match\": \"the the\",\n      \"sentence\": \"Check the the docs.\"\n    },\n    {\n      \"rule\": \"Microsoft.Adverbs\",\n      \"level\": \"warning\",\n      \"message\": \"Remove 'very' if it's not important to the meaning of the statement.\",\n      \"match\": \"very\",\n      \"sentence\": \"This step is very easy to miss.\",\n      \"link\": \"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\n    }\n  ]\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"checked\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"publish_only\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"alerts\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"rule\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Vale.Repetition\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"level\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"error\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"message\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"'the' is repeated!\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"match\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"the the\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"sentence\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Check the the docs.\"\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"rule\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Microsoft.Adverbs\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"level\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"warning\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"message\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Remove 'very' if it's not important to the meaning of the statement.\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"match\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"very\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"sentence\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"This step is very easy to miss.\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"link\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":110},"\u003Cp>A caller needs the \u003Cem>Use the Vale check endpoint\u003C\u002Fem> permission and edit access to the field, and a cookie session sends an \u003Ccode>X-CSRF-Token\u003C\u002Fcode>.\u003C\u002Fp>",{"type":17,"title":112,"layout":18,"regions":113},"Before a workspace goes live",{"content":114},[115,117,119],{"type":22,"html":116},"\u003Cp>Before a workspace is published, the module checks everything in it. With \u003Ccode>workspace_mode\u003C\u002Fcode> set to \u003Ccode>block\u003C\u002Fcode>, the publish is refused while anything has an error or a value can't be checked, even though an ordinary save goes through when Vale can't run. The default, \u003Ccode>advise\u003C\u002Fcode>, publishes and lists the findings.\u003C\u002Fp>",{"type":22,"html":118},"\u003Cp>Publish-only mode and workspaces suit each other. Inside a workspace every save is a draft, including an edit to a page that's already live, so editors save freely and the workspace's publish is where the rules apply, in \u003Ccode>advise\u003C\u002Fcode> mode as well as \u003Ccode>block\u003C\u002Fcode>. \u003Ccode>drush vale:lint --workspace=stage\u003C\u002Fcode> runs the same check from a deploy script.\u003C\u002Fp>",{"type":56,"alt":120,"caption":121,"width":122,"height":123,"src":124},"Two Drupal messages after a refused workspace publish. The error lists a Vale.Repetition and a Microsoft.Contractions finding on the body of a node called Release notes, then a summary: Stage, 2 prose errors, 1 warning or suggestion. A warning below lists a Microsoft.Adverbs finding.","\u003Cp>Publishing the Stage workspace, refused: two errors in a draft, and the warning listed beside them.\u003C\u002Fp>",968,393,"\u002Fimages\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028\u002Fvale-workspace-publish-refused.png",{"type":17,"title":126,"layout":18,"regions":127},"Before you install",{"content":128},[129,131,133],{"type":22,"html":130},"\u003Cp>Vale ships as one binary, and it has to be on every web server and in the CLI container. \u003Ccode>drush vale:install\u003C\u002Fcode> covers Linux and macOS. On a container host, build it into every image that serves Drupal or runs Drush, as druxtjs.org does on Lagoon, and add \u003Ccode>gcompat\u003C\u002Fcode> on Alpine. Other managed hosts are \u003Ca href=\"https:\u002F\u002Fgithub.com\u002FDecipher\u002Fvale\u002Fissues\u002F33\">on the 1.1.0 list\u003C\u002Fa>.\u003C\u002Fp>",{"type":22,"html":132},"\u003Cp>On a test site, Vale added about 55 ms to an uncached JSON:API save of a short paragraph, and nothing when the text hadn't changed, because results are cached until the text or the styles do. If Vale is missing or times out, the content saves and the author is told it wasn't checked, unless you set \u003Ccode>fail_closed\u003C\u002Fcode>.\u003C\u002Fp>",{"type":22,"html":134},"\u003Cp>For saves that skip validation, like a migration, \u003Ccode>drush vale:lint\u003C\u002Fcode> checks the content afterwards and exits with 1, so a deploy can depend on it. Any text field you opt in is checked, on a Drupal CMS site too. The title is a base field rather than one you opt in, and Canvas keeps its text in component props, so neither is checked.\u003C\u002Fp>",{"type":17,"layout":18,"regions":136},{"content":137},[138],{"type":139,"description":140,"url":141,"gitpod":142,"drupalUrl":143},"repository","\u003Cp>The code is on GitHub, and the issue queue is on Drupal.org. If your editors rely on Vale, sponsoring is what funds the next release.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002FDecipher\u002Fvale",false,"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fvale",{"type":17,"layout":18,"regions":145},{"content":146},[147],{"type":22,"html":148},"\u003Cp>Next up are hooks for other modules and summary fields, with AI-suggested rewrites further out. What would you want it to do for your editors?\u003C\u002Fp>","\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028","4 min",[152,155,158],{"path":153,"source":154},"\u002Fwriting\u002Fdrupal-site-settings-over-jsonapi-consumer-20260826","picked",{"path":156,"source":157},"\u002Fwriting\u002Fdruxt-drupal-13x-resource-list-yours-20260909","view",{"path":159,"source":157},"\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023",{"loc":149},"articles-data\u002Fvale-100-prose-linting-drupal-20261028",[163,164,165],"editing","JSON:API","paragraphs","EYkIieCTUwK4Eldm8uUXZsDP7FEEBRIzTsojtcgC65Q",[168,273,445],{"id":169,"title":170,"articleType":6,"categories":171,"date":173,"description":174,"extension":11,"hiddenCategories":175,"meta":176,"paragraphs":177,"path":153,"readingTime":150,"related":263,"sitemap":268,"stem":269,"topics":270,"__hash__":272},"articleEntries\u002Farticles-data\u002Fdrupal-site-settings-over-jsonapi-consumer-20260826.json","Drupal site settings over JSON:API, per consumer",[8,172],"Druxt","2026-08-26T19:20:00+10:00","Run the same decoupled frontend twice with two consumer IDs and get two differently branded sites. Decoupled Settings serves the site and theme config Drupal already holds over JSON:API, with per-consumer overrides. Four years ago I guessed the answer would be Consumers plus theme settings. Turns out it was.",[13],{},[178,185,191,197,227,237,250,256],{"type":17,"layout":18,"regions":179},{"content":180},[181,183],{"type":22,"html":182},"\u003Cp>In April 2022 I wrote about \u003Ca href=\"\u002Fwriting\u002Fdecoupling-configuration-config-pages-20220412\">decoupling configuration with Config Pages\u003C\u002Fa>, and ended it with a guess at where I'd go next:\u003C\u002Fp>\n\n\u003Cblockquote>\u003Cp>One of my future experiments with Druxt will be to look at the options of using the Drupal Consumers module alongside Drupal theme settings as a solution.\u003C\u002Fp>\u003C\u002Fblockquote>",{"type":22,"html":184},"\u003Cp>That experiment is now a module. \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdecoupled_settings\">Decoupled Settings\u003C\u002Fa> serves the site and theme configuration Drupal already holds over JSON:API, and lets every frontend override just the values it needs. It went up on Drupal.org this week.\u003C\u002Fp>",{"type":17,"title":186,"layout":18,"regions":187},"What core doesn't give you",{"content":188},[189],{"type":22,"html":190},"\u003Cp>Core's JSON:API is an entity API. Content entities and config entities alike, so node types, image styles and views are all in there. What it has never exposed is simple configuration, and there's a good argument that it shouldn't by default, because plenty of what sits in \u003Ccode>system.site\u003C\u002Fcode> is nobody's business.\u003C\u002Fp>\n\n\u003Cp>But a decoupled frontend still needs the boring stuff:\u003C\u002Fp>\n\n\u003Cul>\u003Cli>The site name, for the document title\u003C\u002Fli>\n\u003Cli>The slogan\u003C\u002Fli>\n\u003Cli>The logo and the favicon\u003C\u002Fli>\n\u003Cli>The front page path, so the router knows what \"home\" means\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>So every decoupled build solves it locally, and most of the ways to solve it end up keeping a second copy of the site name somewhere.\u003C\u002Fp>",{"type":17,"title":192,"layout":18,"regions":193},"Isn't that what Config Pages is for?",{"content":194},[195],{"type":22,"html":196},"\u003Cp>Config Pages is for settings \u003Cem>you\u003C\u002Fem> invent: define a type, add the fields your app needs, done. That job hasn't changed, and it's still on my sites.\u003C\u002Fp>\n\n\u003Cp>Where it gets awkward is settings Drupal already stores. Model the site name as a field on a Config Page and the site name exists in two places, and something has to keep them in step, and sooner or later something won't.\u003C\u002Fp>",{"type":17,"title":198,"layout":18,"regions":199},"How it works",{"content":200},[201,203,209,211,217,219,222,225],{"type":22,"html":202},"\u003Cp>So the module doesn't store settings. It reads them from the config objects that already hold them, and a consumer stores only what it changes.\u003C\u002Fp>",{"type":56,"alt":204,"caption":205,"width":206,"height":207,"src":208},"The per-consumer overrides form for a consumer named Partner Portal, filtered to system.site. Columns are Setting, Inherited value, Override and Value for this consumer. system.site:name and system.site:slogan are ticked and overridden to 'Partner Portal' and 'Same code, different consumer'; langcode, page.403, page.404 and page.front are unticked and inherited.","\u003Cp>Tick a setting to override it for this consumer. Everything unticked keeps following the site.\u003C\u002Fp>",1336,1022,"\u002Fimages\u002Fwriting\u002Fdecoupled-settings-consumer-overrides.png",{"type":22,"html":210},"\u003Cp>Tick a setting and it's yours. Leave it alone and it keeps following the site. Tick it and leave the value blank and that frontend doesn't show a slogan at all, which is a different thing again from clearing the override and going back to inheriting.\u003C\u002Fp>\n\n\u003Cp>What's exposed at all is bounded twice: the config objects you list, and within those, the keys typed config schema declares. On install that's \u003Ccode>system.site\u003C\u002Fcode> plus the active theme, and both of \u003Ccode>system.site\u003C\u002Fcode>'s email keys ship excluded. The settings form shows exactly how wide the exposure is, unsaved changes included, so you can trim it to the keys you want before anything ships.\u003C\u002Fp>",{"type":56,"alt":212,"caption":213,"width":214,"height":215,"src":216},"The Decoupled Settings configuration form: an Exposed configuration list containing system.site, a checkbox to expose the active theme settings, an Excluded settings box listing system.site:mail and system.site:mail_notification, and a 'What a frontend will read' table showing each setting with its global value.","\u003Cp>The exposure list, the keys it holds back, and what a frontend gets from what's left. Filtered to \u003Ccode>system.site\u003C\u002Fcode> here, so the theme's fourteen keys aren't in shot.\u003C\u002Fp>",1016,1358,"\u002Fimages\u002Fwriting\u002Fdecoupled-settings-exposure-form.png",{"type":22,"html":218},"\u003Cp>Reading it is one GET:\u003C\u002Fp>",{"type":39,"title":220,"code":221},"Reading settings as a consumer","curl \"https:\u002F\u002Fdrupal.example.com\u002Fjsonapi\u002Fdecoupled\u002Fsettings?consumerId=partner_frontend\"",{"type":39,"title":223,"code":224},"Abbreviated: 7 of the 23 keys, otherwise as returned","{\n  \"data\": {\n    \"type\": \"decoupled_settings--settings\",\n    \"id\": \"decoupled-settings\",\n    \"attributes\": {\n      \"settings\": {\n        \"system.site\": {\n          \"name\": \"Partner Portal\",\n          \"slogan\": \"Same code, different consumer\",\n          \"page\": { \"front\": \"\u002Fnode\" }\n        },\n        \"olivero.settings\": {\n          \"favicon\": { \"url\": \"\u002Fcore\u002Fthemes\u002Folivero\u002Ffavicon.ico\", \"use_default\": true },\n          \"logo\": { \"url\": \"\u002Fcore\u002Fthemes\u002Folivero\u002Flogo.svg\", \"use_default\": true }\n        }\n      },\n      \"consumer\": \"partner_frontend\"\n    }\n  }\n}",{"type":22,"html":226},"\u003Cp>The logo and favicon come back as resolved URLs, through core's own theme settings resolution and its fallbacks. Responses collect the config cache tags of every object that contributed, so an editor changing the site name invalidates them with nobody writing a hook.\u003C\u002Fp>\n\n\u003Cp>With \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fsimple_oauth\">Simple OAuth\u003C\u002Fa> it's simpler still: a client credentials token identifies the consumer by itself, no header and no query parameter, because the OAuth client and the settings consumer are the same entity.\u003C\u002Fp>",{"type":17,"title":228,"layout":18,"regions":229},"One backend, many frontends",{"content":230},[231,233,235],{"type":22,"html":232},"\u003Cp>A consumer is a name on a request. Point two builds of the same frontend at one backend, have each identify as a different consumer, and each comes up as its own site: its own name, its own slogan, its own logo and favicon. Nothing in the code differs between them, only an environment variable.\u003C\u002Fp>",{"type":22,"html":234},"\u003Cp>Read the settings at build time and bake them into the artifact, and the artifact doesn't depend on Drupal at runtime, at the cost of a rebuild whenever a setting changes. Read them at runtime and the cache tags start earning their keep. Either way it's one request, so it matters very little what the frontend is built with.\u003C\u002Fp>\n\n\u003Cp>A consumer gets its own name, slogan, front page and branding assets. Menus, regions, layouts and per-market content are still yours to solve.\u003C\u002Fp>",{"type":22,"html":236},"\u003Cp>If you're on Druxt, a module that wires all of this into the build is close behind, for the current Druxt release. It identifies the app as a consumer, reads the settings, and applies the site name and favicon to the document head. It also bakes the whole resolved set into Nuxt's runtime config, so \u003Ccode>$config.decoupledSettings['system.site'].name\u003C\u002Fcode> works anywhere in the app.\u003C\u002Fp>",{"type":17,"title":238,"layout":18,"regions":239},"Where it stands",{"content":240},[241,243,245,247],{"type":22,"html":242},"\u003Cp>108 tests and 492 assertions across unit, kernel and functional coverage, green on Drupal 10 and Drupal 11. The first pipeline on drupalcode.org came back green across all eight jobs on its first run, which never happens.\u003C\u002Fp>",{"type":22,"html":244},"\u003Cp>It's \u003Cstrong>1.0.0-beta2\u003C\u002Fstrong>. Drupal's security advisory policy covers stable releases only, and the response shape can still move before 1.0. Adopt now and you're the second consumer of this API, which means you get a say in that shape while it's still soft.\u003C\u002Fp>\n\n\u003Cp>Overrides live on the consumer entity, so they're content rather than config, and they stay in the database rather than travelling with \u003Ccode>drush cim\u003C\u002Fcode>. Multilingual works one way today: exposed values follow language negotiation, but an override applies in every language, so you can translate a setting or brand it per consumer, not both yet.\u003C\u002Fp>",{"type":22,"html":246},"\u003Cp>If any of that is useful, the beta is out now:\u003C\u002Fp>",{"type":39,"title":248,"code":249},"Install","composer require 'drupal\u002Fdecoupled_settings:^1.0@beta'\ndrush en decoupled_settings",{"type":17,"title":251,"layout":18,"regions":252},"What next?",{"content":253},[254],{"type":22,"html":255},"\u003Cp>File (Field) Paths, still next up, and the Druxt half of this behind it.\u003C\u002Fp>\n\n\u003Cp>If you're running decoupled Drupal already, how are you getting the site name into your frontend today? I'd like to know, while the API shape is still soft enough to change.\u003C\u002Fp>",{"type":17,"layout":18,"regions":257},{"content":258},[259],{"type":139,"description":260,"url":261,"gitpod":142,"drupalUrl":262},"\u003Cp>Source on GitHub, project page and issue queue on Drupal.org. If your projects lean on modules like this one, sponsoring is what keeps them actively maintained.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002FDecipher\u002Fdecoupled_settings","https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdecoupled_settings",[264,266,267],{"path":265,"source":154},"\u002Fwriting\u002Fdecoupling-configuration-config-pages-20220412",{"path":159,"source":157},{"path":156,"source":157},{"loc":153},"articles-data\u002Fdrupal-site-settings-over-jsonapi-consumer-20260826",[271,164],"decoupled config","6EY5xLR8qnBn03kPnjyAa6Ku6VDAOyhAb-qGu-MH6zs",{"id":274,"title":275,"articleType":6,"categories":276,"date":277,"description":278,"extension":11,"hiddenCategories":279,"meta":280,"paragraphs":282,"path":159,"readingTime":432,"related":433,"sitemap":439,"stem":440,"topics":441,"__hash__":444},"articleEntries\u002Farticles-data\u002Fdruxt-0250-caching-and-performance-buffs-20261023.json","Druxt 0.25.0; caching and performance buffs",[8,172],"2026-10-23T18:00:00+11:00","Druxt 0.25.0 takes a Nuxt server from 17 requests to Drupal a page to 11, and to none with content caching on, with Drupal's own Cache-Control deciding how long and a webhook to clear it. Plus Manage display changes without a rebuild.",[13],{"feedImage":281},true,[283,298,308,326,341,369,381,389,395,407,415,425],{"type":17,"layout":18,"regions":284},{"content":285},[286,288,290,292],{"type":22,"html":287},"\u003Cp>Druxt is the Fully Decoupled Drupal framework for Nuxt 2, and 0.25.0, the first Druxt release since 2023, makes it ask Drupal a lot less.\u003C\u002Fp>",{"type":22,"html":289},"\u003Cp>On druxtjs.org, the Druxt docs site, a page render made 17 requests to Drupal. On 0.25.0 it makes 11, and with content caching on it makes none, until Drupal's lifetime for the page runs out. The Nuxt server keeps what Drupal sends for as long as Drupal's \u003Ccode>Cache-Control\u003C\u002Fcode> allows, and a webhook clears it when content changes.\u003C\u002Fp>",{"type":22,"html":291},"\u003Cp>Separately, the first-load flicker is gone, and with \u003Ccode>schema.refresh\u003C\u002Fcode> on, a change in Drupal's Manage display shows up without a rebuild, which is the part I have wanted for years. The upgrade command is near the end.\u003C\u002Fp>",{"type":56,"src":293,"alt":294,"caption":295,"width":296,"height":297},"\u002Fimages\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023\u002Fdruxt-0-25-0-content-cache-grid.png","Three dot grids, one dot per request to Drupal, one column per page of a 24-page warm crawl. Druxt 0.24.0 makes 17 on most pages and 29 and 35 on the two section pages, 438 in all. Druxt 0.25.0 with cache.content off makes 11 on most pages, 294 in all. Druxt 0.25.0 with cache.content on makes none on any page. Counted at a proxy in front of Drupal answering max-age 300.","Requests to Drupal per page over a 24-page warm crawl: 17 a page on Druxt 0.24.0, 11 on 0.25.0, and 0 on 0.25.0 with cache.content on. Counted at a proxy, Drupal answering max-age 300; each process also reads the index and the decoupled settings once at start.",1280,640,{"type":17,"layout":18,"regions":299,"title":307},{"content":300},[301,303,305],{"type":22,"html":302},"\u003Cp>0.24.0 made 17 calls a page, and six of them were the JSON:API index and the menus, asked again on every page. The index is Drupal's map of its API, and Druxt reads it to find everything else. Two of the six were duplicates within one render, and they go the moment you upgrade: identical requests now share one fetch, and the menu cache keys on the query it sends.\u003C\u002Fp>",{"type":22,"html":304},"\u003Cp>The other four are kept on the Nuxt server between requests. Drupal's \u003Ccode>Cache-Control\u003C\u002Fcode> decides how long, \u003Ccode>ttl\u003C\u002Fcode> can only shorten it, and when in doubt nothing is kept. The \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fhow-to\u002Fcaching#how-long-it-lasts\">caching guide\u003C\u002Fa> has the full rules. Across a 24-page crawl, that is 144 index and menu requests down to none once the first page has been rendered.\u003C\u002Fp>",{"type":22,"html":306},"\u003Cp>A signed-in request never fills it: an \u003Ccode>Authorization\u003C\u002Fcode> header, basic auth or a Drupal session cookie skips it, and an analytics cookie doesn't count.\u003C\u002Fp>","Asking Drupal once",{"type":17,"layout":18,"regions":309,"title":325},{"content":310},[311,313,319,323],{"type":22,"html":312},"\u003Cp>Index and menu caching is on by default in production builds. Drupal needs one setting, the page cache max-age, which ships at 0. It applies to anonymous responses across the site, so browsers and CDNs will hold pages for five minutes too. Check Drupal itself rather than a CDN in front of it:\u003C\u002Fp>",{"type":56,"src":314,"alt":315,"caption":316,"width":317,"height":318},"\u002Fimages\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023\u002Fdrupal-performance-max-age.png","Drupal 11's Performance page under Administration, Configuration, Development, with the Caching section open and Browser and proxy cache maximum age set to 5 min, above Drupal's note that the value is used as max-age in Cache-Control headers.","Administration > Configuration > Development > Performance, with the page cache maximum age at 5 minutes.",788,551,{"type":39,"title":320,"code":321,"language":42,"highlighted":322},"Five minutes, then check the header","drush -y config:set system.performance cache.page.max_age 300\ndrush -y config:export\ncurl -sI https:\u002F\u002Fcms.example.com\u002Fjsonapi | grep -i cache-control\n# cache-control: max-age=300, public","drush \u003Cspan class=\"token parameter variable\">-y\u003C\u002Fspan> config:set system.performance cache.page.max_age \u003Cspan class=\"token number\">300\u003C\u002Fspan>\ndrush \u003Cspan class=\"token parameter variable\">-y\u003C\u002Fspan> config:export\n\u003Cspan class=\"token function\">curl\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-sI\u003C\u002Fspan> https:\u002F\u002Fcms.example.com\u002Fjsonapi \u003Cspan class=\"token operator\">|\u003C\u002Fspan> \u003Cspan class=\"token function\">grep\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-i\u003C\u002Fspan> cache-control\n\u003Cspan class=\"token comment\"># cache-control: max-age=300, public\u003C\u002Fspan>",{"type":22,"html":324},"\u003Cp>If you have turned CORS on in Drupal's \u003Ccode>services.yml\u003C\u002Fcode>, allow one origin, or \u003Ccode>*\u003C\u002Fcode> without credentials. Two or more origins, or an origin pattern, add \u003Ccode>Vary: Origin\u003C\u002Fcode> to every response, and nothing gets cached. Credentials are fine with one origin, so a preview setup like \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fconsumer_node_preview\">Consumer Node Preview\u003C\u002Fa>'s keeps caching, and an editor's signed-in preview skips the cache anyway.\u003C\u002Fp>","Turning it on",{"type":17,"layout":18,"title":327,"regions":328},"Caching content",{"content":329},[330,332,337,339],{"type":22,"html":331},"\u003Cp>The index and menus are cached by default. Set \u003Ccode>cache.content\u003C\u002Fcode> and the same cache keeps everything else Druxt fetches from Drupal: entities, collections, views and route lookups.\u003C\u002Fp>",{"type":39,"title":333,"code":334,"language":335,"highlighted":336},"nuxt.config.js, with content caching","export default {\n  druxt: {\n    cache: { content: true, secret: process.env.DRUXT_CACHE_SECRET },\n  },\n}","js","\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">cache\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">content\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">secret\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> process\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>env\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token constant\">DRUXT_CACHE_SECRET\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":338},"\u003Cp>On a local copy of druxtjs.org, a warm docs page went from 11 calls to Drupal to none. A cold crawl, every page visited once, went from 438 calls on 0.24.0 to 157, a third of them on the first page, where schema refresh reads Drupal's displays. Only successful anonymous responses are kept, so a signed-in visitor still reaches Drupal, and a page published after someone saw its 404 shows straight away.\u003C\u002Fp>",{"type":22,"html":340},"\u003Cp>Content changes far more often than menus, so pair it with the clear from Drupal, below. Each cache area holds at most 50 MB of stored JSON per consumer by default, set by \u003Ccode>cache.maxSize\u003C\u002Fcode>, and can take about three times that in memory, so size it against your Nuxt process.\u003C\u002Fp>",{"type":17,"layout":18,"regions":342,"title":368},{"content":343},[344,346,350,352,354,358,362,366],{"type":22,"html":345},"\u003Cp>Five minutes is a long time to wait for a menu edit, so Drupal can clear the cache the moment content changes. Give Druxt a secret:\u003C\u002Fp>",{"type":39,"title":347,"code":348,"language":335,"highlighted":349},"nuxt.config.js","export default {\n  druxt: {\n    cache: { secret: process.env.DRUXT_CACHE_SECRET },\n  },\n}","\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">cache\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">secret\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> process\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>env\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token constant\">DRUXT_CACHE_SECRET\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":351},"\u003Cp>A server-rendered production build then has a clear endpoint, \u003Ccode>POST \u002F_druxt\u002Fcache\u002Fclear\u003C\u002Fcode>, locked to the secret in an \u003Ccode>X-Druxt-Secret\u003C\u002Fcode> header. Each Nuxt process keeps its own cache, so a site running several clears each one. List Druxt, or a module that adds it such as \u003Ccode>druxt-site\u003C\u002Fcode>, under \u003Ccode>modules\u003C\u002Fcode> rather than \u003Ccode>buildModules\u003C\u002Fcode>, or the endpoint answers 404 under \u003Ccode>nuxt start\u003C\u002Fcode>.\u003C\u002Fp>",{"type":22,"html":353},"\u003Cp>On druxtjs.org, Drupal's Purge module sends that POST whenever a cache tag is invalidated, so a saved menu link reaches the frontend on the next request. It clears Druxt's cache only, so a CDN in front of Drupal needs its own purger, and a \u003Ccode>drush cr\u003C\u002Fcode> doesn't send it.\u003C\u002Fp>",{"type":39,"title":355,"code":356,"language":42,"highlighted":357},"Purge, sending the clear to Nuxt","drush -y pm:install purge purge_ui purge_purger_http \\\n  purge_queuer_coretags purge_processor_lateruntime\ndrush p:purger-add httpbundled   # note the purger ID it prints\n# At Configuration > Development > Performance > Purge, set the purger to\n# Nuxt's host and port, path \u002F_druxt\u002Fcache\u002Fclear, method POST,\n# invalidation type tag, and a header X-Druxt-Secret.","drush \u003Cspan class=\"token parameter variable\">-y\u003C\u002Fspan> pm:install purge purge_ui purge_purger_http \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  purge_queuer_coretags purge_processor_lateruntime\ndrush p:purger-add httpbundled   \u003Cspan class=\"token comment\"># note the purger ID it prints\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># At Configuration > Development > Performance > Purge, set the purger to\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># Nuxt's host and port, path \u002F_druxt\u002Fcache\u002Fclear, method POST,\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># invalidation type tag, and a header X-Druxt-Secret.\u003C\u002Fspan>",{"type":39,"title":359,"code":360,"language":48,"highlighted":361},"settings.php, keeping the secret out of exported config","$config['purge_purger_http.settings.PURGER_ID']['headers'][0]['value'] = getenv('DRUXT_CACHE_SECRET');","\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'purge_purger_http.settings.PURGER_ID'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'headers'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token number\">0\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'value'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token function\">getenv\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'DRUXT_CACHE_SECRET'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>",{"type":39,"title":363,"code":364,"language":42,"highlighted":365},"Check the endpoint, expecting 204","curl -s -o \u002Fdev\u002Fnull -w '%{http_code}\\n' -X POST \\\n  -H \"X-Druxt-Secret: your-secret\" http:\u002F\u002Fnuxt:3000\u002F_druxt\u002Fcache\u002Fclear","\u003Cspan class=\"token function\">curl\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-s\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-o\u003C\u002Fspan> \u002Fdev\u002Fnull \u003Cspan class=\"token parameter variable\">-w\u003C\u002Fspan> \u003Cspan class=\"token string\">'%{http_code}\\n'\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-X\u003C\u002Fspan> POST \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  \u003Cspan class=\"token parameter variable\">-H\u003C\u002Fspan> \u003Cspan class=\"token string\">\"X-Druxt-Secret: your-secret\"\u003C\u002Fspan> http:\u002F\u002Fnuxt:3000\u002F_druxt\u002Fcache\u002Fclear",{"type":22,"html":367},"\u003Cp>Every tag invalidation clears the whole cache, which is blunt but cheap. The \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fhow-to\u002Fcaching#clear-on-save-with-purge\">caching guide\u003C\u002Fa> has the details.\u003C\u002Fp>","Clearing it from Drupal",{"type":17,"layout":18,"regions":370,"title":380},{"content":371},[372,374,378],{"type":22,"html":373},"\u003Cp>Druxt builds its schemas, which fields a component renders and in what order, from Manage display at build time, so moving a field meant a rebuild. With \u003Ccode>schema.refresh\u003C\u002Fcode> on, the server regenerates them after each clear: move a field, save, and the next page has it.\u003C\u002Fp>",{"type":39,"title":375,"code":376,"language":335,"highlighted":377},"nuxt.config.js, with schema refresh","export default {\n  modules: ['druxt-site'], \u002F\u002F or druxt-schema, not buildModules\n  druxt: {\n    cache: { secret: process.env.DRUXT_CACHE_SECRET },\n    schema: { refresh: true },\n  },\n}","\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">modules\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'druxt-site'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token comment\">\u002F\u002F or druxt-schema, not buildModules\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">cache\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">secret\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> process\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>env\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token constant\">DRUXT_CACHE_SECRET\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">schema\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">refresh\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":379},"\u003Cp>The first page after a clear pays for it, with about two dozen configuration reads on druxtjs.org, and every page after that is back to normal. The server generates with the build's access to Drupal, so those credentials need to be in production too. A static site can run the same refresh from its own Node server with \u003Ccode>createSchemaRefresh\u003C\u002Fcode>, which the \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fhow-to\u002Fcaching#schemas-after-a-display-change\">caching guide\u003C\u002Fa> covers.\u003C\u002Fp>","Display changes without a rebuild",{"type":17,"layout":18,"regions":382,"title":388},{"content":383},[384,386],{"type":22,"html":385},"\u003Cp>Over a network that adds up. On a local copy of druxtjs.org, alternating the versions page by page, 0.25.0 rendered warm pages nearly a third faster than 0.24.0 in all 66 paired visits, and in about a sixth of 0.24.0's time with content caching on. Inside a hosting cluster, where Drupal is a couple of milliseconds away, the time saved is small, and the win is Drupal answering fewer requests. Put a proxy in front of your own Drupal and count: how many times does your site ask the same question?\u003C\u002Fp>",{"type":22,"html":387},"\u003Cp>Drupal is getting quicker too. ptmkenny's core patch in \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdrupal\u002Fissues\u002F3626255\">#3626255\u003C\u002Fa> reads JSON:API's cached normalizations in bulk. On the docs collection behind druxtjs.org's sitemap, with normalizations warm, it took 1301 database queries down to 77 with identical responses. It needs reviews, and the issue covers running it on 11.x.\u003C\u002Fp>","What it saves",{"type":17,"layout":18,"regions":390,"title":394},{"content":391},[392],{"type":22,"html":393},"\u003Cul>\u003Cli>No more first-load flicker. Druxt components no longer fetch their data again when a production build hydrates, so any \u003Ccode>components:extend\u003C\u002Fcode> hook you added to hide it can go.\u003C\u002Fli>\u003Cli>Pages don't jump on a refetch, because components keep their content while they fetch.\u003C\u002Fli>\u003Cli>Views follow the language you asked for, not the site default.\u003C\u002Fli>\u003Cli>A 403 or 404 from a route renders through Nuxt's own error page.\u003C\u002Fli>\u003Cli>Vue and Vuex are peer dependencies, so a site uses its own copy, and a mismatched second copy can't stop Nuxt at startup.\u003C\u002Fli>\u003C\u002Ful>","Also in 0.25.0",{"type":17,"layout":18,"regions":396,"title":406},{"content":397},[398,400,404],{"type":22,"html":399},"\u003Cp>The packages version together, so move them in one go:\u003C\u002Fp>",{"type":39,"title":401,"code":402,"language":42,"highlighted":403},"Upgrade the set together","# Drop any druxt package your site doesn't use\nnpm install druxt@^0.25.0 druxt-site@^0.15.0 druxt-router@^0.32.0 \\\n  druxt-entity@^0.29.0 druxt-menu@^0.22.0 druxt-blocks@^0.18.0 \\\n  druxt-views@^0.23.0 druxt-breadcrumb@^0.18.0 druxt-schema@^0.12.0 \\\n  axios@^0.34.0\n# Still on Vue 2.6? Add vue@^2.7.14 vuex@^3.6.2\n# Using druxt-auth? Take it to 0.5.1 in the same install.","\u003Cspan class=\"token comment\"># Drop any druxt package your site doesn't use\u003C\u002Fspan>\n\u003Cspan class=\"token function\">npm\u003C\u002Fspan> \u003Cspan class=\"token function\">install\u003C\u002Fspan> druxt@^0.25.0 druxt-site@^0.15.0 druxt-router@^0.32.0 \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  druxt-entity@^0.29.0 druxt-menu@^0.22.0 druxt-blocks@^0.18.0 \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  druxt-views@^0.23.0 druxt-breadcrumb@^0.18.0 druxt-schema@^0.12.0 \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  axios@^0.34.0\n\u003Cspan class=\"token comment\"># Still on Vue 2.6? Add vue@^2.7.14 vuex@^3.6.2\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># Using druxt-auth? Take it to 0.5.1 in the same install.\u003C\u002Fspan>",{"type":22,"html":405},"\u003Cp>The deprecated \u003Ccode>DruxtField\u003C\u002Fcode> components are off unless you set \u003Ccode>druxt.entity.components.fields: true\u003C\u002Fcode>, and a \u003Ccode>consola\u003C\u002Fcode> 2.15.3 pin can go. Wrapper components no longer render module data as HTML attributes, so \u003Ccode>entity=\"[object Object]\"\u003C\u002Fcode> is gone from your markup. Wrappers still read it from \u003Ccode>$attrs\u003C\u002Fcode>. The rest is in the \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fapi\u002Fpackages\u002Fdruxt\u002FCHANGELOG\">changelog on druxtjs.org\u003C\u002Fa>.\u003C\u002Fp>","Upgrading",{"type":17,"layout":18,"title":408,"regions":409},"Why 0.25.0, and not Nuxt 4?",{"content":410},[411,413],{"type":22,"html":412},"\u003Cp>Nuxt 2 is end of life, so it is a fair question. Druxt had years of improvements and fixes sitting unreleased, and I would rather finish them than carry them across half done. 0.25.0 gets that work out, and gives the 2.0.0 port a stable, fast baseline to start from. Get it right on the stack it runs on today, then upgrade, then add new features, then polish.\u003C\u002Fp>",{"type":22,"html":414},"\u003Cp>There is one more 0.x release planned before 1.0.0. 0.26.0 is mostly waiting on a \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdecoupled_router\u002Fissues\u002F3111456\">decoupled router patch\u003C\u002Fa>, so a site whose default language has no URL prefix stops redirecting its front page to \u003Ccode>\u002Fen\u003C\u002Fcode>, plus whatever other multilingual fixes turn up before then. Then 1.0.0, the last Druxt on Nuxt 2, with Druxt 2.0.0, the Nuxt 4 rewrite under \u003Ccode>@druxt\u003C\u002Fcode>, on track for a first beta alongside it, building on the caching model 0.25.0 settled on.\u003C\u002Fp>",{"type":17,"layout":18,"title":416,"regions":417},"Experimenting with WebSockets",{"content":418},[419,421],{"type":22,"html":420},"\u003Cp>Something to play with while you wait. \u003Ca href=\"https:\u002F\u002Fumami.demo.druxtjs.org\u002Fplay\">Umami Go\u003C\u002Fa> is a multiplayer card game on the Umami demo, dealt from the magazine's recipes and rendered by Druxt. It is also where you can try the upcoming Druxt WebSockets module, still in development: edit a recipe in Drupal, and it changes in every hand holding it.\u003C\u002Fp>",{"type":56,"src":422,"alt":423,"caption":424,"width":296,"height":297},"\u002Fimages\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023\u002Fumami-go.jpg","The Umami Go page: the title Umami Go in white italic serif on a dark brown lattice, the line 'A card game for two to five, dealt from this magazine's recipes', a Start a table button with a box to join by code, a fan of five recipe cards, and a 'Built on Druxt' badge with the Druxt icon.","Umami Go, a card game dealt from Drupal's Umami recipes, with a live table over the upcoming Druxt WebSockets module.",{"type":17,"layout":18,"regions":426},{"content":427},[428],{"type":139,"description":429,"url":430,"gitpod":142,"drupalUrl":431},"\u003Cp>Druxt 1.0.0 and 2.x move as fast as sponsorship lets them.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002Fdruxt\u002Fdruxt.js","https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdruxt","7 min",[434,435,437],{"path":156,"source":154},{"path":436,"source":157},"\u002Fwriting\u002Fjsonapi-views-120-20260812",{"path":438,"source":157},"\u002Fwriting\u002Fdruxt-auth-050-two-ways-to-sign-in-20260926",{"loc":159},"articles-data\u002Fdruxt-0250-caching-and-performance-buffs-20261023",[442,164,443],"caching","Nuxt","6N_4-TQ-2qH6Dm39OKyx6EwZo3-LYkx3s_wQFYx6190",{"id":446,"title":447,"articleType":6,"categories":448,"date":449,"description":450,"extension":11,"hiddenCategories":451,"meta":452,"paragraphs":453,"path":156,"readingTime":550,"related":551,"sitemap":555,"stem":556,"topics":557,"__hash__":559},"articleEntries\u002Farticles-data\u002Fdruxt-drupal-13x-resource-list-yours-20260909.json","Druxt (for Drupal) 1.3.x; the resource list is yours",[8,172],"2026-09-09T19:30:00+10:00","Druxt's Drupal module answers for twelve JSON:API resources, hardcoded, all of them describing structure. 1.3.0 makes that list yours, and here's what I'd add to mine. Any JSON:API client benefits, not only Druxt.",[13],{},[454,463,475,503,511,526,536,544],{"type":17,"layout":18,"regions":455},{"content":456},[457,459,461],{"type":22,"html":458},"\u003Cp>Druxt's Drupal module carries a list of twelve JSON:API resources it answers for. It's been the same twelve since 2021, hardcoded in a PHP array, and changing it has meant carrying a patch.\u003C\u002Fp>",{"type":22,"html":460},"\u003Cp>1.3.0, tagged today, makes that list yours. The twelve stay exactly as they are, so nothing a site exposes changes when it updates. What is new is being able to choose, and the first thing I would choose is the one my frontend has always had to guess at: the toolbar an administrator configured for a text format.\u003C\u002Fp>\u003Cp>A CORS default ships in the same release. If you keep a proxy rule in front of your frontend so that authenticated calls succeed, this is the release where you delete it.\u003C\u002Fp>",{"type":22,"html":462},"\u003Cp>The module name suggests otherwise, but this is a Drupal-side change. Any client holding the right permission gets the same reads.\u003C\u002Fp>",{"type":17,"title":464,"layout":18,"regions":465},"What it answers today",{"content":466},[467,469,473],{"type":22,"html":468},"\u003Cp>Anyone holding the \u003Ccode>access druxt resources\u003C\u002Fcode> permission can read these, whatever access control would otherwise have said:\u003C\u002Fp>",{"type":39,"title":470,"code":471,"language":472},"The list, as it stands","block--block\nconfigurable_language--configurable_language\nentity_form_display--entity_form_display\nentity_form_mode--entity_form_mode\nentity_view_display--entity_view_display\nentity_view_mode--entity_view_mode\nfield_config--field_config\nfield_storage_config--field_storage_config\njsonapi_resource_config--jsonapi_resource_config\nmenu--menu\nmenu_link_content--menu_link_content\nview--view","text",{"type":22,"html":474},"\u003Cp>Every one describes structure, and all of it is configuration, with one exception: \u003Ccode>menu_link_content\u003C\u002Fcode> is content, and we will come back to it. None of them describes how the site prefers things to look or behave, and with the list compiled in, nobody could add one that did.\u003C\u002Fp>",{"type":17,"title":476,"layout":18,"regions":477},"Starting with rich text",{"content":478},[479,481,483,485,491,493,495,499,501],{"type":22,"html":480},"\u003Cp>A frontend offering an editing experience has no way to read the toolbar an administrator configured, so it ships whatever buttons its developer chose and hopes they match.\u003C\u002Fp>",{"type":22,"html":482},"\u003Cp>The reason it cannot read it is the interesting part. The only core permission that grants access to a text format's configuration is \u003Cstrong>administer filters\u003C\u002Fstrong>, which core itself flags \u003Ccode>restrict access\u003C\u002Fcode>. Hand it to an author and they can rewrite the text format, one of the shorter routes to an XSS. So the choice has been between a permission you cannot give them and a frontend guessing at a toolbar.\u003C\u002Fp>",{"type":22,"html":484},"\u003Cp>A checkbox is the third option, on a new settings form at \u003Cem>Configuration &gt; Web services &gt; Druxt\u003C\u002Fem>. It grants read where \u003Cstrong>administer filters\u003C\u002Fstrong> grants write, and it grants it to everyone holding \u003Ccode>access druxt resources\u003C\u002Fcode> instead of to one trusted account. List \u003Ccode>editor--editor\u003C\u002Fcode> and the frontend reads the toolbar and builds from it; change the toolbar in Drupal and the editor changes, with no rebuild: 10 buttons for \u003Ccode>basic_html\u003C\u002Fcode>, 16 for \u003Ccode>full_html\u003C\u002Fcode>.\u003C\u002Fp>",{"type":56,"src":486,"alt":487,"caption":488,"width":489,"height":490},"\u002Fimages\u002Fwriting\u002Fdruxt-130-toolbars.png","Two toolbars stacked, each labelled with one word. Above, labelled Drupal: the buttons sit in grey tiles and the separators are tiles of their own. Below, labelled Frontend: the same buttons in the same order in a plain row with thin dividers. In both, reading left to right: bold, italic, strikethrough, superscript, subscript, code, remove format, link, bulleted list, numbered list, block quote, image, table, horizontal line, a heading dropdown, code block and source editing.","\u003Cp>The same toolbar, twice. Drupal draws the separators as draggable tiles because that screen is for editing the toolbar; the frontend draws them as dividers.\u003C\u002Fp>",2560,744,{"type":22,"html":492},"\u003Cp>Which filters run is a second tick, on \u003Ccode>filter_format--filter_format\u003C\u002Fcode>. It tells a frontend whether a caption arrives in a \u003Ccode>data-caption\u003C\u002Fcode> attribute or in the markup. Fetch it once first: JSON:API hands over whole entities, so that is every format on the site and each one's filter settings, an open blob contrib modules fill with their own keys.\u003C\u002Fp>",{"type":22,"html":494},"\u003Cp>What comes back is Drupal's vocabulary, not CKEditor's. \u003Ccode>drupalInsertImage\u003C\u002Fcode> is Drupal's name for \u003Ccode>uploadImage\u003C\u002Fcode>, and an item your build has not loaded is dropped with a console warning rather than an error, so the editor comes up fine, quietly missing buttons the format says it has.\u003C\u002Fp>",{"type":39,"title":496,"code":497,"language":335,"highlighted":498},"Reading the toolbar, server side","\u002F\u002F Nuxt 2. fetch() runs on the server; druxt-site injects $druxt.\nasync fetch() {\n  \u002F\u002F A string: an object does not survive the query serialiser, and 400s.\n  const { data } = await this.$druxt.getCollection(\n    'editor--editor',\n    'filter[drupal_internal__format]=basic_html',\n  )\n  this.items = ((data[0] || {}).attributes || {}).settings.toolbar.items || []\n}","\u003Cspan class=\"token comment\">\u002F\u002F Nuxt 2. fetch() runs on the server; druxt-site injects $druxt.\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">async\u003C\u002Fspan> \u003Cspan class=\"token function\">fetch\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token comment\">\u002F\u002F A string: an object does not survive the query serialiser, and 400s.\u003C\u002Fspan>\n  \u003Cspan class=\"token keyword\">const\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> data \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token keyword\">await\u003C\u002Fspan> \u003Cspan class=\"token keyword\">this\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>$druxt\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">getCollection\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\n    \u003Cspan class=\"token string\">'editor--editor'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token string\">'filter[drupal_internal__format]=basic_html'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n  \u003Cspan class=\"token keyword\">this\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>items \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>data\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token number\">0\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">||\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>attributes \u003Cspan class=\"token operator\">||\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>settings\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>toolbar\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>items \u003Cspan class=\"token operator\">||\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":22,"html":500},"\u003Cp>All of which is me saying: expect a Druxt CKEditor module in the future. Mounting the editor is where the work actually is. CKEditor 5 uses static class blocks, which the bundler Nuxt 2 ships cannot parse, so today you load it from a script tag and match the version Drupal ships rather than the one npm defaults to. That is exactly the sort of thing a module should absorb so nobody else meets it.\u003C\u002Fp>",{"type":22,"html":502},"\u003Cp>Neither is ticked by default. Updating writes the same twelve into configuration and stops there, and nothing on the list is readable by anyone until \u003Ccode>access druxt resources\u003C\u002Fcode> is granted, which installing grants to nobody. Changing the list needs \u003Ccode>administer druxt\u003C\u002Fcode>, which ships restricted.\u003C\u002Fp>",{"type":17,"title":504,"layout":18,"regions":505},"Then everything else",{"content":506},[507,509],{"type":22,"html":508},"\u003Cp>What else is a frontend inventing? All three of these are configuration entities, so they are already in that checkbox list, unticked:\u003C\u002Fp>",{"type":22,"html":510},"\u003Cul>\u003Cli>\u003Cstrong>Image styles.\u003C\u002Fstrong> Effects carry the dimensions a \u003Ccode>srcset\u003C\u002Fcode> needs. The derivative URLs are solved already, by \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fjsonapi_image_styles\">JSON:API Image Styles\u003C\u002Fa> and \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fconsumer_image_styles\">Consumer Image Styles\u003C\u002Fa>, after \u003Ca href=\"https:\u002F\u002Fwww.lullabot.com\u002Farticles\u002Fdecoupled-drupal-hard-problems-image-styles\">Lullabot named it in 2018\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Editorial workflow.\u003C\u002Fstrong> Content moderation's states and transitions would let an editing interface show where a document sits and which moves are legal, instead of a hardcoded draft-and-published pair\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Bulk actions.\u003C\u002Fstrong> \u003Ccode>action--action\u003C\u002Fcode> carries every action a site has configured, so a decoupled content list could offer exactly those\u003C\u002Fli>\n\u003C\u002Ful>",{"type":17,"title":512,"layout":18,"regions":513},"What it will not answer for",{"content":514},[515,517,519,521],{"type":22,"html":516},"\u003Cp>Druxt answers Drupal's entity access check with a yes for any listed route, and lifts JSON:API's limit on filtering by fields the requester could not otherwise see. A listed resource is readable by anyone holding \u003Ccode>access druxt resources\u003C\u002Fcode>, whatever the site would otherwise have said, and on a decoupled site that permission often goes to anonymous.\u003C\u002Fp>",{"type":22,"html":518},"\u003Cp>The list has one axis. It says what, never who: the same yes, site-wide, for everyone holding the permission. That suits configuration, which answers the same whoever is asking. Content varies per entity by published state, owner and field access, so one yes is never right for it. \u003Ccode>user--user\u003C\u002Fcode> would hand over every account, blocked ones included, and enough filtering to enumerate them.\u003C\u002Fp>",{"type":22,"html":520},"\u003Cp>\u003Ccode>menu_link_content\u003C\u002Fcode> is the exception, and it is content, grandfathered because it shipped in the hardcoded list. It is also the resource that gives a frontend the least, so I maintain \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fjsonapi_menu_items\">JSON:API Menu Items\u003C\u002Fa> to cover the gap.\u003C\u002Fp>",{"type":56,"src":522,"alt":523,"caption":524,"width":296,"height":525},"\u002Fimages\u002Fwriting\u002Fdruxt-130-settings-form.png","The Druxt settings form at Configuration, Web services, Druxt. A list headed Exposed resources with a checkbox per configuration entity type. Ticked: block, editor, entity_form_display, entity_form_mode, entity_view_display, entity_view_mode, field_config, field_storage_config, menu, menu_link_content, view. Unticked: action, base_field_override, block_content_type, date_format, filter_format, image_style, media_type, node_type, oauth2_scope, oauth2_token_type, taxonomy_vocabulary, user_role. No content entity types are offered. Help text below reads that a checked resource is readable by anyone with the access druxt resources permission, whatever the site would otherwise allow.","\u003Cp>Configuration entity types only, and no content ones offered at all. \u003Ccode>editor--editor\u003C\u002Fcode> and \u003Ccode>filter_format--filter_format\u003C\u002Fcode> are separate ticks, and the help text under the list says outright that a ticked resource is readable by anyone holding the permission.\u003C\u002Fp>",900,{"type":17,"title":527,"layout":18,"regions":528},"CORS, and the proxy rule you can delete",{"content":529},[530,532,534],{"type":22,"html":531},"\u003Cp>One more thing, and it takes a step out of everyone's setup. On a site that has not configured CORS itself, Druxt turns it on and allows every header, and now sets \u003Ccode>allowedMethods\u003C\u002Fcode> too. That list is what the library underneath advertises methods from, and core ships it empty, so the browser refused the preflight and the request never left.\u003C\u002Fp>",{"type":22,"html":533},"\u003Cp>Anonymous \u003Ccode>GET\u003C\u002Fcode> was never affected, since a simple request is not preflighted. What changes is everything else: writes, and reads that set an \u003Ccode>Authorization\u003C\u002Fcode> header. If you keep a proxy rule or a middleware in front of your frontend so that authenticated calls succeed, this is the release where you delete it. Worth checking rather than assuming, because a refused preflight answers 204 with a clean log, and \u003Ccode>curl\u003C\u002Fcode> never reproduces it: only a browser enforces the response.\u003C\u002Fp>",{"type":22,"html":535},"\u003Cp>A site that configured \u003Ccode>cors.config\u003C\u002Fcode> itself is untouched, and credentials stay off, so cookies and sessions still do not travel cross-origin. An \u003Ccode>Authorization\u003C\u002Fcode> header does. The \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002F\">documentation site\u003C\u002Fa> was rebuilt around \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Ftutorials\">tutorials\u003C\u002Fa> last week, and the \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Ftutorials\u002Fauthentication\">login flow\u003C\u002Fa> is the one to start with if authenticated requests are what you have been putting off.\u003C\u002Fp>",{"type":17,"title":537,"layout":18,"regions":538},"What are you working around?",{"content":539},[540,542],{"type":22,"html":541},"\u003Cp>Enough about what I would tick. What I want to know is what you are already working around. Anyone building against Drupal from outside it has written something that guesses at a value Drupal already holds. Mine was a toolbar. It is a checkbox now.\u003C\u002Fp>",{"type":22,"html":543},"\u003Cp>So what is yours, and what would you build if you could simply read it?\u003C\u002Fp>",{"type":17,"layout":18,"regions":545},{"content":546},[547],{"type":139,"description":548,"url":549,"gitpod":142,"drupalUrl":431},"\u003Cp>The Drupal module this ships in. If your projects lean on modules like it, sponsoring is what keeps them maintained.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002Fdruxt\u002Fdruxt_drupal","6 min",[552,553,554],{"path":438,"source":154},{"path":159,"source":157},{"path":153,"source":157},{"loc":156},"articles-data\u002Fdruxt-drupal-13x-resource-list-yours-20260909",[164,558,163],"CORS","BiQYxPC-kI_K971pi4dFLLoMvY-L07QIX6prHG07RqA",1793165036451]