Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/authors/writing-tutorials.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,10 @@ The reader sees only the variant matching their OS. Their choice persists across
the browser; BAS detected when the tutorial is opened from inside Business Application Studio).
After they pick an OS, that choice is remembered.

**Missing variants.** When a step doesn't cover the reader's chosen OS, the platform shows the
closest match and a small banner: "No Linux instructions for this step — showing macOS." Use
this when one OS path is genuinely identical to another.
**Missing variants.** When a step doesn't cover the reader's chosen OS, the platform shows a
placeholder message — "No `<OS>` instructions for this step." — instead of substituting another
OS's content. No automatic closest-match substitution is made. To avoid gaps, include a panel for
every OS you expect readers to use, or omit the group entirely if the step is OS-agnostic.

**Existing OS-tabbed tutorials get the picker for free.** No author migration required — the
heuristic detects OS-flavored OPTION blocks automatically.
Expand Down
15 changes: 14 additions & 1 deletion packages/mcp/tutorial-step-slicer.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,22 @@ import * as metrics from '@tutorials/core/metrics.js';
import { isFlagEnabled } from '@tutorials/core/feature-flags/db-flags.js';
import { parseMarkdownSteps } from '@tutorials/core/tutorial-markdown-steps.js';
import { absolutizeImagePaths } from '@tutorials/core/tutorial-markdown.js';
import { renderDirectivesAsText } from '../../scripts/parsers/directives.js';
import { filterVariants } from './option-filter.js';
import { collectTroubleshootEntries } from '../../scripts/parsers/blockquote-troubleshoot-collect.js';

// Lazy import: scripts/parsers/directives.js is a TypeScript source file that is NOT
// bundled into gen/srv at cds build time. At CF runtime the workspace path is absent;
// the pre-built _shared/parsers.bundle.mjs (created by scripts/bundle-shared.cjs, same
// pattern as freshness-detector.js after #3029) is used instead.
// Memoised so the async resolution only happens once per process lifetime.
let _directivesMod;
async function getDirectivesMod() {
if (_directivesMod) return _directivesMod;
try { _directivesMod = await import('../../scripts/parsers/directives.js'); }
catch { _directivesMod = await import('./_shared/parsers.bundle.mjs'); }
return _directivesMod;
}

const NS = 'com.sap.developers.ims';
const LOG = cds.log('mcp-slicer');

Expand Down Expand Up @@ -256,6 +268,7 @@ async function loadAndParseMarkdown(slug) {
}

const { repo, branch } = await getRepoProvenance(slug);
const { renderDirectivesAsText } = await getDirectivesMod();
const steps = new Map();
for (const s of parsedSteps) {
const md = renderDirectivesAsText(absolutizeImagePaths(s.markdown, { slug, repo, branch }));
Expand Down
23 changes: 22 additions & 1 deletion scripts/lint-rules/__tests__/authoring-aids.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { authorNotesInProdRule, qaOnlyInProdRule } from '../authoring-aids.js';
import { authorNotesInProdRule, qaOnlyInProdRule, promotionWarningRule } from '../authoring-aids.js';

describe('authorNotesInProdRule', () => {
it('finds an inline author note', () => {
Expand Down Expand Up @@ -35,3 +35,24 @@ describe('qaOnlyInProdRule', () => {
expect(qaOnlyInProdRule.scan('test', ['Hello.'], ['Hello.'])).toHaveLength(0);
});
});

describe('promotionWarningRule', () => {
it('finds a promotion-warning directive in PROD source', () => {
const lines = [':::promotion-warning[resolve before PROD]', 'This step needs a screenshot refresh.', ':::'];
const findings = promotionWarningRule.scan('test', lines, lines);
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('notice');
expect(findings[0].rule).toBe('promotion-warning-in-prod-source');
expect(findings[0].line).toBe(1);
expect(findings[0].message).toContain('promotion-warning');
});

it('does NOT flag a promotion-warning pattern mid-line (must be fence-opener)', () => {
const lines = ['Some text :::promotion-warning inline'];
expect(promotionWarningRule.scan('test', lines, lines)).toHaveLength(0);
});

it('returns empty for clean source', () => {
expect(promotionWarningRule.scan('test', ['Hello.'], ['Hello.'])).toHaveLength(0);
});
});
38 changes: 38 additions & 0 deletions scripts/lint-rules/authoring-aids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,41 @@ export const qaOnlyInProdRule: Rule = {
return findings
},
}

/**
* promotionWarningRule — notices when PROD source contains `:::promotion-warning` blocks.
*
* A `:::promotion-warning` block is an author-placed marker indicating content that should
* be reviewed or resolved before the tutorial is promoted to PROD. It is analogous to
* `:::qa-only` but is intended to flag specific concerns rather than hide content.
*
* Assumption: the `:::promotion-warning` directive has not yet been deployed to the
* platform as of this rule's addition; this rule pre-declares the lint gate for when
* authors begin using the pattern. Owners should confirm the exact directive syntax
* once it ships and adjust the regex if needed.
*
* Severity: 'notice' (advisory — never blocks a build even with --strict).
*/
export const promotionWarningRule: Rule = {
id: 'promotion-warning-in-prod-source',
describe: 'PROD source contains `:::promotion-warning` blocks. These are author-placed "do not promote" markers that should be resolved before PROD publication.',

scan(slug, _lines, rawLines): LintFinding[] {
const findings: LintFinding[] = []

for (let i = 0; i < rawLines.length; i++) {
if (/^:::promotion-warning/.test(rawLines[i])) {
findings.push({
rule: 'promotion-warning-in-prod-source',
slug,
file: slug,
line: i + 1,
message: '`:::promotion-warning` block found in PROD source. Resolve the flagged concern and remove the block before promoting to PROD.',
excerpt: rawLines[i].trim(),
severity: 'notice',
})
}
}
return findings
},
}
50 changes: 50 additions & 0 deletions scripts/lint-rules/data/cli-deprecations.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,36 @@
"replacement": "Use `no-route: true` in manifest.yml",
"docs": "https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#no-route"
},
{
"tool": "cf",
"match": "cf\\s+map-route\\s+.*--hostname",
"severity": "warn",
"since": "cf-cli v8",
"message": "The `--hostname` flag was removed from `cf map-route` in cf CLI v8. Pass the full route as a positional argument instead.",
"replacement": "cf map-route <app> <domain> --hostname <h> → cf map-route <app> <hostname>.<domain>",
"docs": "https://docs.cloudfoundry.org/devguide/deploy-apps/routes-domains.html",
"needsOwnerReview": true
},
{
"tool": "cf",
"match": "cf\\s+create-route\\s+.*--hostname",
"severity": "warn",
"since": "cf-cli v8",
"message": "The `--hostname` flag was removed from `cf create-route` in cf CLI v8. Specify the full hostname directly.",
"replacement": "cf create-route <space> <domain> --hostname <h> → cf create-route <space> <hostname>.<domain>",
"docs": "https://docs.cloudfoundry.org/devguide/deploy-apps/routes-domains.html",
"needsOwnerReview": true
},
{
"tool": "cf",
"match": "cf\\s+push\\s+.*--no-route\\b",
"severity": "warn",
"since": "cf-cli v8",
"message": "The `--no-route` flag was removed from `cf push` in cf CLI v8. Set `no-route: true` in manifest.yml instead.",
"replacement": "Add `no-route: true` to manifest.yml",
"docs": "https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#no-route",
"needsOwnerReview": true
},
{
"tool": "cds",
"match": "cds\\s+serve\\s+--in-memory",
Expand All @@ -26,6 +56,16 @@
"replacement": "cds watch",
"docs": "https://cap.cloud.sap/docs/tools/"
},
{
"tool": "cds",
"match": "cds\\s+run\\b",
"severity": "warn",
"since": "@sap/cds 6",
"message": "`cds run` was deprecated in favour of `cds watch` for local development. `cds watch` also monitors for file changes and restarts automatically.",
"replacement": "cds watch",
"docs": "https://cap.cloud.sap/docs/tools/",
"needsOwnerReview": true
},
{
"tool": "npm",
"match": "npm\\s+install\\s+--save\\b",
Expand All @@ -34,5 +74,15 @@
"message": "`npm install --save` is the default since npm 5 and the flag is unnecessary.",
"replacement": "npm install <package>",
"docs": "https://docs.npmjs.com/cli/v10/commands/npm-install"
},
{
"tool": "npm",
"match": "npm\\s+install\\s+-g\\s+@sap/cds\\b",
"severity": "warn",
"since": "@sap/cds-dk 7",
"message": "Install the CAP CLI via `@sap/cds-dk`, not `@sap/cds`. `@sap/cds` is the runtime library; `@sap/cds-dk` is the development kit that provides the `cds` command.",
"replacement": "npm install -g @sap/cds-dk",
"docs": "https://cap.cloud.sap/docs/get-started/",
"needsOwnerReview": true
}
]
13 changes: 13 additions & 0 deletions scripts/lint-rules/deprecated-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
*
* Scans bash/sh/shell/console/terminal/powershell/cmd fences for commands
* matching known deprecated patterns from cli-deprecations.json.
*
* QUARTERLY REVIEW REMINDER (spec task 4.4):
* Each quarter, review cli-deprecations.json for:
* - New CF CLI v8+ removals: https://docs.cloudfoundry.org/cf-cli/cf-help.html
* - New CDS/CAP CLI changes: https://cap.cloud.sap/docs/releases/
* - New BTP CLI changes: https://help.sap.com/docs/btp/sap-business-technology-platform/btp-cli
* - New MBT CLI changes: https://sap.github.io/cloud-mta-build-tool/
* Rules marked `needsOwnerReview: true` should be verified and the flag removed
* once an owner confirms the deprecation against the official changelog.
*/
import { createRequire } from 'node:module'
import type { LintFinding } from '../lint-tutorial-markdown.js'
Expand All @@ -17,6 +26,10 @@ interface CliDeprecation {
message: string
replacement: string
docs: string
/** When true, the rule was added based on best-effort research and needs
* an owner to verify against the official changelog before treating as
* authoritative. The rule still fires — it's advisory at 'warning' level. */
needsOwnerReview?: boolean
}

const DEPRECATIONS: CliDeprecation[] = require('./data/cli-deprecations.json')
Expand Down
4 changes: 2 additions & 2 deletions scripts/lint-tutorial-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { iframeNonAllowlistedHostRule } from './lint-rules/iframe-non-allowliste
import { authorNameWithoutProfileRule } from './lint-rules/author-name-without-profile'
import { directiveUnknownRule } from './lint-rules/directive-unknown'
import { deprecatedCliRule } from './lint-rules/deprecated-cli.js'
import { authorNotesInProdRule, qaOnlyInProdRule } from './lint-rules/authoring-aids.js'
import { authorNotesInProdRule, qaOnlyInProdRule, promotionWarningRule } from './lint-rules/authoring-aids.js'
import { directiveSuggestUiRule } from './lint-rules/directive-suggest-ui'
import { directiveSuggestKbdRule } from './lint-rules/directive-suggest-kbd'

Expand Down Expand Up @@ -209,7 +209,7 @@ const fenceIndentDiscipline: Rule = {
},
}

const RULES: Rule[] = [indentedNumberedListItem, fenceIndentDiscipline, iframeNonAllowlistedHostRule, authorNameWithoutProfileRule, directiveUnknownRule, deprecatedCliRule, authorNotesInProdRule, qaOnlyInProdRule, directiveSuggestUiRule, directiveSuggestKbdRule]
const RULES: Rule[] = [indentedNumberedListItem, fenceIndentDiscipline, iframeNonAllowlistedHostRule, authorNameWithoutProfileRule, directiveUnknownRule, deprecatedCliRule, authorNotesInProdRule, qaOnlyInProdRule, promotionWarningRule, directiveSuggestUiRule, directiveSuggestKbdRule]

/**
* Rule: branch-syntax (issue #172 PR 3).
Expand Down
Loading