Compare commits

..
Author SHA1 Message Date
yu-i-i 6b0f0601fa Admin Tools: Manage users and Manage projects pages 2026-01-26 03:06:40 +01:00
yu-i-i e24e13ae3b Hotfix 5.5.6-ext-3.3 2025-12-01 16:58:53 +01:00
yu-i-i 00c07f2f76 Linked URL: add sanitize and normalize 2025-11-28 04:46:01 +01:00
yu-i-i 1f65a48eb8 README for extended CE 2025-11-12 02:06:01 +01:00
yu-i-i c1fb202139 Add service for Linked URL feature 2025-11-12 02:00:07 +01:00
yu-i-i 8710772330 Sandboxed compiles: adjust clsi-profile 2025-11-12 02:00:07 +01:00
yu-i-i 37652aaeef Track changes: Fix import after upstream converted DocumentUpdaterHandler and CollaboratorsGetter to ESM 2025-11-12 02:00:07 +01:00
yu-i-i 033e6ab5a5 Authentication: Fix import after upstream converted AuthenticationController to ESM 2025-11-12 02:00:07 +01:00
yu-i-i f0a38e0868 Template gallery: Fix import after upstream converted AuthenticationController, ProjectGetter, ProjectLocator and DocumentUpdaterHandler` to ESM 2025-11-12 02:00:07 +01:00
yu-i-i e799444a9a Template gallery: Fix import after upstream converted CompileManager, ClsiManager and RateLimiterMiddleware to ESM 2025-11-12 02:00:07 +01:00
yu-i-i cc66e0d990 Template gallery: Fix OLModal usage (see upstream commit 19b3834) 2025-11-12 02:00:07 +01:00
yu-i-i 2c953c8738 Authentication: Fix import after upstream converted PermissionsManager, RateLimiterMiddleware, UserCreator and SAMLIdentityManager to ESM 2025-11-12 02:00:06 +01:00
yu-i-i b721fbf481 Track changes: Fix import after upstream converted ChatManager, UserInfoManager and UserInfoController to ESM 2025-11-12 02:00:06 +01:00
Rudy ERCEK f8cd7874ef Add MAX_UPLOAD_SIZE environment variable 2025-11-12 02:00:06 +01:00
yu-i-i 56e0a12a8f Fix style of Templates button in navbar 2025-11-12 02:00:06 +01:00
yu-i-i 95dd9eafa6 Fix: comments thread does not necessarily belong to current document 2025-11-12 02:00:06 +01:00
yu-i-i 410eac15a2 Fix import after upstream converted AuthorizationMiddleware to ESM 2025-11-12 02:00:06 +01:00
yu-i-i 25d7c1bd42 Track changes: Use getProjectRanges introduced in the upstream 2025-11-12 02:00:06 +01:00
yu-i-i dff7c27496 Rename @node-saml/node-saml patch for v5.1.0 2025-11-12 02:00:06 +01:00
yu-i-i d885217a6f Fix imports after upstream converted UserController and CaptchaMiddleware to ESM 2025-11-12 02:00:06 +01:00
yu-i-i 0d665f4dda Fix import after upstream converted ErrorController to ESM 2025-11-12 02:00:05 +01:00
yu-i-i 6f99004dde Bring back deleteFile removed upstream 2025-11-12 02:00:05 +01:00
yu-i-i 72d182f553 Symbol palette: ui components were moved to shared folder 2025-11-12 02:00:05 +01:00
yu-i-i 7b826ad5ac Move Sympol Palette code to modules, closes #63 2025-11-12 02:00:05 +01:00
yu-i-i a5af5ce053 Template gallery: ui components were moved to shared folder 2025-11-12 02:00:05 +01:00
yu-i-i a313cf0bc7 passport-saml was updated to v5.1.0 2025-11-12 02:00:05 +01:00
yu-i-i b7c97898e4 Fix: always include repository prefix in sandboxed compiles image name 2025-11-12 02:00:05 +01:00
yu-i-i 2be928d820 Fix template publishing to align with upstream project context changes 2025-11-12 02:00:05 +01:00
yu-i-i 03fa1be85b Remove LDAP admin check via group search in ldap-passport 2025-11-12 02:00:05 +01:00
yu-i-i d18bdb907f toggleSymbolPalette now in useEditorPropertiesContext 2025-11-12 02:00:04 +01:00
yu-i-i 927b07700c Set use-ai capability to false 2025-11-12 02:00:04 +01:00
yu-i-i 52c27d8e78 Track changes: convert to mjs 2025-11-12 02:00:04 +01:00
yu-i-i 62f7c071bf Track changes: use getTrackedChangesUserIds introduced in the mainstream 2025-11-12 02:00:04 +01:00
yu-i-i b9d3f8e739 Remove OVERLEAF_OIDC_DISABLE_JIT_ACCOUNT_CREATION in favor of OVERLEAF_OIDC_ALLOWED_EMAIL_DOMAINS 2025-11-12 02:00:04 +01:00
Synésio Neto 0ceee70efc Introduce an environment variable to allow JIT OIDC users creation based on their email address domain. 2025-11-12 02:00:04 +01:00
yu-i-i fabbbc5d39 Introduce an environment variable to control user creation in OIDC authentication, closes #47 2025-11-12 02:00:04 +01:00
David Rotermund 57586773b6 I need the --network=host settings for building the container 2025-11-12 02:00:04 +01:00
yu-i-i 4c1b93d6ff Avoid DEP0174 by removing async from callback-based getGroupPolicyForUser 2025-11-12 02:00:04 +01:00
yu-i-i 4a289cc0a0 Fix login page 2025-11-12 02:00:03 +01:00
yu-i-i 83c379e1e3 Symbol Palette: make close button visible 2025-11-12 02:00:03 +01:00
yu-i-i f2bf106633 Template Gallery: replace markdown-it with marked 2025-11-12 02:00:03 +01:00
yu-i-i 9cc9997ba5 Refactor Template Gallery; resolves #38 and #39
- Replace free-text license input with a select box
- Improve visual presentation of modals and enhance keyboard interaction
2025-11-12 02:00:03 +01:00
yu-i-i 201e5a505b Refactor Sandboxed Compiles 2025-11-12 02:00:03 +01:00
yu-i-i 282c43ca47 Symbol Palette: improve keyboard input experience 2025-11-12 02:00:03 +01:00
yu-i-i c1b1c21a4d Symbol Palette: get rid of @reach/tabs 2025-11-12 02:00:03 +01:00
yu-i-i e50f802879 Add 'poll' to clsi seccomp profile, fixes minted. Thanks, David. 2025-11-12 02:00:02 +01:00
yu-i-i 1c93b441fa Make Template Gallery optional; rename environment variables 2025-11-12 02:00:02 +01:00
yu-i-i 19a657bac8 Add Template Gallery support 2025-11-12 02:00:02 +01:00
yu-i-i 2487ff8c43 Track changes / comments: update backend to support frontend changes 2025-11-12 02:00:02 +01:00
yu-i-i c274422eb6 Allow EXTERNAL_AUTH to be undefined, fixes #26 2025-11-12 02:00:02 +01:00
yu-i-i 54e19c4e87 Symbol palette: switch to 'OL' UI components and apply minor cosmetic changes 2025-11-12 02:00:02 +01:00
yu-i-i f8444a4d50 Make OVERLEAF_OIDC_USER_ID_FIELD support 'email' as a value 2025-11-12 02:00:02 +01:00
yu-i-i f9489bdf1a See upstream commit 42ee56e 2025-11-12 02:00:01 +01:00
yu-i-i 232a4276ad Fix glitches in symbol palette after switching to Bootstrap 5 2025-11-12 02:00:01 +01:00
yu-i-i b61083ee2a Whitelist /oidc/login endpoint, fixes #21 2025-11-12 02:00:01 +01:00
yu-i-i e3311bb8f3 Add ENV variables to control SAML signature validation 2025-11-12 02:00:01 +01:00
yu-i-i b930b9c3e8 Re-export doLogout (was removed from exports in commit b9fb636). 2025-11-12 02:00:01 +01:00
yu-i-i f734ab92a9 Refactor authentication code; add OIDC support 2025-11-12 02:00:01 +01:00
yu-i-i 3962eed5ca Allow adding extra flags to LaTeX compiler through environment variable 2025-11-12 02:00:01 +01:00
yu-i-i 13443e2bd4 Enable LDAP and SAML authentication support 2025-11-12 02:00:00 +01:00
yu-i-i 519c1961ce Enable Symbol Palette 2025-11-12 02:00:00 +01:00
yu-i-i 730086b3f5 Allow selecting a TeX Live image for a project 2025-11-12 02:00:00 +01:00
Sam Van den Vonder 9c42a2ee54 Enable Sandboxed Compiles feature 2025-11-12 02:00:00 +01:00
yu-i-i dfd99d8ad0 Enable autocomplete of reference keys feature 2025-11-12 02:00:00 +01:00
yu-i-i 905b21efb1 Enable track changes and comments feature 2025-11-11 04:34:38 +01:00
yu-i-i 2a2d3ab36e Redirect non-existing links to Overleaf page 2025-11-11 04:34:38 +01:00
3625 changed files with 149249 additions and 213660 deletions
+14 -13
View File
@@ -4,19 +4,25 @@ about: Report a bug
title: ''
labels: type:bug
assignees: ''
---
<!--
Note: This form should only be used to report bugs in the
Extended CE release of Overleaf.
Note: If you are using www.overleaf.com and have a problem,
or if you would like to request a new feature please contact
the support team at support@overleaf.com
This form should only be used to report bugs in the
Community Edition release of Overleaf.
-->
<!-- BUG REPORT TEMPLATE -->
## Steps to Reproduce
<!-- Describe the steps leading up to when / where you found the bug. -->
<!-- Screenshots may be helpful here. -->
@@ -25,28 +31,23 @@ Note: This form should only be used to report bugs in the
3.
## Expected Behaviour
<!-- What should have happened when you completed the steps above? -->
## Observed Behaviour
<!-- What actually happened when you completed the steps above? -->
<!-- Screenshots may be helpful here. -->
## Context
<!-- How has this issue affected you? What were you trying to accomplish? -->
## Technical Info
<!-- Provide any technical details that may be applicable (or N/A if not applicable). -->
- URL:
- Browser Name and version:
- Operating System and version (desktop or mobile):
- Signed in as:
- Project and/or file:
* URL:
* Browser Name and version:
* Operating System and version (desktop or mobile):
* Signed in as:
* Project and/or file:
## Analysis
<!--- Optionally, document investigation of / suggest a fix for the bug, e.g. 'comes from this line / commit' -->
+6 -2
View File
@@ -1,7 +1,11 @@
## Description
<!-- Goal of the pull request -->
## Related issues / Pull Requests
## Related issues / Pull Requests
<!-- Fixes #xyz, Contributes to #xyz, Related to #xyz-->
## Contributor Agreement
- [ ] I confirm I have signed the [Contributor License Agreement](https://github.com/overleaf/overleaf/blob/main/CONTRIBUTING.md#contributor-license-agreement)
-5
View File
@@ -4,8 +4,3 @@
# user defined files
.env
docker-compose.override.yml
# local-only agent docs + plan notes (never track: they hold credentials/creds refs)
references/
AGENTS.md
todo.md
@@ -1,16 +0,0 @@
diff --git a/dist/index.mjs b/dist/index.mjs
index b781835b37a1262510bdd66d8d3b399a076e9d68..312c314186d85bb3bc2ab2620e99bca259ef7167 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -44,10 +44,9 @@ import { z as z2 } from "zod/v4";
// src/tool/types.ts
import { z } from "zod/v4";
-var LATEST_PROTOCOL_VERSION = "2025-11-25";
+var LATEST_PROTOCOL_VERSION = "2025-06-18";
var SUPPORTED_PROTOCOL_VERSIONS = [
LATEST_PROTOCOL_VERSION,
- "2025-06-18",
"2025-03-26",
"2024-11-05"
];
@@ -1,831 +0,0 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 39215ae..b44cb76 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -187,16 +187,23 @@ Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-function insertCompletionText(state$1, text, from, to) {
+function insertCompletionText(state$1, text, from, to, extend) {
let { main } = state$1.selection, fromOff = from - main.from, toOff = to - main.from;
return Object.assign(Object.assign({}, state$1.changeByRange(range => {
if (range != main && from != to &&
state$1.sliceDoc(range.from + fromOff, range.from + toOff) != state$1.sliceDoc(from, to))
return { range };
- let lines = state$1.toText(text);
+ let change = {
+ from: range.from + fromOff,
+ to: to == main.from ? range.to : range.from + toOff,
+ insert: text instanceof state.Text ? text : state$1.toText(text),
+ };
+ if (extend) {
+ extend(state$1, change);
+ }
return {
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
- range: state.EditorSelection.cursor(range.from + fromOff + lines.length)
+ changes: change,
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
};
})), { scrollIntoView: true, userEvent: "input.complete" });
}
@@ -389,7 +396,9 @@ const completionConfig = state.Facet.define({
filterStrict: false,
compareCompletions: (a, b) => a.label.localeCompare(b.label),
interactionDelay: 75,
- updateSyncTime: 100
+ updateSyncTime: 100,
+ // overleaf: default to at top which is default CM6 behaviour
+ unfilteredResultsAtEnd: false
}, {
defaultKeymap: (a, b) => a && b,
closeOnBlur: (a, b) => a && b,
@@ -744,6 +753,7 @@ function score(option) {
(option.type ? 1 : 0);
}
function sortOptions(active, state) {
+ var _a;
let options = [];
let sections = null;
let addOption = (option) => {
@@ -763,7 +773,8 @@ function sortOptions(active, state) {
let getMatch = a.result.getMatch;
if (a.result.filter === false) {
for (let option of a.result.options) {
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
}
}
else {
@@ -790,15 +801,42 @@ function sortOptions(active, state) {
}
}
let result = [], prev = null;
+ const priorityIndices = new Map();
let compare = conf.compareCompletions;
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
+ // overleaf: Deduplicate results with dedup options
+ // The goal is to keep only the highest priority option, in the
+ // highest scoring position.
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
+ if (key) {
+ // Handle merging specifically for deduplicated items item
+ const currentOptionIndex = priorityIndices.get(key);
+ if (currentOptionIndex === undefined) {
+ priorityIndices.set(key, result.length);
+ result.push(opt);
+ prev = opt.completion;
+ }
+ else {
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
+ result[currentOptionIndex] = opt;
+ if (currentOptionIndex === result.length - 1) {
+ prev = opt.completion;
+ }
+ }
+ }
+ continue;
+ }
+ // overleaf: end
let cur = opt.completion;
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
- prev.apply != cur.apply || prev.boost != cur.boost)
+ if (!prev || prev.label != cur.label)
+ result.push(opt);
+ // overleaf: we're already handling deduplication, so skip extra merges
+ else if (prev.deduplicate)
result.push(opt);
else if (score(opt.completion) > score(prev))
result[result.length - 1] = opt;
+ else if (opt.completion.info)
+ result[result.length - 1] = opt;
prev = opt.completion;
}
return result;
@@ -817,8 +855,9 @@ class CompletionDialog {
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
}
static build(active, state, id, prev, conf, didSetActive) {
- if (prev && !didSetActive && active.some(s => s.isPending))
- return prev.setDisabled();
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
+ // if (prev && !didSetActive && active.some(s => s.isPending))
+ // return prev.setDisabled()
let options = sortOptions(active, state);
if (!options.length)
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
@@ -1017,13 +1056,14 @@ const completionState = state.StateField.define({
view.EditorView.contentAttributes.from(f, state => state.attrs)
]
});
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
function applyCompletion(view, option) {
const apply = option.completion.apply || option.completion.label;
let result = view.state.field(completionState).active.find(a => a.source == option.source);
if (!(result instanceof ActiveResult))
return false;
if (typeof apply == "string")
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
else
apply(view, option.completion, result.from, result.to);
return true;
@@ -1559,20 +1599,42 @@ interpreted as indicating a placeholder.
function snippet(template) {
let snippet = Snippet.parse(template);
return (editor, completion, from, to) => {
- let { text, ranges } = snippet.instantiate(editor.state, from);
- let { main } = editor.state.selection;
- let spec = {
- changes: { from, to: to == main.from ? main.to : to, insert: state.Text.of(text) },
- scrollIntoView: true,
- annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined
- };
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
+ let ranges = [];
+ let totalOffset = 0;
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
+ if (range != main && from != to &&
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
+ return { range };
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
+ let change = {
+ from: range.from + fromOff,
+ to: range.from + toOff,
+ insert: state.Text.of(text)
+ };
+ let originalTo = change.to;
+ let offset = change.insert.length + fromOff;
+ if (completion.extend) {
+ completion.extend(editor.state, change);
+ offset += originalTo - change.to;
+ }
+ for (const fieldRange of fieldRanges) {
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
+ }
+ totalOffset += offset;
+ return {
+ changes: change,
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
+ };
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
if (ranges.length)
spec.selection = fieldSelection(ranges, 0);
if (ranges.some(r => r.field > 0)) {
let active = new ActiveSnippet(ranges, 0);
- let effects = spec.effects = [setActive.of(active)];
- if (editor.state.field(snippetState, false) === undefined)
- effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ spec.effects.push(setActive.of(active));
+ if (editor.state.field(snippetState, false) === undefined) {
+ spec.effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ }
}
editor.dispatch(editor.state.update(spec));
};
@@ -1746,7 +1808,8 @@ const completeAnyWord = context => {
const defaults = {
brackets: ["(", "[", "{", "'", '"'],
before: ")]}:;>",
- stringPrefixes: []
+ stringPrefixes: [],
+ buildInsert: (state, range, open, close) => open + close,
};
const closeBracketEffect = state.StateEffect.define({
map(value, mapping) {
@@ -1854,8 +1917,8 @@ function insertBracket(state$1, bracket) {
for (let tok of tokens) {
let closed = closing(state.codePointAt(tok, 0));
if (bracket == tok)
- return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
- : handleOpen(state$1, tok, closed, conf.before || defaults.before);
+ return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
+ : handleOpen(state$1, tok, closed, conf.before || defaults.before, conf);
if (bracket == closed && closedBracketAt(state$1, state$1.selection.main.from))
return handleClose(state$1, tok, closed);
}
@@ -1877,17 +1940,21 @@ function prevChar(doc, pos) {
let prev = doc.sliceString(pos - 2, pos);
return state.codePointSize(state.codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
}
-function handleOpen(state$1, open, close, closeBefore) {
+function handleOpen(state$1, open, close, closeBefore, config) {
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state$1.changeByRange(range => {
+ var _a;
if (!range.empty)
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
effects: closeBracketEffect.of(range.to + open.length),
range: state.EditorSelection.range(range.anchor + open.length, range.head + open.length) };
let next = nextChar(state$1.doc, range.head);
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
- return { changes: { insert: open + close, from: range.head },
- effects: closeBracketEffect.of(range.head + open.length),
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
+ const insert = (_a = buildInsert(state$1, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
+ return { changes: { insert, from: range.head },
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
range: state.EditorSelection.cursor(range.head + open.length) };
+ }
return { range: dont = range };
});
return dont ? null : state$1.update(changes, {
@@ -1909,18 +1976,36 @@ function handleClose(state$1, _open, close) {
}
// Handles cases where the open and close token are the same, and
// possibly triple quotes (as in `"""abc"""`-style quoting).
-function handleSame(state$1, token, allowTriple, config) {
+function handleSame(state$1, token, allowDouble, allowTriple, config) {
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state$1.changeByRange(range => {
+ var _a, _b, _c;
if (!range.empty)
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
effects: closeBracketEffect.of(range.to + token.length),
range: state.EditorSelection.range(range.anchor + token.length, range.head + token.length) };
let pos = range.head, next = nextChar(state$1.doc, pos), start;
- if (next == token) {
+ if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
+ (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
+ nodeStart(state$1, start)) {
+ return { changes: { insert: token + token + token + token, from: pos },
+ effects: closeBracketEffect.of(pos + token.length),
+ range: state.EditorSelection.cursor(pos + token.length) };
+ }
+ else if (allowDouble && state$1.sliceDoc(pos - token.length, pos) == token &&
+ (start = canStartStringAt(state$1, pos - token.length, stringPrefixes)) > -1 &&
+ nodeStart(state$1, start)) {
+ let insert = (_a = buildInsert(state$1, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
+ range: state.EditorSelection.cursor(pos + token.length) };
+ }
+ else if (next == token) {
if (nodeStart(state$1, pos)) {
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ let insert = (_b = buildInsert(state$1, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: state.EditorSelection.cursor(pos + token.length) };
}
else if (closedBracketAt(state$1, pos)) {
@@ -1930,18 +2015,13 @@ function handleSame(state$1, token, allowTriple, config) {
range: state.EditorSelection.cursor(pos + content.length) };
}
}
- else if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
- (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
- nodeStart(state$1, start)) {
- return { changes: { insert: token + token + token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
- range: state.EditorSelection.cursor(pos + token.length) };
- }
else if (state$1.charCategorizer(pos)(next) != state.CharCategory.Word) {
- if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes))
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes)) {
+ const insert = (_c = buildInsert(state$1, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: state.EditorSelection.cursor(pos + token.length) };
+ }
}
return { range: dont = range };
});
@@ -2086,6 +2166,7 @@ exports.completionKeymap = completionKeymap;
exports.completionStatus = completionStatus;
exports.currentCompletions = currentCompletions;
exports.deleteBracketPair = deleteBracketPair;
+exports.getCompletionTooltip = getCompletionTooltip;
exports.hasNextSnippetField = hasNextSnippetField;
exports.hasPrevSnippetField = hasPrevSnippetField;
exports.ifIn = ifIn;
@@ -2093,8 +2174,10 @@ exports.ifNotIn = ifNotIn;
exports.insertBracket = insertBracket;
exports.insertCompletionText = insertCompletionText;
exports.moveCompletionSelection = moveCompletionSelection;
+exports.nextChar = nextChar;
exports.nextSnippetField = nextSnippetField;
exports.pickedCompletion = pickedCompletion;
+exports.prevChar = prevChar;
exports.prevSnippetField = prevSnippetField;
exports.selectedCompletion = selectedCompletion;
exports.selectedCompletionIndex = selectedCompletionIndex;
diff --git a/dist/index.d.cts b/dist/index.d.cts
index b57b8f6..fce47ab 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
import * as _lezer_common from '@lezer/common';
/**
@@ -73,6 +73,19 @@ interface Completion {
a `{name}` object.
*/
section?: string | CompletionSection;
+ /**
+ Can be used to alter the change created when the completion is applied
+ */
+ extend?: ExtendCompletion;
+ /**
+ If multiple sources return the same result, use this field to specifiy a
+ deduplication key as well as a priority. For each unique key, only the
+ completion with the highest priority will be shown.
+ */
+ deduplicate?: {
+ key: string;
+ priority: number;
+ };
}
/**
The type returned from
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
picking a completion.
*/
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
+type ExtendCompletion = (state: EditorState, change: {
+ from: number;
+ to: number;
+ insert: string | Text;
+}) => void;
/**
Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
interface CompletionConfig {
/**
@@ -441,6 +459,10 @@ interface CompletionConfig {
milliseconds.
*/
updateSyncTime?: number;
+ /**
+ overleaf: Move unfiltered results after the filtered ones
+ */
+ unfilteredResultsAtEnd?: boolean;
}
/**
@@ -514,6 +536,8 @@ applies the snippet.
*/
declare function snippetCompletion(template: string, completion: Completion): Completion;
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
+
/**
Returns a command that moves the completion selection forward or
backward by the given amount.
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
these prefixes before the opening quote.
*/
stringPrefixes?: string[];
+ /**
+ An optional callback for overriding the content that's inserted
+ based on surrounding characters
+ */
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
}
/**
Extension to enable bracket-closing behavior. When a closeable
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
take care of running this for user input.)
*/
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
+declare function nextChar(doc: Text, pos: number): string;
+declare function prevChar(doc: Text, pos: number): string;
/**
Returns an extension that enables autocompletion.
@@ -636,4 +667,5 @@ the currently selected completion.
*/
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
diff --git a/dist/index.d.ts b/dist/index.d.ts
index b57b8f6..fce47ab 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
import * as _lezer_common from '@lezer/common';
/**
@@ -73,6 +73,19 @@ interface Completion {
a `{name}` object.
*/
section?: string | CompletionSection;
+ /**
+ Can be used to alter the change created when the completion is applied
+ */
+ extend?: ExtendCompletion;
+ /**
+ If multiple sources return the same result, use this field to specifiy a
+ deduplication key as well as a priority. For each unique key, only the
+ completion with the highest priority will be shown.
+ */
+ deduplicate?: {
+ key: string;
+ priority: number;
+ };
}
/**
The type returned from
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
picking a completion.
*/
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
+type ExtendCompletion = (state: EditorState, change: {
+ from: number;
+ to: number;
+ insert: string | Text;
+}) => void;
/**
Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
interface CompletionConfig {
/**
@@ -441,6 +459,10 @@ interface CompletionConfig {
milliseconds.
*/
updateSyncTime?: number;
+ /**
+ overleaf: Move unfiltered results after the filtered ones
+ */
+ unfilteredResultsAtEnd?: boolean;
}
/**
@@ -514,6 +536,8 @@ applies the snippet.
*/
declare function snippetCompletion(template: string, completion: Completion): Completion;
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
+
/**
Returns a command that moves the completion selection forward or
backward by the given amount.
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
these prefixes before the opening quote.
*/
stringPrefixes?: string[];
+ /**
+ An optional callback for overriding the content that's inserted
+ based on surrounding characters
+ */
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
}
/**
Extension to enable bracket-closing behavior. When a closeable
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
take care of running this for user input.)
*/
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
+declare function nextChar(doc: Text, pos: number): string;
+declare function prevChar(doc: Text, pos: number): string;
/**
Returns an extension that enables autocompletion.
@@ -636,4 +667,5 @@ the currently selected completion.
*/
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
diff --git a/dist/index.js b/dist/index.js
index 4729223..9361a53 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1,4 +1,4 @@
-import { Annotation, StateEffect, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Text, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
+import { Annotation, StateEffect, Text, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
import { Direction, logException, showTooltip, EditorView, ViewPlugin, getTooltip, Decoration, WidgetType, keymap } from '@codemirror/view';
import { syntaxTree, indentUnit } from '@codemirror/language';
@@ -185,16 +185,23 @@ Helper function that returns a transaction spec which inserts a
completion's text in the main selection range, and any other
selection range that has the same text in front of it.
*/
-function insertCompletionText(state, text, from, to) {
+function insertCompletionText(state, text, from, to, extend) {
let { main } = state.selection, fromOff = from - main.from, toOff = to - main.from;
return Object.assign(Object.assign({}, state.changeByRange(range => {
if (range != main && from != to &&
state.sliceDoc(range.from + fromOff, range.from + toOff) != state.sliceDoc(from, to))
return { range };
- let lines = state.toText(text);
+ let change = {
+ from: range.from + fromOff,
+ to: to == main.from ? range.to : range.from + toOff,
+ insert: text instanceof Text ? text : state.toText(text),
+ };
+ if (extend) {
+ extend(state, change);
+ }
return {
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
- range: EditorSelection.cursor(range.from + fromOff + lines.length)
+ changes: change,
+ range: EditorSelection.cursor(change.from + change.insert.length)
};
})), { scrollIntoView: true, userEvent: "input.complete" });
}
@@ -387,7 +394,9 @@ const completionConfig = /*@__PURE__*/Facet.define({
filterStrict: false,
compareCompletions: (a, b) => a.label.localeCompare(b.label),
interactionDelay: 75,
- updateSyncTime: 100
+ updateSyncTime: 100,
+ // overleaf: default to at top which is default CM6 behaviour
+ unfilteredResultsAtEnd: false
}, {
defaultKeymap: (a, b) => a && b,
closeOnBlur: (a, b) => a && b,
@@ -742,6 +751,7 @@ function score(option) {
(option.type ? 1 : 0);
}
function sortOptions(active, state) {
+ var _a;
let options = [];
let sections = null;
let addOption = (option) => {
@@ -761,7 +771,8 @@ function sortOptions(active, state) {
let getMatch = a.result.getMatch;
if (a.result.filter === false) {
for (let option of a.result.options) {
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
}
}
else {
@@ -788,15 +799,42 @@ function sortOptions(active, state) {
}
}
let result = [], prev = null;
+ const priorityIndices = new Map();
let compare = conf.compareCompletions;
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
+ // overleaf: Deduplicate results with dedup options
+ // The goal is to keep only the highest priority option, in the
+ // highest scoring position.
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
+ if (key) {
+ // Handle merging specifically for deduplicated items item
+ const currentOptionIndex = priorityIndices.get(key);
+ if (currentOptionIndex === undefined) {
+ priorityIndices.set(key, result.length);
+ result.push(opt);
+ prev = opt.completion;
+ }
+ else {
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
+ result[currentOptionIndex] = opt;
+ if (currentOptionIndex === result.length - 1) {
+ prev = opt.completion;
+ }
+ }
+ }
+ continue;
+ }
+ // overleaf: end
let cur = opt.completion;
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
- prev.apply != cur.apply || prev.boost != cur.boost)
+ if (!prev || prev.label != cur.label)
+ result.push(opt);
+ // overleaf: we're already handling deduplication, so skip extra merges
+ else if (prev.deduplicate)
result.push(opt);
else if (score(opt.completion) > score(prev))
result[result.length - 1] = opt;
+ else if (opt.completion.info)
+ result[result.length - 1] = opt;
prev = opt.completion;
}
return result;
@@ -815,8 +853,9 @@ class CompletionDialog {
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
}
static build(active, state, id, prev, conf, didSetActive) {
- if (prev && !didSetActive && active.some(s => s.isPending))
- return prev.setDisabled();
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
+ // if (prev && !didSetActive && active.some(s => s.isPending))
+ // return prev.setDisabled()
let options = sortOptions(active, state);
if (!options.length)
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
@@ -1015,13 +1054,14 @@ const completionState = /*@__PURE__*/StateField.define({
EditorView.contentAttributes.from(f, state => state.attrs)
]
});
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
function applyCompletion(view, option) {
const apply = option.completion.apply || option.completion.label;
let result = view.state.field(completionState).active.find(a => a.source == option.source);
if (!(result instanceof ActiveResult))
return false;
if (typeof apply == "string")
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
else
apply(view, option.completion, result.from, result.to);
return true;
@@ -1557,20 +1597,42 @@ interpreted as indicating a placeholder.
function snippet(template) {
let snippet = Snippet.parse(template);
return (editor, completion, from, to) => {
- let { text, ranges } = snippet.instantiate(editor.state, from);
- let { main } = editor.state.selection;
- let spec = {
- changes: { from, to: to == main.from ? main.to : to, insert: Text.of(text) },
- scrollIntoView: true,
- annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined
- };
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
+ let ranges = [];
+ let totalOffset = 0;
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
+ if (range != main && from != to &&
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
+ return { range };
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
+ let change = {
+ from: range.from + fromOff,
+ to: range.from + toOff,
+ insert: Text.of(text)
+ };
+ let originalTo = change.to;
+ let offset = change.insert.length + fromOff;
+ if (completion.extend) {
+ completion.extend(editor.state, change);
+ offset += originalTo - change.to;
+ }
+ for (const fieldRange of fieldRanges) {
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
+ }
+ totalOffset += offset;
+ return {
+ changes: change,
+ range: EditorSelection.cursor(change.from + change.insert.length)
+ };
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
if (ranges.length)
spec.selection = fieldSelection(ranges, 0);
if (ranges.some(r => r.field > 0)) {
let active = new ActiveSnippet(ranges, 0);
- let effects = spec.effects = [setActive.of(active)];
- if (editor.state.field(snippetState, false) === undefined)
- effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ spec.effects.push(setActive.of(active));
+ if (editor.state.field(snippetState, false) === undefined) {
+ spec.effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
+ }
}
editor.dispatch(editor.state.update(spec));
};
@@ -1744,7 +1806,8 @@ const completeAnyWord = context => {
const defaults = {
brackets: ["(", "[", "{", "'", '"'],
before: ")]}:;>",
- stringPrefixes: []
+ stringPrefixes: [],
+ buildInsert: (state, range, open, close) => open + close,
};
const closeBracketEffect = /*@__PURE__*/StateEffect.define({
map(value, mapping) {
@@ -1852,8 +1915,8 @@ function insertBracket(state, bracket) {
for (let tok of tokens) {
let closed = closing(codePointAt(tok, 0));
if (bracket == tok)
- return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
- : handleOpen(state, tok, closed, conf.before || defaults.before);
+ return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
+ : handleOpen(state, tok, closed, conf.before || defaults.before, conf);
if (bracket == closed && closedBracketAt(state, state.selection.main.from))
return handleClose(state, tok, closed);
}
@@ -1875,17 +1938,21 @@ function prevChar(doc, pos) {
let prev = doc.sliceString(pos - 2, pos);
return codePointSize(codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
}
-function handleOpen(state, open, close, closeBefore) {
+function handleOpen(state, open, close, closeBefore, config) {
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state.changeByRange(range => {
+ var _a;
if (!range.empty)
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
effects: closeBracketEffect.of(range.to + open.length),
range: EditorSelection.range(range.anchor + open.length, range.head + open.length) };
let next = nextChar(state.doc, range.head);
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
- return { changes: { insert: open + close, from: range.head },
- effects: closeBracketEffect.of(range.head + open.length),
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
+ const insert = (_a = buildInsert(state, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
+ return { changes: { insert, from: range.head },
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
range: EditorSelection.cursor(range.head + open.length) };
+ }
return { range: dont = range };
});
return dont ? null : state.update(changes, {
@@ -1907,18 +1974,36 @@ function handleClose(state, _open, close) {
}
// Handles cases where the open and close token are the same, and
// possibly triple quotes (as in `"""abc"""`-style quoting).
-function handleSame(state, token, allowTriple, config) {
+function handleSame(state, token, allowDouble, allowTriple, config) {
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
+ let buildInsert = config.buildInsert || defaults.buildInsert;
let dont = null, changes = state.changeByRange(range => {
+ var _a, _b, _c;
if (!range.empty)
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
effects: closeBracketEffect.of(range.to + token.length),
range: EditorSelection.range(range.anchor + token.length, range.head + token.length) };
let pos = range.head, next = nextChar(state.doc, pos), start;
- if (next == token) {
+ if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
+ (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
+ nodeStart(state, start)) {
+ return { changes: { insert: token + token + token + token, from: pos },
+ effects: closeBracketEffect.of(pos + token.length),
+ range: EditorSelection.cursor(pos + token.length) };
+ }
+ else if (allowDouble && state.sliceDoc(pos - token.length, pos) == token &&
+ (start = canStartStringAt(state, pos - token.length, stringPrefixes)) > -1 &&
+ nodeStart(state, start)) {
+ let insert = (_a = buildInsert(state, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
+ range: EditorSelection.cursor(pos + token.length) };
+ }
+ else if (next == token) {
if (nodeStart(state, pos)) {
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ let insert = (_b = buildInsert(state, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: EditorSelection.cursor(pos + token.length) };
}
else if (closedBracketAt(state, pos)) {
@@ -1928,18 +2013,13 @@ function handleSame(state, token, allowTriple, config) {
range: EditorSelection.cursor(pos + content.length) };
}
}
- else if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
- (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
- nodeStart(state, start)) {
- return { changes: { insert: token + token + token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
- range: EditorSelection.cursor(pos + token.length) };
- }
else if (state.charCategorizer(pos)(next) != CharCategory.Word) {
- if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes))
- return { changes: { insert: token + token, from: pos },
- effects: closeBracketEffect.of(pos + token.length),
+ if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes)) {
+ const insert = (_c = buildInsert(state, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
+ return { changes: { insert, from: pos },
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
range: EditorSelection.cursor(pos + token.length) };
+ }
}
return { range: dont = range };
});
@@ -2071,4 +2151,4 @@ function setSelectedCompletion(index) {
return setSelectedEffect.of(index);
}
-export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
@@ -1,381 +0,0 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 46231ae..fb0f9aa 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -592,6 +592,7 @@ class SearchQuery {
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
this.unquoted = this.unquote(this.search);
this.wholeWord = !!config.wholeWord;
+ this.scope = config.scope;
}
/**
@internal
@@ -606,7 +607,7 @@ class SearchQuery {
eq(other) {
return this.search == other.search && this.replace == other.replace &&
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
- this.wholeWord == other.wholeWord;
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
}
/**
@internal
@@ -631,7 +632,12 @@ class QueryType {
}
}
function stringCursor(spec, state, from, to) {
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, buffer, bufferPos) => {
+ return (!test || test(from, to, buffer, bufferPos))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
}
function stringWordTest(doc, categorizer) {
return (from, to, buf, bufPos) => {
@@ -695,9 +701,14 @@ class StringQuery extends QueryType {
}
}
function regexpCursor(spec, state, from, to) {
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, match) => {
+ return (!test || test(from, to, match))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
return new RegExpCursor(state.doc, spec.search, {
ignoreCase: !spec.caseSensitive,
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
+ test: testWithinScope,
}, from, to);
}
function charBefore(str, index) {
@@ -737,10 +748,18 @@ class RegExpQuery extends QueryType {
this.prevMatchInRange(state, curTo, state.doc.length);
}
getReplacement(result) {
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
- : i == "&" ? result.match[0]
- : i != "0" && +i < result.match.length ? result.match[i]
- : m);
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
+ if (i == "&")
+ return result.match[0];
+ if (i == "$")
+ return "$";
+ for (let l = i.length; l > 0; l--) {
+ let n = +i.slice(0, l);
+ if (n > 0 && n < result.match.length)
+ return result.match[n] + i.slice(l);
+ }
+ return m;
+ });
}
matchAll(state, limit) {
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
@@ -1227,7 +1246,9 @@ const searchExtensions = [
exports.RegExpCursor = RegExpCursor;
exports.SearchCursor = SearchCursor;
exports.SearchQuery = SearchQuery;
+exports.StringQuery = StringQuery;
exports.closeSearchPanel = closeSearchPanel;
+exports.createSearchPanel = createSearchPanel;
exports.findNext = findNext;
exports.findPrevious = findPrevious;
exports.getSearchQuery = getSearchQuery;
@@ -1242,4 +1263,6 @@ exports.searchPanelOpen = searchPanelOpen;
exports.selectMatches = selectMatches;
exports.selectNextOccurrence = selectNextOccurrence;
exports.selectSelectionMatches = selectSelectionMatches;
+exports.selectWord = selectWord;
exports.setSearchQuery = setSearchQuery;
+exports.togglePanel = togglePanel;
diff --git a/dist/index.d.cts b/dist/index.d.cts
index 08f5696..663d192 100644
--- a/dist/index.d.cts
+++ b/dist/index.d.cts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
/**
A search cursor provides an iterator over text matches in a
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
itself will be highlighted with `"cm-selectionMatch-main"`.
*/
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
+declare const selectWord: StateCommand;
/**
Select next occurrence of the current selection. Expand selection
to the surrounding word when the selection is empty.
@@ -264,6 +265,13 @@ declare class SearchQuery {
*/
readonly wholeWord: boolean;
/**
+ When set, only include search matches within these ranges
+ */
+ readonly scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
+ /**
Create a query object.
*/
constructor(config: {
@@ -293,6 +301,13 @@ declare class SearchQuery {
Enable whole-word matching.
*/
wholeWord?: boolean;
+ /**
+ The ranges to match within
+ */
+ scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
});
/**
Compare this query to another query.
@@ -307,6 +322,34 @@ declare class SearchQuery {
to: number;
}>;
}
+type SearchResult = typeof SearchCursor.prototype.value;
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
+ readonly spec: SearchQuery;
+ constructor(spec: SearchQuery);
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract getReplacement(result: Result): string;
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
+declare class StringQuery extends QueryType<SearchResult> {
+ constructor(spec: SearchQuery);
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ private prevMatchInRange;
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ getReplacement(_result: SearchResult): string;
+ matchAll(state: EditorState, limit: number): {
+ from: number;
+ to: number;
+ }[] | null;
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
/**
A state effect that updates the current search query. Note that
this only has an effect if the search state has been initialized
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
once).
*/
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
/**
Get the current search query from an editor state.
*/
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
replacement.
*/
declare const replaceAll: Command;
+declare function createSearchPanel(view: EditorView): Panel;
/**
Make sure the search panel is open and focused.
*/
@@ -372,4 +417,4 @@ Default search-related key bindings.
*/
declare const searchKeymap: readonly KeyBinding[];
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 08f5696..663d192 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -1,6 +1,6 @@
import * as _codemirror_state from '@codemirror/state';
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
/**
A search cursor provides an iterator over text matches in a
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
itself will be highlighted with `"cm-selectionMatch-main"`.
*/
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
+declare const selectWord: StateCommand;
/**
Select next occurrence of the current selection. Expand selection
to the surrounding word when the selection is empty.
@@ -264,6 +265,13 @@ declare class SearchQuery {
*/
readonly wholeWord: boolean;
/**
+ When set, only include search matches within these ranges
+ */
+ readonly scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
+ /**
Create a query object.
*/
constructor(config: {
@@ -293,6 +301,13 @@ declare class SearchQuery {
Enable whole-word matching.
*/
wholeWord?: boolean;
+ /**
+ The ranges to match within
+ */
+ scope?: Readonly<{
+ from: number;
+ to: number;
+ }[]>;
});
/**
Compare this query to another query.
@@ -307,6 +322,34 @@ declare class SearchQuery {
to: number;
}>;
}
+type SearchResult = typeof SearchCursor.prototype.value;
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
+ readonly spec: SearchQuery;
+ constructor(spec: SearchQuery);
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
+ abstract getReplacement(result: Result): string;
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
+declare class StringQuery extends QueryType<SearchResult> {
+ constructor(spec: SearchQuery);
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ private prevMatchInRange;
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
+ from: number;
+ to: number;
+ } | null;
+ getReplacement(_result: SearchResult): string;
+ matchAll(state: EditorState, limit: number): {
+ from: number;
+ to: number;
+ }[] | null;
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
+}
/**
A state effect that updates the current search query. Note that
this only has an effect if the search state has been initialized
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
once).
*/
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
/**
Get the current search query from an editor state.
*/
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
replacement.
*/
declare const replaceAll: Command;
+declare function createSearchPanel(view: EditorView): Panel;
/**
Make sure the search panel is open and focused.
*/
@@ -372,4 +417,4 @@ Default search-related key bindings.
*/
declare const searchKeymap: readonly KeyBinding[];
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
diff --git a/dist/index.js b/dist/index.js
index 22172ef..08a9974 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -590,6 +590,7 @@ class SearchQuery {
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
this.unquoted = this.unquote(this.search);
this.wholeWord = !!config.wholeWord;
+ this.scope = config.scope;
}
/**
@internal
@@ -604,7 +605,7 @@ class SearchQuery {
eq(other) {
return this.search == other.search && this.replace == other.replace &&
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
- this.wholeWord == other.wholeWord;
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
}
/**
@internal
@@ -629,7 +630,12 @@ class QueryType {
}
}
function stringCursor(spec, state, from, to) {
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, buffer, bufferPos) => {
+ return (!test || test(from, to, buffer, bufferPos))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
}
function stringWordTest(doc, categorizer) {
return (from, to, buf, bufPos) => {
@@ -693,9 +699,14 @@ class StringQuery extends QueryType {
}
}
function regexpCursor(spec, state, from, to) {
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
+ const testWithinScope = (from, to, match) => {
+ return (!test || test(from, to, match))
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
+ };
return new RegExpCursor(state.doc, spec.search, {
ignoreCase: !spec.caseSensitive,
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
+ test: testWithinScope,
}, from, to);
}
function charBefore(str, index) {
@@ -735,10 +746,18 @@ class RegExpQuery extends QueryType {
this.prevMatchInRange(state, curTo, state.doc.length);
}
getReplacement(result) {
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
- : i == "&" ? result.match[0]
- : i != "0" && +i < result.match.length ? result.match[i]
- : m);
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
+ if (i == "&")
+ return result.match[0];
+ if (i == "$")
+ return "$";
+ for (let l = i.length; l > 0; l--) {
+ let n = +i.slice(0, l);
+ if (n > 0 && n < result.match.length)
+ return result.match[n] + i.slice(l);
+ }
+ return m;
+ });
}
matchAll(state, limit) {
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
@@ -1222,4 +1241,4 @@ const searchExtensions = [
baseTheme
];
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
File diff suppressed because it is too large Load Diff
@@ -1,30 +0,0 @@
diff --git a/index.js b/index.js
index 2fae107d03b30aff9320d135ec79c049c51f298a..32ec707ddbf8937e3e130c3deac1060c308bf439 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,6 @@
'use strict';
-const {PassThrough} = require('stream');
+const {PassThrough, pipeline} = require('stream');
const extend = require('extend');
let debug = () => {};
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
.on('complete', (...params) => handleFinish(params))
.on('finish', (...params) => handleFinish(params));
- requestStream.pipe(delayStream);
+ pipeline(requestStream, delayStream, () => {});
} else {
activeRequest = opts.request(requestOpts, onResponse);
}
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
// No more attempts need to be made, just continue on.
if (streamMode) {
retryStream.emit('response', response);
- delayStream.pipe(retryStream);
+ pipeline(delayStream, retryStream, () => {});
requestStream.on('error', err => {
retryStream.destroy(err);
});
@@ -1,30 +0,0 @@
diff --git a/index.js b/index.js
index 298a351097d70a7fb005b6961f4d58247e391d8f..6a809ace0349d40cb2b6732ad66fd8ad208698ca 100644
--- a/index.js
+++ b/index.js
@@ -1,6 +1,6 @@
'use strict';
-const {PassThrough} = require('stream');
+const {PassThrough, pipeline} = require('stream');
const extend = require('extend');
let debug = () => {};
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
.on('complete', (...params) => handleFinish(params))
.on('finish', (...params) => handleFinish(params));
- requestStream.pipe(delayStream);
+ pipeline(requestStream, delayStream, () => {});
} else {
activeRequest = opts.request(requestOpts, onResponse);
}
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
// No more attempts need to be made, just continue on.
if (streamMode) {
retryStream.emit('response', response);
- delayStream.pipe(retryStream);
+ pipeline(delayStream, retryStream, () => {});
requestStream.on('error', err => {
retryStream.destroy(err);
});
@@ -1,57 +0,0 @@
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
index 1cd6743..4718b97 100644
--- a/lib/sandboxed_module.js
+++ b/lib/sandboxed_module.js
@@ -4,7 +4,7 @@ var Module = require('module');
var fs = require('fs');
var vm = require('vm');
var path = require('path');
-var builtinModules = require('./builtin_modules.json');
+var builtinModules = Module.builtinModules || require('./builtin_modules.json');
var parent = module.parent;
var globalOptions = {};
var registeredBuiltInSourceTransformers = ['coffee'];
@@ -157,12 +157,20 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
var cache = Object.create(null);
var required = this._getRequires();
for (var key in required) {
- var injectedFilename = requireLike(this.filename).resolve(key);
- cache[injectedFilename] = required[key];
+ // Under Yarn PnP, resolution from a transitive dependency's context may fail
+ // for packages not declared in that dependency's package.json. Silently skip
+ // cache pre-population on failure; the mock will still be injected via the
+ // inject map in requireInterceptor or resolved via RecursiveRequireProxy fallback.
+ try {
+ var injectedFilename = requireLike(this.filename).resolve(key);
+ cache[injectedFilename] = required[key];
+ } catch (e) {}
}
cache[this.filename] = this.exports;
var globals = this.globals;
+ // Store the top-level module's filename for PnP fallback resolution
+ var topLevelFilename = this.filename;
var options;
if(!this._options.sourceTransformersSingleOnly && this._options.sourceTransformers){
options = {
@@ -208,8 +216,18 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
if (request in cache) return cache[request];
return require(request);
}
- // cached modules
- var requestedFilename = requireLike(this.filename).resolve(request);
+ // Resolve the requested module filename.
+ // Under Yarn PnP, packages can only resolve their declared dependencies.
+ // When sandboxed-module loads a transitive dependency, the resolution context
+ // may not have access to all needed packages. Fall back to resolving from
+ // the top-level module's context (the module under test).
+ var requestedFilename;
+ try {
+ requestedFilename = requireLike(this.filename).resolve(request);
+ } catch (e) {
+ if (this.filename === topLevelFilename) throw e;
+ requestedFilename = requireLike(topLevelFilename).resolve(request);
+ }
if (requestedFilename in cache) return cache[requestedFilename];
var sandboxedModule = createInnerSandboxedModule(requestedFilename)
return sandboxedModule.exports;
@@ -1,58 +0,0 @@
diff --git a/build/src/index.js b/build/src/index.js
index af5d15e260e2a47588c7c536447fe84bd3f86136..2b63d0c0b1eb6595c7a0bb314c1df792454c1a72 100644
--- a/build/src/index.js
+++ b/build/src/index.js
@@ -115,6 +115,9 @@ function createMultipartStream(boundary, multipart) {
}
else {
part.body.pipe(stream, { end: false });
+ part.body.on('error', (err) => {
+ stream.destroy(err);
+ });
part.body.on('end', () => {
stream.write('\r\n');
stream.write(finale);
@@ -168,25 +171,27 @@ function teenyRequest(reqOpts, callback) {
// Stream mode
const requestStream = streamEvents(new stream_1.PassThrough());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- let responseStream;
- requestStream.once('reading', () => {
- if (responseStream) {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
- }
- else {
- requestStream.once('response', () => {
- (0, stream_1.pipeline)(responseStream, requestStream, () => { });
- });
- }
- });
+ // let responseStream;
+ // requestStream.once('reading', () => {
+ // if (responseStream) {
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
+ // }
+ // else {
+ // requestStream.once('response', () => {
+ // (0, stream_1.pipeline)(responseStream, requestStream, () => { });
+ // });
+ // }
+ // });
+
+
options.compress = false;
teenyRequest.stats.requestStarting();
(0, node_fetch_1.default)(uri, options).then(res => {
- teenyRequest.stats.requestFinished();
- responseStream = res.body;
- responseStream.on('error', (err) => {
- requestStream.emit('error', err);
- });
+ teenyRequest.stats.requestFinished(); stream_1.pipeline(res.body, requestStream, () => {});
+ // responseStream = res.body;
+ // responseStream.on('error', (err) => {
+ // requestStream.emit('error', err);
+ // });
const response = fetchToRequestResponse(options, res);
requestStream.emit('response', response);
}, err => {
-25
View File
@@ -1,25 +0,0 @@
approvedGitRepositories:
- "**"
enableGlobalCache: false
enableScripts: true
enableTelemetry: false
nodeLinker: node-modules
npmMinimalAgeGate: 3d
supportedArchitectures:
cpu:
- current
- arm64
- x64
libc:
- current
- glibc
os:
- current
- darwin
- linux
+23 -9
View File
@@ -1,29 +1,43 @@
# Contributing to Overleaf CE+
Contributing to Overleaf
==========================
Thank you for reading this! If you'd like to report a bug or join in the development
of Overleaf CE+, then here are some notes on how to do that.
of Overleaf, then here are some notes on how to do that.
## Reporting bugs and opening issues
Reporting bugs and opening issues
---------------------------------
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/yu-i-i/overleaf-cep).**
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/overleaf/overleaf/issues).**
If there is then please add any more information that you have, or give it a 👍.
When submitting an issue please describe the issue as clearly as possible, including how to
reproduce the bug, which situations it appears in, what you expected to happen, and what actually happens.
If you can include a screenshot for front end issues that is very helpful.
**Note**: If you are using [www.overleaf.com](www.overleaf.com) and have a problem, or if you would like to request a new feature, please contact the Support team at support@overleaf.com. Raise an issue here only to report bugs in the Community Edition release of Overleaf.
## Pull Requests
Pull Requests
-------------
See [Overleaf wiki](https://github.com/overleaf/overleaf/wiki)
See [our wiki](https://github.com/overleaf/overleaf/wiki)
for how to manage the Overleaf development environment and for our developer guidelines.
We love pull requests, so be bold with them! Don't be afraid of going ahead
and changing something, or adding a new feature. We're very happy to work with you
to get your changes merged into Overleaf.
If you're looking for something to work on, have a look at the [open issues](https://github.com/yu-i-i/overleaf-cep/issues).
If you're looking for something to work on, have a look at the [open issues](https://github.com/overleaf/overleaf/issues).
## Security
Security
--------
Please see [Overleaf security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
Please see [our security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
Contributor License Agreement
-----------------------------
Before we can accept any contributions of code, we need you to agree to our
[Contributor License Agreement](https://docs.google.com/forms/d/e/1FAIpQLSef79XH3mb7yIiMzZw-yALEegS-wyFetvjTiNBfZvf_IHD2KA/viewform?usp=sf_link).
This is to ensure that you own the copyright of your contribution, and that you
agree to give us a license to use it in both the open source version, and the version
of Overleaf running at www.overleaf.com, which may have additional changes.
-53
View File
@@ -1,53 +0,0 @@
# CREDITS
This project includes ideas and adapted code from several open-source projects.\
In most cases the original code has been modified, optimized, or extended.
## Symbol palette
The symbol palette feature is based on the
[original Overleaf implementation](https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette)
The original code was slightly improved, particularly in the parts related to keyboard input.
## LDAP authentication
LDAP authentication was inspired by [this project](https://github.com/smhaller/ldap-overleaf-sl).
The project provided the idea and part of the implementation for adding LDAP users to the user's contacts.
## Real-time track changes and comments
The Track Changes and Comments feature exists largely in the original Overleaf codebase.\
The missing parts were implemented based on [this code](https://github.com/ertuil/overleaf).
The referenced code was fixed, optimized, and extended.
## Sandboxed compiles
The Sandboxed Compiles feature was until recently largely present in the original Overleaf codebase.\
The missing parts were implemented in this project.
## Import file from external URL
The "From External URL" feature exists in the original Overleaf code.\
The missing proxy component was implemented in this project.
## Git integration
The Git integration feature includes parts of the frontend code
from [here](https://github.com/ayaka-notes/overleaf-pro/tree/feat-git-bridge)
## GitHub synchronization
The GitHub synchronization feature includes parts of the frontend code and the OAuth2 backend implementation
from [here](https://github.com/ayaka-notes/overleaf-pro/commit/06a30fe9a0ed75e5ab40b50a8a4e94f43161cf71).
## Sign Up page
The Sign Up page is based on [this code](https://github.com/ayaka-notes/overleaf-pro/tree/feat-public-registeration).
# Acknowledgments
Thanks to the users of the project for valuable feedback, suggestions,
and help in identifying and fixing bugs.
+9 -19
View File
@@ -21,26 +21,19 @@
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. Overleaf runs a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
## Extended Community Edition (CE+)
## Extended Community Edition
The present "extended" version of Overleaf CE includes:
- Sandboxed compiles with TeX Live image selection
- Sign Up page
- Template Gallery
- Sandboxed Compiles with TeX Live image selection
- LDAP authentication
- SAML authentication
- OpenID Connect authentication
- Real-time track changes and comments
- Symbol palette
- Template gallery
- Import file from external URL
- Git integration
- GitHub synchronization
- Zotero integration
- Reference Search and Pick Tool
- Document Import (`.docx`, `.md`) and Export (`.docx`, `.md`, `.html`)
- Advanced administrator tools for managing user accounts and projects
- Logo tools
- Autocomplete of reference keys
- Symbol Palette
- "From External URL" feature
> [!CAUTION]
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
@@ -80,14 +73,11 @@ folder are added.
## Authors
[The Overleaf Team](https://www.overleaf.com/about)\
[yu-i-i](https://github.com/yu-i-i), [davrot](https://github.com/davrot) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md)
[The Overleaf Team](https://www.overleaf.com/about)
[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — Extensions for CE unless otherwise noted
## License
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
Copyright (c) Overleaf, 2014-2026.\
Copyright (c) @yu-i-i, 2024-2026, for CE extensions.
Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md).
Copyright (c) Overleaf, 2014-2025.
+14
View File
@@ -19,6 +19,18 @@ bin/up
Once the services are running, open <http://localhost/launchpad> to create the first admin account.
## TeX Live
Compiling a PDF requires building a TeX Live image to handle the compilation inside Docker:
```shell
docker build texlive -t texlive-full
```
> [!NOTE]
> To compile on a macOS host, you may need to override the path to the Docker socket by creating a `.env` file in this directory, containing
> `DOCKER_SOCKET_PATH=/var/run/docker.sock.raw`
## Development
To avoid running `bin/build && bin/up` after every code change, you can run Overleaf
@@ -59,11 +71,13 @@ each service:
| `web` | 9229 |
| `clsi` | 9230 |
| `chat` | 9231 |
| `contacts` | 9232 |
| `docstore` | 9233 |
| `document-updater` | 9234 |
| `filestore` | 9235 |
| `notifications` | 9236 |
| `real-time` | 9237 |
| `references` | 9238 |
| `history-v1` | 9239 |
| `project-history` | 9240 |
| `linked-url-proxy` | 9241 |
+2 -1
View File
@@ -1,6 +1,6 @@
CHAT_HOST=chat
CLSI_HOST=clsi
DOWNLOAD_HOST=clsi-nginx
CONTACTS_HOST=contacts
DOCSTORE_HOST=docstore
DOCUMENT_UPDATER_HOST=document-updater
FILESTORE_HOST=filestore
@@ -17,6 +17,7 @@ QUEUES_REDIS_HOST=redis
DSMP_REDIS_HOST=redis
REALTIME_HOST=real-time
REDIS_HOST=redis
REFERENCES_HOST=references
SESSION_SECRET=foo
V1_HISTORY_HOST=history-v1
WEBPACK_HOST=webpack
+23 -1
View File
@@ -21,6 +21,17 @@ services:
- ../services/chat/app.js:/overleaf/services/chat/app.js
- ../services/chat/config:/overleaf/services/chat/config
contacts:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9232:9229"
volumes:
- ../services/contacts/app:/overleaf/services/contacts/app
- ../services/contacts/app.js:/overleaf/services/contacts/app.js
- ../services/contacts/config:/overleaf/services/contacts/config
docstore:
command: ["node", "--watch", "app.js"]
environment:
@@ -77,7 +88,7 @@ services:
volumes:
- ../services/linked-url-proxy/app:/overleaf/services/linked-url-proxy/app
- ../services/linked-url-proxy/config:/overleaf/services/linked-url-proxy/config
- ../services/linked-url-proxy/app.mjs:/overleaf/services/linked-url-proxy/app.mjs
- ../services/linked-url-proxy/app.js:/overleaf/services/linked-url-proxy/app.js
notifications:
command: ["node", "--watch", "app.ts"]
@@ -112,6 +123,17 @@ services:
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
- ../services/real-time/config:/overleaf/services/real-time/config
references:
command: ["node", "--watch", "app.js"]
environment:
- NODE_OPTIONS=--inspect=0.0.0.0:9229
ports:
- "127.0.0.1:9238:9229"
volumes:
- ../services/references/app:/overleaf/services/references/app
- ../services/references/config:/overleaf/services/references/config
- ../services/references/app.js:/overleaf/services/references/app.js
web:
command: ["node", "--watch", "app.mjs", "--watch-locales"]
environment:
+29 -38
View File
@@ -22,11 +22,13 @@ services:
build:
context: ..
dockerfile: services/clsi/Dockerfile
target: with-texlive
env_file:
- dev.env
environment:
- SANDBOXED_COMPILES=false
- TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full
- SANDBOXED_COMPILES=true
- SANDBOXED_COMPILES_HOST_DIR_COMPILES=${PWD}/compiles
- SANDBOXED_COMPILES_HOST_DIR_OUTPUT=${PWD}/output
user: root
volumes:
- ${PWD}/compiles:/overleaf/services/clsi/compiles
@@ -34,16 +36,12 @@ services:
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
- clsi-cache:/overleaf/services/clsi/cache
clsi-nginx:
image: nginx:1.28
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /run
volumes:
- ${PWD}/output:/output:ro
- ../services/clsi/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro
contacts:
build:
context: ..
dockerfile: services/contacts/Dockerfile
env_file:
- dev.env
docstore:
build:
@@ -65,8 +63,8 @@ services:
dockerfile: services/filestore/Dockerfile
env_file:
- dev.env
# environment:
# - ENABLE_CONVERSIONS=true
# environment:
# - ENABLE_CONVERSIONS=true
volumes:
- filestore-public-files:/overleaf/services/filestore/public_files
- filestore-template-files:/overleaf/services/filestore/template_files
@@ -77,7 +75,7 @@ services:
context: ..
dockerfile: services/history-v1/Dockerfile
env_file:
- dev.env
- dev.env
environment:
OVERLEAF_EDITOR_ANALYTICS_BUCKET: "/buckets/analytics"
OVERLEAF_EDITOR_BLOBS_BUCKET: "/buckets/blobs"
@@ -86,7 +84,7 @@ services:
OVERLEAF_EDITOR_ZIPS_BUCKET: "/buckets/zips"
PERSISTOR_BACKEND: fs
volumes:
- history-v1-buckets:/buckets
- history-v1-buckets:/buckets
linked-url-proxy:
build:
@@ -96,7 +94,7 @@ services:
- dev.env
mongo:
image: mongo:8
image: mongo:6.0
command: --replSet overleaf
ports:
- "127.0.0.1:27017:27017" # for debugging
@@ -123,7 +121,7 @@ services:
context: ..
dockerfile: services/project-history/Dockerfile
env_file:
- dev.env
- dev.env
real-time:
build:
@@ -131,14 +129,21 @@ services:
dockerfile: services/real-time/Dockerfile
env_file:
- dev.env
redis:
image: redis:7
image: redis:5
ports:
- "127.0.0.1:6379:6379" # for debugging
volumes:
- redis-data:/data
references:
build:
context: ..
dockerfile: services/references/Dockerfile
env_file:
- dev.env
web:
build:
context: ..
@@ -148,7 +153,7 @@ services:
- dev.env
environment:
- APP_NAME=Overleaf Community Edition
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file,zotero
- ENABLED_LINKED_FILE_TYPES=project_file,project_output_file
- EMAIL_CONFIRMATION_DISABLED=true
- NODE_ENV=development
- OVERLEAF_ALLOW_PUBLIC_ACCESS=true
@@ -161,6 +166,7 @@ services:
- redis
- chat
- clsi
- contacts
- docstore
- document-updater
- filestore
@@ -169,30 +175,15 @@ services:
- notifications
- project-history
- real-time
- references
webpack:
build:
context: ..
dockerfile: services/web/Dockerfile
target: webpack
command:
["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
command: ["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
ports:
- "127.0.0.1:80:3808"
volumes:
- ./webpack.config.dev-env.js:/overleaf/services/web/webpack.config.dev-env.js
git-bridge:
image: quay.io/sharelatex/git-bridge:latest
expose:
- "8000"
ports:
- "8000:8000"
environment:
GIT_BRIDGE_API_BASE_URL: "http://web:3000/api/v0"
GIT_BRIDGE_OAUTH2_SERVER: "http://web:3000"
GIT_BRIDGE_POSTBACK_BASE_URL: "http://git-bridge:8000"
GIT_BRIDGE_ROOT_DIR: "/data/git-bridge"
LOG_LEVEL: "DEBUG"
volumes:
- ./data/git-bridge:/data/git-bridge
+8
View File
@@ -0,0 +1,8 @@
FROM debian:testing-slim
RUN apt-get update
RUN apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends
RUN apt-get install -y --no-install-recommends fontconfig inkscape pandoc python3-pygments
RUN useradd tex
USER tex
-5
View File
@@ -14,11 +14,6 @@ module.exports = merge(base, {
target: 'http://real-time:3026',
ws: true,
},
{
context: '/git/**',
target: 'http://git-bridge:8000',
pathRewrite: { '^/git': '' }
},
{
context: ['!**/*.js', '!**/*.css', '!**/*.json'],
target: 'http://web:3000',
Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

+21 -21
View File
@@ -1,23 +1,23 @@
version: "2.2"
version: '2.2'
services:
sharelatex:
ports:
- 30000:30000
- 30150:30150
- 30120:30120
- 30050:30050
- 30420:30420
- 30030:30030
- 30160:30160
- 30360:30360
- 30130:30130
- 30100:30100
- 30540:30540
- 30640:30640
- 40000:40000
sharelatex:
ports:
- 30000:30000
- 30150:30150
- 30120:30120
- 30050:30050
- 30420:30420
- 30030:30030
- 30160:30160
- 30360:30360
- 30130:30130
- 30100:30100
- 30540:30540
- 30640:30640
- 40000:40000
# Server Pro
- 30070:30070
- 30400:30400
environment:
DEBUG_NODE: "true"
# Server Pro
- 30070:30070
- 30400:30400
environment:
DEBUG_NODE: 'true'
+127 -128
View File
@@ -1,152 +1,151 @@
services:
sharelatex:
restart: always
# Server Pro users:
# image: quay.io/sharelatex/sharelatex-pro
image: sharelatex/sharelatex
container_name: sharelatex
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_started
ports:
- 80:80
stop_grace_period: 60s
volumes:
- ~/sharelatex_data:/var/lib/overleaf
########################################################################
#### Server Pro: Uncomment the following line to mount the docker ####
#### socket, required for Sibling Containers to work ####
########################################################################
# - /var/run/docker.sock:/var/run/docker.sock
environment:
OVERLEAF_APP_NAME: Overleaf Community Edition
sharelatex:
restart: always
# Server Pro users:
# image: quay.io/sharelatex/sharelatex-pro
image: sharelatex/sharelatex
container_name: sharelatex
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_started
ports:
- 80:80
stop_grace_period: 60s
volumes:
- ~/sharelatex_data:/var/lib/overleaf
########################################################################
#### Server Pro: Uncomment the following line to mount the docker ####
#### socket, required for Sibling Containers to work ####
########################################################################
# - /var/run/docker.sock:/var/run/docker.sock
environment:
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
OVERLEAF_APP_NAME: Overleaf Community Edition
# Same property, unfortunately with different names in
# different locations
OVERLEAF_REDIS_HOST: redis
REDIS_HOST: redis
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
# IMPORTANT: Required for invite token generation
# OVERLEAF_INVITE_TOKEN_SECRET: generate with `openssl rand -base64 32`
# Same property, unfortunately with different names in
# different locations
OVERLEAF_REDIS_HOST: redis
REDIS_HOST: redis
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero"
ENABLED_LINKED_FILE_TYPES: 'project_file,project_output_file'
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS: "true"
# Enables Thumbnail generation using ImageMagick
ENABLE_CONVERSIONS: 'true'
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED: "true"
# Disables email confirmation requirement
EMAIL_CONFIRMATION_DISABLED: 'true'
## Set for SSL via nginx-proxy
#VIRTUAL_HOST: 103.112.212.22
## Set for SSL via nginx-proxy
#VIRTUAL_HOST: 103.112.212.22
# OVERLEAF_SITE_URL: http://overleaf.example.com
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
# OVERLEAF_ADMIN_EMAIL: support@it.com
# OVERLEAF_SITE_URL: http://overleaf.example.com
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
# OVERLEAF_ADMIN_EMAIL: support@it.com
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
# OVERLEAF_EMAIL_SMTP_PORT: 587
# OVERLEAF_EMAIL_SMTP_SECURE: false
# OVERLEAF_EMAIL_SMTP_USER:
# OVERLEAF_EMAIL_SMTP_PASS:
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
# OVERLEAF_EMAIL_SMTP_LOGGER: true
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
# OVERLEAF_EMAIL_SMTP_PORT: 587
# OVERLEAF_EMAIL_SMTP_SECURE: false
# OVERLEAF_EMAIL_SMTP_USER:
# OVERLEAF_EMAIL_SMTP_PASS:
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
# OVERLEAF_EMAIL_SMTP_LOGGER: true
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
# ENABLE_CRON_RESOURCE_DELETION: true
# ENABLE_CRON_RESOURCE_DELETION: true
################
## Server Pro ##
################
################
## Server Pro ##
################
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
## so the following environment variables must be commented out to avoid compile issues.
##
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
SANDBOXED_COMPILES: "true"
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
SANDBOXED_COMPILES_HOST_DIR_COMPILES: "/home/user/sharelatex_data/data/compiles"
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: "/home/user/sharelatex_data/data/output"
### Backwards compatibility (before Server Pro 5.5)
DOCKER_RUNNER: "true"
SANDBOXED_COMPILES_SIBLING_CONTAINERS: "true"
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
## so the following environment variables must be commented out to avoid compile issues.
##
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
SANDBOXED_COMPILES: 'true'
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
SANDBOXED_COMPILES_HOST_DIR_COMPILES: '/home/user/sharelatex_data/data/compiles'
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: '/home/user/sharelatex_data/data/output'
### Backwards compatibility (before Server Pro 5.5)
DOCKER_RUNNER: 'true'
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
## Works with test LDAP server shown at bottom of docker compose
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
# OVERLEAF_LDAP_NAME_ATT: 'cn'
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
## Works with test LDAP server shown at bottom of docker compose
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
# OVERLEAF_LDAP_NAME_ATT: 'cn'
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
# OVERLEAF_PROXY_LEARN: "true"
mongo:
restart: always
image: mongo:8.0
container_name: mongo
command: "--replSet overleaf"
volumes:
- ~/mongo_data:/data/db
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the replica set.
# This override is not needed when running the setup after starting up mongo.
- mongo:127.0.0.1
healthcheck:
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
interval: 10s
timeout: 10s
retries: 5
# OVERLEAF_PROXY_LEARN: "true"
redis:
restart: always
image: redis:6.2
container_name: redis
volumes:
- ~/redis_data:/data
mongo:
restart: always
image: mongo:6.0
container_name: mongo
command: '--replSet overleaf'
volumes:
- ~/mongo_data:/data/db
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
environment:
MONGO_INITDB_DATABASE: sharelatex
extra_hosts:
# Required when using the automatic database setup for initializing the replica set.
# This override is not needed when running the setup after starting up mongo.
- mongo:127.0.0.1
healthcheck:
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
interval: 10s
timeout: 10s
retries: 5
# ldap:
# restart: always
# image: rroemhild/test-openldap
# container_name: ldap
redis:
restart: always
image: redis:6.2
container_name: redis
volumes:
- ~/redis_data:/data
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
# but the example here can be used if you'd prefer to run everything with docker-compose
# ldap:
# restart: always
# image: rroemhild/test-openldap
# container_name: ldap
# nginx-proxy:
# image: jwilder/nginx-proxy
# container_name: nginx-proxy
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - /var/run/docker.sock:/tmp/docker.sock:ro
# - /home/overleaf/tmp:/etc/nginx/certs
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
# but the example here can be used if you'd prefer to run everything with docker-compose
# nginx-proxy:
# image: jwilder/nginx-proxy
# container_name: nginx-proxy
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - /var/run/docker.sock:/tmp/docker.sock:ro
# - /home/overleaf/tmp:/etc/nginx/certs
-18
View File
@@ -1,18 +0,0 @@
FROM cypress/included:15.12.0
ARG USER_UID=1000
ARG USER_GID=1000
# Corepack setup, shared between all the images.
ENV PATH="/overleaf/node_modules/.bin:$PATH"
ENV COREPACK_HOME=/opt/corepack
RUN corepack enable && corepack install -g yarn@4.14.1
ENV COREPACK_ENABLE_NETWORK=0
WORKDIR /overleaf
RUN sed -i s/node:x:1000:/node:x:${USER_GID}:/ /etc/group \
&& sed -i s_node:x:1000:1000::/home/node:/bin/bash_node:x:${USER_UID}:${USER_GID}::/home/node:/bin/bash_ /etc/passwd \
&& chown -R node:node /home/node \
&& chown node:node /overleaf
USER node
@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/access-token-encryptor/**
package-lock.json
package.json
patches/**
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: require.resolve('mocha-multi-reporters'),
reporter: '/overleaf/node_modules/mocha-multi-reporters',
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1 +1 @@
24.14.1
22.18.0
+82
View File
@@ -0,0 +1,82 @@
// Autogenerated by build scripts. Do not edit.
pipeline {
agent {
node {
label 'jenkins-agent-web'
customWorkspace '/workspace'
}
}
options {
timestamps()
timeout(time: 15, unit: 'MINUTES')
}
environment {
BRANCH_NAME = "${env.CHANGE_BRANCH ? env.CHANGE_BRANCH : env.BRANCH_NAME}"
JENKINS_BUILD_NUMBER = "${BUILD_NUMBER}"
BUILD_NUMBER = "${SHORT_SHA}_${BUILD_NUMBER}"
COMMIT_SHA = "${GIT_COMMIT}"
SHORT_SHA = "${GIT_COMMIT.take(7)}"
}
stages {
stage('Stage 1') {
parallel {
stage('Install monorepo') {
steps {
retry(count: 3) {
sh 'make monorepo_setup'
}
}
}
stage('Create reports folder') {
steps {
sh 'mkdir libraries/access-token-encryptor/reports'
}
}
}
}
stage('Stage 2') {
parallel {
stage('Lint') {
steps {
sh 'bin/run -w /overleaf/libraries/access-token-encryptor monorepo npm run lint -- --format json --output-file reports/eslint.json'
}
post {
always {
sh """
sed -i 's_"filePath":"/overleaf_"filePath":"/workspace_g' libraries/access-token-encryptor/reports/eslint.json
"""
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'access-token-encryptor-eslint', name: 'access-token-encryptor eslint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [esLint(pattern: 'libraries/access-token-encryptor/reports/eslint.json')]
}
}
}
stage('Format') {
steps {
sh 'bin/run -w /overleaf/libraries/access-token-encryptor monorepo npm run format'
}
}
stage('Typecheck') {
steps {
sh 'bin/run -w /overleaf/libraries/access-token-encryptor monorepo npm run types:check'
}
}
stage('Test') {
steps {
retry(count: 3) {
sh 'bin/run -w /overleaf/libraries/access-token-encryptor monorepo npm run test:ci'
}
}
}
}
}
}
post {
always {
junit checksName: 'access-token-encryptor test results', testResults: 'libraries/access-token-encryptor/reports/junit-*.xml'
}
cleanup {
sh 'rm -rf libraries/access-token-encryptor/reports'
sh 'make clean_jenkins -j10'
}
}
}
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/access-token-encryptor monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/access-token-encryptor
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/access-token-encryptor
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/access-token-encryptor
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
@@ -4,6 +4,5 @@ access-token-encryptor
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--node-version=22.18.0
--public-repo=False
@@ -4,17 +4,19 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:ci": "yarn run test:unit",
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
"test:ci": "npm run test:unit",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit"
},
"author": "",
"license": "AGPL-3.0-only",
"dependencies": {
"lodash": "^4.18.1"
"lodash": "^4.17.21"
},
"devDependencies": {
"chai": "^4.3.6",
@@ -4,6 +4,5 @@ module.exports = {
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
includePending: true,
jenkinsMode: true,
output: true,
},
}
@@ -1,4 +1,8 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
"include": [
"**/*.js",
"**/*.cjs",
"**/*.ts"
]
}
@@ -1,160 +0,0 @@
// French typographic spacing rules for JSON string values.
// Per https://typographisme.net/post/Les-espaces-typographiques-et-le-web
// - NNBSP before ? ! ;
// - NBSP before :
// - NBSP inside guillemets (after «, before ») — keeps the
// guillemet attached to its content so it cannot be orphaned at a
// line break
// - NNBSP between digit groups (thousands separator)
// - NBSP between a number and a following word/unit (50 Mo, 5 jours)
// - NBSP before %
// - point médian "·" instead of parenthetical (e) for inclusive writing
const NBSP = ' '
const NNBSP = ' '
function autoFix(value) {
let v = value
// Normalize whitespace before ? ! ; to NNBSP
v = v.replace(/\s*([?!;])/g, `${NNBSP}$1`)
v = v.replace(/([^\s])([?!;])/g, `$1${NNBSP}$2`)
// Normalize whitespace before : (after a letter, not before /) to NBSP
v = v.replace(/(\p{L})\s*:(?!\/)/gu, `$1${NBSP}:`)
// NBSP after «
v = v.replace(/«\s*/g, `«${NBSP}`)
// NBSP before »
v = v.replace(/\s*»/g, `${NBSP}»`)
// NNBSP between digits (thousands separator)
v = v.replace(/(\d)\s+(\d)/g, `$1${NNBSP}$2`)
// NBSP between digit and following letter (number + unit/word)
v = v.replace(/(\d)\s+(\p{L})/gu, `$1${NBSP}$2`)
// NBSP before %
v = v.replace(/(\d)\s*%/g, `$1${NBSP}%`)
return v
}
function lint(value) {
const violations = []
for (const m of value.matchAll(/(.)([?!;])/g)) {
if (m[1] !== NNBSP) {
violations.push({
message: `expected NNBSP before "${m[2]}", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\p{L})(\s*):(?!\/)/gu)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP before ":" after letter ${JSON.stringify(m[1])}, got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/«(.)/g)) {
if (m[1] !== NBSP) {
violations.push({
message: `expected NBSP after "«", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(.)»/g)) {
if (m[1] !== NBSP) {
violations.push({
message: `expected NBSP before "»", got ${JSON.stringify(m[1])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s)(\d)/g)) {
if (m[2] !== NNBSP) {
violations.push({
message: `expected NNBSP between digits "${m[1]}${m[3]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s)(\p{L})/gu)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP between digit "${m[1]}" and "${m[3]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
for (const m of value.matchAll(/(\d)(\s*)%/g)) {
if (m[2] !== NBSP) {
violations.push({
message: `expected NBSP before "%" after "${m[1]}", got ${JSON.stringify(m[2])}`,
fixable: true,
})
}
}
if (value.includes('(e)')) {
violations.push({
message:
'expected point médian "·" instead of "(e)" for inclusive writing',
fixable: false, // suggestion-only
})
}
return violations
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description:
'Enforce French typographic spacing in JSON string values (NNBSP before ? ! ;, NBSP before : and inside « », etc.).',
},
schema: [],
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
const violations = lint(original)
if (violations.length === 0) return
const fixed = autoFix(original)
const replacement = JSON.stringify(fixed)
const inclusiveSuggestion = original.includes('(e)')
? JSON.stringify(original.replace(/\(e\)/g, '·e'))
: null
for (const v of violations) {
context.report({
node: node.value,
message: v.message,
...(v.fixable && fixed !== original
? {
fix(fixer) {
return fixer.replaceText(node.value, replacement)
},
}
: {}),
...(!v.fixable && inclusiveSuggestion
? {
suggest: [
{
desc: 'Replace "(e)" with "·e"',
fix(fixer) {
return fixer.replaceText(
node.value,
inclusiveSuggestion
)
},
},
],
}
: {}),
})
}
},
}
},
}
-25
View File
@@ -1,25 +0,0 @@
const pkg = require('./package.json')
module.exports = {
meta: {
name: pkg.name,
version: pkg.version,
},
rules: {
'no-unnecessary-trans': require('./no-unnecessary-trans'),
'prefer-kebab-url': require('./prefer-kebab-url'),
'should-unescape-trans': require('./should-unescape-trans'),
'no-generated-editor-themes': require('./no-generated-editor-themes'),
'require-script-runner': require('./require-script-runner'),
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
'require-loading-label': require('./require-loading-label'),
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
'no-throw-in-callback': require('./no-throw-in-callback'),
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
'french-typography-in-locales': require('./french-typography-in-locales'),
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
'locale-variables-match-en': require('./locale-variables-match-en'),
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
},
}
@@ -1,93 +0,0 @@
// Ensure variable placeholders (`__name__`) in non-en locale values match
// those in the corresponding en.json key.
//
// Apply this rule to all non-en locale JSON files.
const fs = require('node:fs')
const Path = require('node:path')
// Placeholders always available in translations (injected by the i18n layer
// or globally defined as defaults).
const GLOBALS = new Set(['__appName__'])
// Keys whose translations are allowed to omit specific base placeholders.
const IGNORE_NESTING_FOR = {
over_x_templates_easy_getting_started: ['__templates__'],
all_packages_and_templates: ['__templatesLink__'],
}
function extractPlaceholders(str) {
return Array.from(str.matchAll(/__.*?__/g), m => m[0])
}
const enCache = new Map()
function loadEn(localesDir) {
if (enCache.has(localesDir)) return enCache.get(localesDir)
const path = Path.join(localesDir, 'en.json')
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
enCache.set(localesDir, data)
return data
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Ensure variable placeholders in non-en locale values match the en.json base.',
},
schema: [],
messages: {
missing:
'Translation for key "{{key}}" is missing placeholder(s) {{placeholders}} present in en.json.',
extra:
'Translation for key "{{key}}" has placeholder(s) {{placeholders}} not present in en.json.',
},
},
create(context) {
const filename = context.filename
const localesDir = Path.dirname(filename)
if (Path.basename(filename) === 'en.json') return {}
let base
try {
base = loadEn(localesDir)
} catch {
return {}
}
return {
Member(node) {
if (node.value.type !== 'String') return
const key = node.name.value
if (!(key in base)) return // orphan key — covered by no-orphan-locale-keys
const basePlaceholders = extractPlaceholders(base[key])
const targetPlaceholders = extractPlaceholders(node.value.value)
const ignored = IGNORE_NESTING_FOR[key] || []
const missing = basePlaceholders.filter(
p => !targetPlaceholders.includes(p) && !ignored.includes(p)
)
const extra = targetPlaceholders.filter(
p => !basePlaceholders.includes(p) && !GLOBALS.has(p)
)
if (missing.length > 0) {
context.report({
node: node.value,
messageId: 'missing',
data: { key, placeholders: missing.join(', ') },
})
}
if (extra.length > 0) {
context.report({
node: node.value,
messageId: 'extra',
data: { key, placeholders: extra.join(', ') },
})
}
},
}
},
}
@@ -1,37 +0,0 @@
// Reject runs of two or more consecutive whitespace characters in JSON string
// values. Catches ASCII spaces, NBSP, NNBSP, tabs, and other Unicode whitespace.
const CONSECUTIVE_WHITESPACE = /\s{2,}/
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow runs of two or more consecutive whitespace characters in JSON string values (typically locale files).',
},
schema: [],
messages: {
consecutiveSpaces:
'Locale value contains a run of consecutive whitespace. Collapse to a single space.',
},
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
if (!CONSECUTIVE_WHITESPACE.test(original)) return
const fixed = original.replace(/\s{2,}/g, ' ')
context.report({
node: node.value,
messageId: 'consecutiveSpaces',
fix(fixer) {
return fixer.replaceText(node.value, JSON.stringify(fixed))
},
})
},
}
},
}
@@ -1,21 +0,0 @@
module.exports = {
meta: {
type: 'error',
docs: {
description:
'Prohibit CodeMirror themes that are generated in a function',
},
},
create(context) {
return {
':matches(ArrowFunctionExpression, FunctionDeclaration, FunctionExpression) CallExpression > MemberExpression[object.name="EditorView"]:matches([property.name="theme"],[property.name="baseTheme"])'(
node
) {
context.report({
node,
message: `EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.`,
})
},
}
},
}
@@ -1,69 +0,0 @@
// Detect translation keys in non-en locale files that don't exist in
// en.json (orphans).
const fs = require('node:fs')
const Path = require('node:path')
const enCache = new Map()
function loadEnKeys(localesDir) {
if (enCache.has(localesDir)) return enCache.get(localesDir)
const path = Path.join(localesDir, 'en.json')
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
const keys = new Set(Object.keys(data))
enCache.set(localesDir, keys)
return keys
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Detect translation keys in non-en locale files that are not present in en.json.',
},
schema: [],
messages: {
orphan:
'Translation key "{{key}}" is not present in en.json (orphan key).',
},
},
create(context) {
const filename = context.filename
const localesDir = Path.dirname(filename)
if (Path.basename(filename) === 'en.json') return {}
let enKeys
try {
enKeys = loadEnKeys(localesDir)
} catch {
return {}
}
return {
'Document > Object'(node) {
const orphans = node.members.filter(m => !enKeys.has(m.name.value))
if (orphans.length === 0) return
const text = context.sourceCode.text
const parsed = JSON.parse(text)
for (const member of orphans) {
delete parsed[member.name.value]
}
const sortedRemaining = Object.keys(parsed).sort()
const cleaned = JSON.stringify(parsed, sortedRemaining, 2) + '\n'
for (const member of orphans) {
context.report({
node: member,
messageId: 'orphan',
data: { key: member.name.value },
fix(fixer) {
return fixer.replaceTextRange([0, text.length], cleaned)
},
})
}
},
}
},
}
@@ -1,36 +0,0 @@
// Reject straight apostrophes (') in JSON string values. Straight apostrophes
// in Angular templates can lead to XSS; use the right single quotation mark
// ’ (U+2019) instead. See https://github.com/overleaf/issues/issues/4478
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Disallow straight apostrophes in JSON string values (typically locale files).',
},
schema: [],
messages: {
straightApostrophe:
"Locale value contains a straight apostrophe ('). Use the right single quotation mark ’ (U+2019) instead.",
},
},
create(context) {
return {
Member(node) {
if (node.value.type !== 'String') return
const original = node.value.value
if (!original.includes("'")) return
const fixed = original.replace(/'/g, '’')
context.report({
node: node.value,
messageId: 'straightApostrophe',
fix(fixer) {
return fixer.replaceText(node.value, JSON.stringify(fixed))
},
})
},
}
},
}
@@ -1,52 +0,0 @@
const CALLBACK_PARAM_NAMES = new Set(['cb', 'callback', 'done', 'next'])
function isCallbackParam(param) {
return (
param && param.type === 'Identifier' && CALLBACK_PARAM_NAMES.has(param.name)
)
}
module.exports = {
meta: {
type: 'error',
docs: {
description: 'Disallow throw statements inside callback-based functions',
},
messages: {
noThrowInCallback:
'Pass the error to the callback instead of throwing in callback-based code.',
},
},
create(context) {
// Stack tracks whether each enclosing function is a callback-style function.
// A callback-style function is non-async and has a last param named cb/callback/done/next.
const stack = []
function enterFunction(node) {
const params = node.params
const isCallback =
!node.async &&
params.length > 0 &&
isCallbackParam(params[params.length - 1])
stack.push(isCallback)
}
function exitFunction() {
stack.pop()
}
return {
FunctionDeclaration: enterFunction,
'FunctionDeclaration:exit': exitFunction,
FunctionExpression: enterFunction,
'FunctionExpression:exit': exitFunction,
ArrowFunctionExpression: enterFunction,
'ArrowFunctionExpression:exit': exitFunction,
ThrowStatement(node) {
if (stack[stack.length - 1]) {
context.report({ node, messageId: 'noThrowInCallback' })
}
},
}
},
}
@@ -1,43 +0,0 @@
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'Prohibit Trans with no components or values',
},
},
create(context) {
return {
'JSXOpeningElement[name.name="Trans"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name.name, attr])
)
if (!attributes.has('components')) {
if (node.parent.children.length > 0) {
context.report({
node,
message: `Trans components must not have child elements`,
})
} else if (attributes.has('values')) {
context.report({
node,
message: `Use t('…') when there are no components`,
})
} else {
context.report({
node,
message: `Use t('…') when there are no components`,
fix(fixer) {
const i18nKey = attributes.get('i18nKey').value.value
// Note: Prettier can fix indentation
return fixer.replaceText(node.parent, `{t('${i18nKey}')}`)
},
})
}
}
},
}
},
}
-20
View File
@@ -1,20 +0,0 @@
{
"name": "@overleaf/eslint-plugin",
"version": "0.1.0",
"author": "Overleaf (https://www.overleaf.com)",
"license": "AGPL-3.0-only",
"main": "index.js",
"dependencies": {
"lodash": "^4.18.1"
},
"devDependencies": {
"@eslint/json": "^2.0.0",
"@typescript-eslint/parser": "^8.59.4"
},
"peerDependencies": {
"eslint": "^10.4.0"
},
"scripts": {
"test": "node rules.test.js"
}
}
@@ -1,84 +0,0 @@
// URL parts should be kebab-case, but we didn't have this rule in the past.
// The ESLint rule `prefer-kebab-url` will ignore these "legacy" URL parts.
const ignoreWords = {
snake: new Set([
'clear_saml_data',
'confirm_link',
'confirm_university_domain',
'create_recurly_account',
'current_history_content',
'current_user',
'default_email',
'disable_managed_users',
'doc_snapshot',
'enable_history_ranges_support',
'features_override',
'generate_password_reset_url',
'get_assignment',
'get_clone',
'health_check',
'institutional_emails',
'latest_template',
'link_after_saml_response',
'linked_file',
'metrics_segmentation',
'new_users',
'no_autostart_post_gateway',
'personal_info',
'planned_maintenance',
'refresh_features',
'register_admin',
'register_ldap_admin',
'register_saml_admin',
'restore_file',
'revert_file',
'saved_vers',
'send_test_email',
'session_maintenance',
'set_in_session',
'sign_in_to_link',
'split_test',
'sso_configuration_test',
'sso_email',
'sso_enrollment',
'track_changes',
'update_admin',
'user_details',
]),
camel: new Set([
'addWorkflowScope',
'aiErrorAssistant',
'aiFeatureUsage',
'beginAuth',
'brandVariationId',
'closeEditor',
'completeRegistration',
'deactivateOldProjects',
'deletedSubscription',
'disconnectAllUsers',
'editingSession',
'emailSubscription',
'enableManagedUsers',
'externalCollaboration',
'flushProjectToTpds',
'indexAll',
'offboardManagedUser',
'openEditor',
'perfTest',
'pollDropboxForUser',
'resendInvite',
'resendManagedUserInvite',
'salesContactForm',
'showSupport',
]),
other: new Set([
'Project',
'disableSSO',
'enableSSO',
'resendSSOLinkInvite',
'usersCSV',
]),
}
module.exports = { ignoreWords }
@@ -1,91 +0,0 @@
const _ = require('lodash')
const { ignoreWords } = require('./prefer-kebab-url-ignore')
const removeTextBetweenBrackets = text => {
while (text.includes('[') || text.includes('(')) {
text = text.replaceAll(/\[[^[\]]*]/g, '')
text = text.replaceAll(/\([^()]*\)/g, '')
}
return text
}
const shouldIgnoreWord = str =>
str.includes(':') ||
str.includes('(') ||
str === '*' ||
str.match(/^[a-z0-9.]+$/) ||
ignoreWords.snake.has(str) ||
ignoreWords.camel.has(str) ||
ignoreWords.other.has(str)
const getSuggestion = routePath => {
if (typeof routePath === 'string') {
const kebabed = routePath
.split('/')
.map(word => (shouldIgnoreWord(word) ? word : _.kebabCase(word)))
.join('/')
return kebabed === routePath ? null : `'${kebabed}'`
}
if (routePath instanceof RegExp) {
const words = removeTextBetweenBrackets(routePath.source).match(/[\w-]+/g)
if (!words) return routePath
let newSource = routePath.source
for (const word of words) {
if (!shouldIgnoreWord(word)) {
newSource = newSource.replaceAll(
new RegExp(`\\b${word}\\b`, 'g'),
_.kebabCase(word)
)
}
}
const kebabed = new RegExp(newSource, routePath.flags)
return kebabed.source.toString() === routePath.source.toString()
? null
: kebabed
}
}
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
hasSuggestions: true,
docs: {
description: 'Enforce using kebab-case for URL paths',
},
},
create: context => ({
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.arguments[0]?.type === 'Literal' &&
[/app/i, /router/i].some(callee =>
typeof callee === 'string'
? node.callee.object.name === callee
: callee.test(node.callee.object.name)
) &&
['get', 'post', 'put', 'delete'].includes(node.callee.property.name)
) {
const routePath = node.arguments[0].value
const suggestion = getSuggestion(routePath)
if (suggestion) {
context.report({
node: node.arguments[0],
message: 'Route path should be in kebab-case.',
suggest: [
{
desc: `Change to kebab-case: ${suggestion}`,
fix: fixer => fixer.replaceText(node.arguments[0], suggestion),
},
],
})
}
}
},
}),
}
@@ -1,111 +0,0 @@
'use strict'
const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
function isSnakeCase(name) {
return SNAKE_CASE_RE.test(name)
}
function getStaticKeyName(property) {
if (property.computed) return null
if (property.key.type === 'Identifier') return property.key.name
if (property.key.type === 'Literal' && typeof property.key.value === 'string')
return property.key.value
return null
}
/**
* Check if a node is a call to CustomerIoHandler.updateUserAttributes()
* and return the attributes argument (2nd argument)
*/
function getUpdateUserAttributesArg(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'CustomerIoHandler' &&
node.callee.property.name === 'updateUserAttributes' &&
node.arguments[1]?.type === 'ObjectExpression'
) {
return node.arguments[1]
}
return null
}
/**
* Check if a node is a call to Modules[.promises].hooks.fire('setUserProperties', ...)
* and return the attributes argument (3rd argument)
*/
function getSetUserPropertiesArg(node) {
const callee = node.callee
if (callee.type !== 'MemberExpression' || callee.property.name !== 'fire') {
return null
}
// Check first argument is 'setUserProperties'
if (
!node.arguments[0] ||
node.arguments[0].type !== 'Literal' ||
node.arguments[0].value !== 'setUserProperties'
) {
return null
}
// Match: Modules.hooks.fire or Modules.promises.hooks.fire
const obj = callee.object
if (obj.type === 'MemberExpression' && obj.property.name === 'hooks') {
const parent = obj.object
// Modules.hooks
if (parent.type === 'Identifier' && parent.name === 'Modules') {
if (node.arguments[2]?.type === 'ObjectExpression') {
return node.arguments[2]
}
}
// Modules.promises.hooks
if (
parent.type === 'MemberExpression' &&
parent.property.name === 'promises' &&
parent.object.type === 'Identifier' &&
parent.object.name === 'Modules'
) {
if (node.arguments[2]?.type === 'ObjectExpression') {
return node.arguments[2]
}
}
}
return null
}
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Enforce snake_case for Customer.io user property attribute names',
},
},
create(context) {
return {
CallExpression(node) {
const attrsNode =
getUpdateUserAttributesArg(node) || getSetUserPropertiesArg(node)
if (!attrsNode) return
for (const property of attrsNode.properties) {
if (property.type === 'SpreadElement') continue
const keyName = getStaticKeyName(property)
if (keyName === null) continue // skip computed/dynamic keys
if (!isSnakeCase(keyName)) {
context.report({
node: property.key,
message: `Customer.io attribute '{{name}}' must be in snake_case.`,
data: { name: keyName },
})
}
}
},
}
},
}
@@ -1,49 +0,0 @@
module.exports = {
meta: {
type: 'problem',
fixable: null,
docs: {
description:
'Require loadingLabel prop when isLoading is specified on OLButton',
},
schema: [],
},
create(context) {
return {
'JSXOpeningElement[name.name="OLButton"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name?.name, attr])
)
const isLoadingAttr = attributes.get('isLoading')
const loadingLabelAttr = attributes.get('loadingLabel')
if (isLoadingAttr && !loadingLabelAttr) {
const isLoadingValue = isLoadingAttr.value
if (
!isLoadingValue ||
(isLoadingValue.type === 'JSXExpressionContainer' &&
isLoadingValue.expression.type === 'Literal' &&
isLoadingValue.expression.value === true)
) {
context.report({
node: isLoadingAttr,
message:
'Button with isLoading prop must also specify loadingLabel',
})
} else if (
isLoadingValue.type === 'JSXExpressionContainer' &&
isLoadingValue.expression.type !== 'Literal'
) {
context.report({
node: isLoadingAttr,
message:
'Button with isLoading prop must also specify loadingLabel',
})
}
}
},
}
},
}
@@ -1,28 +0,0 @@
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Require Script Runner for scripts',
},
},
create(context) {
let hasImport = false
return {
ImportDeclaration(node) {
if (node.source.value.endsWith('lib/ScriptRunner.mjs')) {
hasImport = true
}
},
'Program:exit'() {
if (!hasImport) {
context.report({
loc: { line: 1, column: 0 },
message:
'Please use Script Runner for scripts. Refer to the developer manual (https://manual.dev-overleaf.com/development/code/web_scripts/#monitor-script-execution-and-usage-with-script-runner) for more information.',
})
}
},
}
},
}
@@ -1,139 +0,0 @@
const path = require('node:path')
const fs = require('node:fs')
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Ensure vi.doMock first argument is a resolvable path.',
category: 'Best Practices',
recommended: false,
url: '',
},
fixable: 'code',
hasSuggestions: true,
schema: [],
messages: {
unresolvablePath:
'The path "{{pathValue}}" in vi.doMock() cannot be resolved relative to the current file.',
notAStringLiteral:
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
noArguments: 'vi.doMock() called with no arguments.',
},
},
create(context) {
const currentFilePath = context.filename
// ESLint can sometimes pass <text> or <input> for snippets not in a file
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
return {}
}
const currentDirectory = path.dirname(currentFilePath)
function canResolve(modulePath) {
try {
require.resolve(path.resolve(currentDirectory, modulePath))
return true
} catch (e) {
const absolutePath = path.resolve(currentDirectory, modulePath)
const extensions = [
'',
'.js',
'.mjs',
'.ts',
'.jsx',
'.tsx',
'.json',
'.node',
'/index.js',
'/index.ts',
] // Add common extensions
for (const ext of extensions) {
if (fs.existsSync(absolutePath + ext)) {
return true
}
}
return false
}
}
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'vi' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'doMock'
) {
if (node.arguments.length === 0) {
context.report({
node,
messageId: 'noArguments',
})
return
}
const firstArg = node.arguments[0]
let pathValue = firstArg.value
if (
firstArg.type !== 'Literal' ||
typeof firstArg.value !== 'string'
) {
if (firstArg.type === 'Identifier') {
const scope = context.sourceCode.getScope(node)
const variable = scope.variables.find(
v => v.name === firstArg.name
)
if (
variable &&
variable.defs.length > 0 &&
variable.defs[0].node.init &&
variable.defs[0].node.init.type === 'Literal' &&
typeof variable.defs[0].node.init.value === 'string'
) {
pathValue = variable.defs[0].node.init.value
if (canResolve(pathValue)) {
return
}
// If the first argument was a variable that didn't resolve then we can't auto-fix it
}
}
context.report({
node: firstArg,
messageId: 'notAStringLiteral',
})
return
}
if (!pathValue.startsWith('.')) {
return
}
if (!canResolve(pathValue)) {
const mjsPath = pathValue.replace('.js', '.mjs')
const additionalReportOptions = {}
if (canResolve(mjsPath)) {
additionalReportOptions.fix = fixer =>
fixer.replaceText(firstArg, `'${mjsPath}'`)
additionalReportOptions.suggest = [
{
desc: `Replace with "${pathValue.replace('.js', '.mjs')}"`,
fix: fixer => fixer.replaceText(firstArg, `'${mjsPath}'`),
},
]
}
context.report({
node: firstArg,
messageId: 'unresolvablePath',
data: {
pathValue,
},
...additionalReportOptions,
})
}
}
},
}
},
}
-455
View File
@@ -1,455 +0,0 @@
const { RuleTester } = require('eslint')
const tsParser = require('@typescript-eslint/parser')
const json = require('@eslint/json').default
const noThrowInCallback = require('./no-throw-in-callback')
const preferKebabUrl = require('./prefer-kebab-url')
const noUnnecessaryTrans = require('./no-unnecessary-trans')
const shouldUnescapeTrans = require('./should-unescape-trans')
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
const frenchTypographyInLocales = require('./french-typography-in-locales')
const sortedKeysInLocales = require('./sorted-keys-in-locales')
const ruleTester = new RuleTester({
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
parserOptions: { ecmaFeatures: { jsx: true } },
},
})
const jsonRuleTester = new RuleTester({
plugins: { json },
language: 'json/json',
})
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
valid: [
{ code: `app.get('/foo-bar')` },
{ code: `app.get('/foo-bar/:id')` },
{ code: `router.post('/foo-bar')` },
{ code: `router.get('/foo-bar/:id/:name/:age')` },
{ code: `webRouter.get('/foo-bar/:user_id/(ProjectName)/get-info')` },
{ code: `webApp.post('/foo-bar/:user_id/(ProjectName)/get-info')` },
{
code: `router.get(/^\\/download\\/project\\/([^/]*)\\/output\\/output\\.pdf$/)`,
},
{
code: `webRouter.get(/^\\/project\\/([^/]*)\\/user\\/([0-9a-f]+)\\/build\\/([0-9a-f-]+)\\/output\\/(.*)$/)`,
},
],
invalid: [
{
code: `app.get('/fooBar')`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `app.get('/fooBar/:id')`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
{
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
errors: [
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
],
},
],
})
ruleTester.run('no-unnecessary-trans', noUnnecessaryTrans, {
valid: [
{ code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>` },
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ test: 'foo '}}/>`,
errors: [{ message: `Use t('…') when there are no components` }],
},
{
code: `<Trans i18nKey="test" />`,
errors: [{ message: `Use t('…') when there are no components` }],
output: `{t('test')}`,
},
],
})
ruleTester.run('should-unescape-trans', shouldUnescapeTrans, {
valid: [
{
code: `<Trans i18nKey="test" components={{ strong: <strong/> }}/>`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape tOptions={{ interpolation: { escapeValue: true } }}/>`,
},
],
invalid: [
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} />`,
errors: [{ message: 'Trans with values must have shouldUnescape' }],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }}\nshouldUnescape components={{ strong: <strong/> }} />`,
},
{
code: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape />`,
errors: [
{
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
},
],
output: `<Trans i18nKey="test" values={{ foo: 'bar' }} components={{ strong: <strong/> }} shouldUnescape\ntOptions={{ interpolation: { escapeValue: true } }} />`,
},
],
})
const noGeneratedEditorThemesError =
'EditorView.theme and EditorView.baseTheme each add CSS to the page for every instance of the theme. Store the theme in a variable and reuse it instead.'
ruleTester.run('no-generated-editor-themes', noGeneratedEditorThemes, {
valid: [
{
code: `EditorView.theme({ '.cm-editor': { color: 'black' } })`,
},
{
code: `const theme = EditorView.theme({ '.cm-editor': { color: 'black' } })`,
},
],
invalid: [
{
code: `function createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) }`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
{
code: `() => EditorView.theme({ '.cm-editor': { color: 'black' } })`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
{
code: `class Foo { createTheme() { return EditorView.theme({ '.cm-editor': { color: 'black' } }) } }`,
errors: [
{
message: noGeneratedEditorThemesError,
},
],
},
],
})
ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
valid: [
{
code: 'vi.doMock("./require-vi-doMock-valid-path.js")',
filename: __filename,
},
{
code: 'const filename = "./require-vi-doMock-valid-path.js"; vi.doMock(filename);',
filename: __filename,
},
],
invalid: [
{
code: "vi.doMock('./require-vi-doMock-valid-path2')",
filename: __filename,
errors: [
{
message:
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
suggestions: [],
},
],
},
{
code: 'const filename = "./require-vi-doMock-valid-path2.js"; vi.doMock(filename);',
filename: __filename,
errors: [
{
message:
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
suggestions: [],
},
],
},
],
})
ruleTester.run(
'require-cio-snake-case-properties',
requireCioSnakeCaseProperties,
{
valid: [
// updateUserAttributes with snake_case keys
{
code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`,
},
// Modules.promises.hooks.fire with snake_case keys
{
code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`,
},
// Modules.hooks.fire with snake_case keys
{
code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`,
},
// Single-word keys are valid snake_case
{
code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`,
},
// Computed/dynamic keys are skipped
{
code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`,
},
// Spread elements are skipped
{
code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`,
},
// Unrelated function calls are not checked
{
code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`,
},
// fire() with a different event name is not checked
{
code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`,
},
],
invalid: [
// camelCase key in updateUserAttributes
{
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
],
},
// kebab-case string key
{
code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`,
errors: [
{
message: `Customer.io attribute 'plan-type' must be in snake_case.`,
},
],
},
// PascalCase key
{
code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'PlanType' must be in snake_case.`,
},
],
},
// camelCase in Modules.promises.hooks.fire
{
code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
],
},
// camelCase in Modules.hooks.fire
{
code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
],
},
// Multiple invalid keys report multiple errors
{
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`,
errors: [
{
message: `Customer.io attribute 'planType' must be in snake_case.`,
},
{
message: `Customer.io attribute 'groupSize' must be in snake_case.`,
},
],
},
],
}
)
const noThrowInCallbackMessage =
'Pass the error to the callback instead of throwing in callback-based code.'
ruleTester.run('no-throw-in-callback', noThrowInCallback, {
valid: [
// Calling the callback with an error is fine
{ code: `function foo(cb) { cb(new Error()) }` },
// async functions may throw (they return a rejected promise)
{ code: `async function foo(cb) { throw new Error() }` },
// Last param not a callback name — not a callback-style function
{ code: `function foo(data) { throw new Error() }` },
// No params at all
{ code: `function foo() { throw new Error() }` },
// throw inside a nested non-callback function is fine
{ code: `function foo(cb) { [1].map(function() { throw new Error() }) }` },
// throw inside a nested async arrow is fine
{ code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` },
],
invalid: [
{
code: `function foo(cb) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(callback) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(done) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(next) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `function foo(data, cb) { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
{
code: `const foo = (cb) => { throw new Error() }`,
errors: [{ message: noThrowInCallbackMessage }],
},
// throw in a nested callback-style function inside another callback function
{
code: `function foo(cb) { bar(function(done) { throw new Error() }) }`,
errors: [{ message: noThrowInCallbackMessage }],
},
],
})
jsonRuleTester.run(
'no-consecutive-spaces-in-locales',
noConsecutiveSpacesInLocales,
{
valid: [
{ code: '{ "key": "one space" }' },
{ code: '{ "key": "no whitespace" }' },
],
invalid: [
{
code: '{ "key": "two spaces" }',
errors: [{ messageId: 'consecutiveSpaces' }],
output: '{ "key": "two spaces" }',
},
{
code: '{ "key": "three spaces" }',
errors: [{ messageId: 'consecutiveSpaces' }],
output: '{ "key": "three spaces" }',
},
{
// \t then space → two consecutive whitespace chars
code: '{ "key": "tab\\t and" }',
errors: [{ messageId: 'consecutiveSpaces' }],
output: '{ "key": "tab and" }',
},
],
}
)
jsonRuleTester.run(
'no-straight-apostrophes-in-locales',
noStraightApostrophesInLocales,
{
valid: [
{ code: '{ "key": "no apostrophe" }' },
{ code: '{ "key": "it’s curly" }' },
],
invalid: [
{
code: `{ "key": "it's straight" }`,
errors: [{ messageId: 'straightApostrophe' }],
output: '{ "key": "it’s straight" }',
},
],
}
)
jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, {
valid: [
{ code: '{\n "a": "1",\n "b": "2"\n}\n' },
{ code: '{}' },
{ code: '{ "only": "one" }' },
],
invalid: [
{
code: '{\n "b": "2",\n "a": "1"\n}\n',
errors: [{ messageId: 'unsorted' }],
output: '{\n "a": "1",\n "b": "2"\n}\n',
},
],
})
jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
valid: [
{ code: '{ "key": "Bonjour ?" }' },
{ code: '{ "key": "Liste :" }' },
{ code: '{ "key": "« contenu »" }' },
{ code: '{ "key": "abc 123" }' },
{ code: '{ "key": "10 %" }' },
],
invalid: [
{
code: '{ "key": "Bonjour?" }',
errors: 1,
output: '{ "key": "Bonjour ?" }',
},
{
code: '{ "key": "Bonjour ?" }',
errors: 1,
output: '{ "key": "Bonjour ?" }',
},
{
code: '{ "key": "Liste:" }',
errors: 1,
output: '{ "key": "Liste :" }',
},
{
code: '{ "key": "«contenu»" }',
errors: 2,
output: '{ "key": "« contenu »" }',
},
{
code: '{ "key": "10%" }',
errors: 1,
output: '{ "key": "10 %" }',
},
{
code: '{ "key": "sûr(e)" }',
errors: [
{
message:
'expected point médian "·" instead of "(e)" for inclusive writing',
suggestions: [
{
desc: 'Replace "(e)" with "·e"',
output: '{ "key": "sûr·e" }',
},
],
},
],
},
],
})
@@ -1,60 +0,0 @@
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description: 'Ensure that Trans with values has shouldUnescape',
},
},
create(context) {
return {
'JSXOpeningElement[name.name="Trans"]'(node) {
const attributes = new Map(
node.attributes.map(attr => [attr.name.name, attr])
)
if (attributes.has('values') && !attributes.has('shouldUnescape')) {
context.report({
node,
message: 'Trans with values must have shouldUnescape',
fix(fixer) {
return fixer.insertTextAfter(
attributes.get('values'),
'\nshouldUnescape' // Note: Prettier can fix indentation
)
},
})
}
if (attributes.has('values') && attributes.has('shouldUnescape')) {
const tOptions = attributes.get('tOptions')
if (!tOptions) {
context.report({
node,
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue',
fix(fixer) {
return fixer.insertTextAfter(
attributes.get('shouldUnescape'),
'\ntOptions={{ interpolation: { escapeValue: true } }}' // Note: Prettier can fix indentation
)
},
})
} else {
const property = tOptions.value.expression.properties
.find(p => p.key.name === 'interpolation')
?.value.properties.find(p => p.key.name === 'escapeValue')
if (property?.value.value !== true) {
context.report({
node,
message:
'Trans with shouldUnescape must have tOptions.interpolation.escapeValue set to true',
})
}
}
}
},
}
},
}
@@ -1,43 +0,0 @@
// Enforce alphabetically sorted top-level keys in JSON files.
module.exports = {
meta: {
type: 'problem',
fixable: 'code',
docs: {
description:
'Require top-level keys to be sorted alphabetically in JSON files (typically locale files).',
},
schema: [],
messages: {
unsorted:
'Top-level keys are not sorted alphabetically. Key "{{key}}" should come before "{{predecessor}}".',
},
},
create(context) {
return {
'Document > Object'(node) {
const members = node.members
for (let i = 1; i < members.length; i++) {
const prev = members[i - 1].name.value
const curr = members[i].name.value
if (curr < prev) {
const text = context.sourceCode.text
const parsed = JSON.parse(text)
const sortedKeys = Object.keys(parsed).sort()
const sorted = JSON.stringify(parsed, sortedKeys, 2) + '\n'
context.report({
node: members[i],
messageId: 'unsorted',
data: { key: curr, predecessor: prev },
fix(fixer) {
return fixer.replaceTextRange([0, text.length], sorted)
},
})
return // one violation per file is enough; the fix re-sorts everything
}
}
},
}
},
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js"]
}
@@ -0,0 +1,8 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/o-error/**
package-lock.json
package.json
patches/**
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: require.resolve('mocha-multi-reporters'),
reporter: '/overleaf/node_modules/mocha-multi-reporters',
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1 +1 @@
24.14.1
22.18.0
+82
View File
@@ -0,0 +1,82 @@
// Autogenerated by build scripts. Do not edit.
pipeline {
agent {
node {
label 'jenkins-agent-web'
customWorkspace '/workspace'
}
}
options {
timestamps()
timeout(time: 15, unit: 'MINUTES')
}
environment {
BRANCH_NAME = "${env.CHANGE_BRANCH ? env.CHANGE_BRANCH : env.BRANCH_NAME}"
JENKINS_BUILD_NUMBER = "${BUILD_NUMBER}"
BUILD_NUMBER = "${SHORT_SHA}_${BUILD_NUMBER}"
COMMIT_SHA = "${GIT_COMMIT}"
SHORT_SHA = "${GIT_COMMIT.take(7)}"
}
stages {
stage('Stage 1') {
parallel {
stage('Install monorepo') {
steps {
retry(count: 3) {
sh 'make monorepo_setup'
}
}
}
stage('Create reports folder') {
steps {
sh 'mkdir libraries/fetch-utils/reports'
}
}
}
}
stage('Stage 2') {
parallel {
stage('Lint') {
steps {
sh 'bin/run -w /overleaf/libraries/fetch-utils monorepo npm run lint -- --format json --output-file reports/eslint.json'
}
post {
always {
sh """
sed -i 's_"filePath":"/overleaf_"filePath":"/workspace_g' libraries/fetch-utils/reports/eslint.json
"""
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'fetch-utils-eslint', name: 'fetch-utils eslint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [esLint(pattern: 'libraries/fetch-utils/reports/eslint.json')]
}
}
}
stage('Format') {
steps {
sh 'bin/run -w /overleaf/libraries/fetch-utils monorepo npm run format'
}
}
stage('Typecheck') {
steps {
sh 'bin/run -w /overleaf/libraries/fetch-utils monorepo npm run types:check'
}
}
stage('Test') {
steps {
retry(count: 3) {
sh 'bin/run -w /overleaf/libraries/fetch-utils monorepo npm run test:ci'
}
}
}
}
}
}
post {
always {
junit checksName: 'fetch-utils test results', testResults: 'libraries/fetch-utils/reports/junit-*.xml'
}
cleanup {
sh 'rm -rf libraries/fetch-utils/reports'
sh 'make clean_jenkins -j10'
}
}
}
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/fetch-utils monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/fetch-utils
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/fetch-utils
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/fetch-utils
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
+1 -2
View File
@@ -4,6 +4,5 @@ fetch-utils
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--node-version=22.18.0
--public-repo=False
+7 -35
View File
@@ -5,12 +5,6 @@ const fetch = require('node-fetch')
const http = require('node:http')
const https = require('node:https')
let logger
function setLogger(loggerInstance) {
logger = loggerInstance
}
/**
* @import { Response } from 'node-fetch'
*/
@@ -29,7 +23,7 @@ async function fetchJson(url, opts = {}) {
}
async function fetchJsonWithResponse(url, opts = {}) {
const { fetchOpts, detachSignal } = parseOpts(opts, url)
const { fetchOpts, detachSignal } = parseOpts(opts)
fetchOpts.headers = fetchOpts.headers ?? {}
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
@@ -59,7 +53,7 @@ async function fetchStream(url, opts = {}) {
}
async function fetchStreamWithResponse(url, opts = {}) {
const { fetchOpts, abortController, detachSignal } = parseOpts(opts, url)
const { fetchOpts, abortController, detachSignal } = parseOpts(opts)
const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
@@ -82,7 +76,7 @@ async function fetchStreamWithResponse(url, opts = {}) {
* @throws {RequestFailedError} if the response has a failure status code
*/
async function fetchNothing(url, opts = {}) {
const { fetchOpts, detachSignal } = parseOpts(opts, url)
const { fetchOpts, detachSignal } = parseOpts(opts)
const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
@@ -114,7 +108,7 @@ async function fetchRedirect(url, opts = {}) {
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
*/
async function fetchRedirectWithResponse(url, opts = {}) {
const { fetchOpts, detachSignal } = parseOpts(opts, url)
const { fetchOpts, detachSignal } = parseOpts(opts)
fetchOpts.redirect = 'manual'
const response = await performRequest(url, fetchOpts, detachSignal)
if (response.status < 300 || response.status >= 400) {
@@ -148,7 +142,7 @@ async function fetchString(url, opts = {}) {
}
async function fetchStringWithResponse(url, opts = {}) {
const { fetchOpts, detachSignal } = parseOpts(opts, url)
const { fetchOpts, detachSignal } = parseOpts(opts)
const response = await performRequest(url, fetchOpts, detachSignal)
if (!response.ok) {
const body = await maybeGetResponseBody(response)
@@ -173,7 +167,7 @@ class RequestFailedError extends OError {
}
}
function parseOpts(opts, url) {
function parseOpts(opts) {
const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
if (opts.json) {
setupJsonBody(fetchOpts, opts.json)
@@ -184,30 +178,9 @@ function parseOpts(opts, url) {
const abortController = new AbortController()
fetchOpts.signal = abortController.signal
let detachSignal
let detachSignal = () => {}
if (opts.signal) {
detachSignal = abortOnSignal(abortController, opts.signal)
} else {
let overTimeoutStart
const stack = new Error().stack
const timeout = setTimeout(() => {
overTimeoutStart = process.hrtime.bigint()
}, 120000)
detachSignal = () => {
clearTimeout(timeout)
if (overTimeoutStart && logger) {
logger.warn(
{
url,
method: opts.method ?? 'GET',
overTimeoutMs:
Number(process.hrtime.bigint() - overTimeoutStart) / 1e6,
stack,
},
'Fetch request did not complete within 120 seconds'
)
}
}
}
if (opts.body instanceof Readable) {
abortOnDestroyedRequest(abortController, fetchOpts.body)
@@ -350,5 +323,4 @@ module.exports = {
ConnectTimeoutError,
CustomHttpAgent,
CustomHttpsAgent,
setLogger,
}
+12 -10
View File
@@ -4,10 +4,12 @@
"description": "utilities for node-fetch",
"main": "index.js",
"scripts": {
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:ci": "yarn run test:unit",
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
"test:ci": "npm run test:unit",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit"
},
@@ -15,19 +17,19 @@
"license": "AGPL-3.0-only",
"devDependencies": {
"@types/node-fetch": "^2.6.13",
"body-parser": "1.20.4",
"body-parser": "^1.20.3",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"express": "4.22.1",
"express": "^4.21.2",
"mocha": "^11.1.0",
"mocha-junit-reporter": "^2.2.1",
"mocha-multi-reporters": "^1.5.1",
"selfsigned": "^5.5.0",
"typescript": "^5.0.4"
},
"dependencies": {
"@overleaf/o-error": "workspace:*",
"lodash": "^4.18.1",
"node-fetch": "^2.7.0"
"@overleaf/o-error": "*",
"lodash": "^4.17.21",
"node-fetch": "^2.7.0",
"selfsigned": "^2.4.1"
}
}
@@ -4,6 +4,5 @@ module.exports = {
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
includePending: true,
jenkinsMode: true,
output: true,
},
}
@@ -21,6 +21,12 @@ const {
const HTTP_PORT = 30001
const HTTPS_PORT = 30002
const attrs = [{ name: 'commonName', value: 'example.com' }]
const pems = selfsigned.generate(attrs, { days: 365 })
const PRIVATE_KEY = pems.private
const PUBLIC_CERT = pems.cert
const dns = require('node:dns')
const _originalLookup = dns.lookup
// Custom DNS resolver function
@@ -39,15 +45,8 @@ dns.lookup = (hostname, options, callback) => {
}
describe('fetch-utils', function () {
let PUBLIC_CERT
before(async function () {
this.server = new TestServer()
const attrs = [{ name: 'commonName', value: 'example.com' }]
const pems = await selfsigned.generate(attrs, { days: 365, keySize: 2048 })
const PRIVATE_KEY = pems.private
PUBLIC_CERT = pems.cert
await this.server.start(HTTP_PORT, HTTPS_PORT, {
key: PRIVATE_KEY,
cert: PUBLIC_CERT,
@@ -119,20 +119,11 @@ class TestServer {
}
stop() {
const promises = []
if (this.server) {
const stopHttp = promisify(this.server.close).bind(this.server)
this.server.closeAllConnections()
promises.push(stopHttp())
}
if (this.https_server) {
const stopHttps = promisify(this.https_server.close).bind(
this.https_server
)
this.https_server.closeAllConnections()
promises.push(stopHttps())
}
return Promise.all(promises)
const stopHttp = promisify(this.server.close).bind(this.server)
const stopHttps = promisify(this.https_server.close).bind(this.https_server)
this.server.closeAllConnections()
this.https_server.closeAllConnections()
return Promise.all([stopHttp(), stopHttps()])
}
}
+5 -1
View File
@@ -1,4 +1,8 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
"include": [
"**/*.js",
"**/*.cjs",
"**/*.ts"
]
}
+9
View File
@@ -0,0 +1,9 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/fetch-utils/**
libraries/logger/**
libraries/o-error/**
package-lock.json
package.json
patches/**
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: require.resolve('mocha-multi-reporters'),
reporter: '/overleaf/node_modules/mocha-multi-reporters',
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1 +1 @@
24.14.1
22.18.0
+3 -2
View File
@@ -1,9 +1,10 @@
## v3.1.1
- Handle malformed requests in the req serializer
* Handle malformed requests in the req serializer
## v3.0.0
- Improve logging in Google Cloud Platform. Set environment variable `GCP_LOGGING=true` to enable.
* Improve logging in Google Cloud Platform. Set environment variable `GCP_LOGGING=true` to enable.
This version of the metrics module only works with versions of the `@overleaf/metrics` module greater than v4.0.0
+82
View File
@@ -0,0 +1,82 @@
// Autogenerated by build scripts. Do not edit.
pipeline {
agent {
node {
label 'jenkins-agent-web'
customWorkspace '/workspace'
}
}
options {
timestamps()
timeout(time: 15, unit: 'MINUTES')
}
environment {
BRANCH_NAME = "${env.CHANGE_BRANCH ? env.CHANGE_BRANCH : env.BRANCH_NAME}"
JENKINS_BUILD_NUMBER = "${BUILD_NUMBER}"
BUILD_NUMBER = "${SHORT_SHA}_${BUILD_NUMBER}"
COMMIT_SHA = "${GIT_COMMIT}"
SHORT_SHA = "${GIT_COMMIT.take(7)}"
}
stages {
stage('Stage 1') {
parallel {
stage('Install monorepo') {
steps {
retry(count: 3) {
sh 'make monorepo_setup'
}
}
}
stage('Create reports folder') {
steps {
sh 'mkdir libraries/logger/reports'
}
}
}
}
stage('Stage 2') {
parallel {
stage('Lint') {
steps {
sh 'bin/run -w /overleaf/libraries/logger monorepo npm run lint -- --format json --output-file reports/eslint.json'
}
post {
always {
sh """
sed -i 's_"filePath":"/overleaf_"filePath":"/workspace_g' libraries/logger/reports/eslint.json
"""
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'logger-eslint', name: 'logger eslint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [esLint(pattern: 'libraries/logger/reports/eslint.json')]
}
}
}
stage('Format') {
steps {
sh 'bin/run -w /overleaf/libraries/logger monorepo npm run format'
}
}
stage('Typecheck') {
steps {
sh 'bin/run -w /overleaf/libraries/logger monorepo npm run types:check'
}
}
stage('Test') {
steps {
retry(count: 3) {
sh 'bin/run -w /overleaf/libraries/logger monorepo npm run test:ci'
}
}
}
}
}
}
post {
always {
junit checksName: 'logger test results', testResults: 'libraries/logger/reports/junit-*.xml'
}
cleanup {
sh 'rm -rf libraries/logger/reports'
sh 'make clean_jenkins -j10'
}
}
}
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/logger monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/logger
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/logger
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/logger
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
+1 -2
View File
@@ -4,6 +4,5 @@ logger
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--node-version=22.18.0
--public-repo=False
-2
View File
@@ -6,7 +6,6 @@ const {
FileLogLevelChecker,
GCEMetadataLogLevelChecker,
} = require('./log-level-checker')
const { setLogger } = require('@overleaf/fetch-utils')
const LoggingManager = {
/**
@@ -32,7 +31,6 @@ const LoggingManager = {
})
this._setupRingBuffer()
this._setupLogLevelChecker()
setLogger(this)
return this
},
+9 -7
View File
@@ -10,17 +10,19 @@
"license": "AGPL-3.0-only",
"version": "3.1.1",
"scripts": {
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:ci": "yarn run test:unit",
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:ci": "npm run test:unit",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"types:check": "tsc --noEmit"
},
"dependencies": {
"@google-cloud/logging-bunyan": "^5.1.0",
"@overleaf/fetch-utils": "workspace:*",
"@overleaf/o-error": "workspace:*",
"@overleaf/fetch-utils": "*",
"@overleaf/o-error": "*",
"bunyan": "^1.8.14"
},
"devDependencies": {
@@ -34,6 +36,6 @@
"typescript": "^5.0.4"
},
"peerDependencies": {
"@overleaf/metrics": "workspace:*"
"@overleaf/metrics": "*"
}
}
+1 -6
View File
@@ -80,9 +80,4 @@ function getRemoteIp(req) {
return null
}
module.exports = {
err: errSerializer,
error: errSerializer,
req: reqSerializer,
res: resSerializer,
}
module.exports = { err: errSerializer, req: reqSerializer, res: resSerializer }
-20
View File
@@ -1,20 +0,0 @@
const logger = require('./')
const bunyan = require('bunyan')
const serializers = require('./serializers')
function testLogRecorder() {
const currentTest = this.currentTest
for (const level of ['error', 'fatal']) {
logger[level] = (info, msg) => {
const entry = { level, ...info, msg }
for (const [name, fn] of Object.entries(serializers)) {
if (name in entry) entry[name] = fn(entry[name])
}
currentTest.consoleErrors = (currentTest.consoleErrors || []).concat(
JSON.stringify(entry, bunyan.safeCycles())
)
}
}
}
module.exports = testLogRecorder
@@ -4,6 +4,5 @@ module.exports = {
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
includePending: true,
jenkinsMode: true,
output: true,
},
}
@@ -43,13 +43,9 @@ describe('LoggingManager', function () {
.stub()
.returns(this.GCEMetadataLogLevelChecker),
}
this.fetchUtils = {
setLogger: sinon.stub(),
}
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
requires: {
bunyan: this.Bunyan,
'@overleaf/fetch-utils': this.fetchUtils,
'./log-level-checker': this.LogLevelChecker,
},
})
+5 -1
View File
@@ -1,4 +1,8 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
"include": [
"**/*.js",
"**/*.cjs",
"**/*.ts"
]
}
+7
View File
@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/metrics/**
package-lock.json
package.json
patches/**
+1 -1
View File
@@ -1,7 +1,7 @@
let reporterOptions = {}
if (process.env.CI) {
reporterOptions = {
reporter: require.resolve('mocha-multi-reporters'),
reporter: '/overleaf/node_modules/mocha-multi-reporters',
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
}
}
+1 -1
View File
@@ -1 +1 @@
24.14.1
22.18.0
+2 -2
View File
@@ -1,9 +1,9 @@
## v4.1.0
- Allows skipping the `sampleRate` argument.
* Allows skipping the `sampleRate` argument.
## v4.0.0
- Send unmodified request and response to logger.
* Send unmodified request and response to logger.
This version of the metrics module only works with versions of the `@overleaf/logger` module greater than v3.0.0
+82
View File
@@ -0,0 +1,82 @@
// Autogenerated by build scripts. Do not edit.
pipeline {
agent {
node {
label 'jenkins-agent-web'
customWorkspace '/workspace'
}
}
options {
timestamps()
timeout(time: 15, unit: 'MINUTES')
}
environment {
BRANCH_NAME = "${env.CHANGE_BRANCH ? env.CHANGE_BRANCH : env.BRANCH_NAME}"
JENKINS_BUILD_NUMBER = "${BUILD_NUMBER}"
BUILD_NUMBER = "${SHORT_SHA}_${BUILD_NUMBER}"
COMMIT_SHA = "${GIT_COMMIT}"
SHORT_SHA = "${GIT_COMMIT.take(7)}"
}
stages {
stage('Stage 1') {
parallel {
stage('Install monorepo') {
steps {
retry(count: 3) {
sh 'make monorepo_setup'
}
}
}
stage('Create reports folder') {
steps {
sh 'mkdir libraries/metrics/reports'
}
}
}
}
stage('Stage 2') {
parallel {
stage('Lint') {
steps {
sh 'bin/run -w /overleaf/libraries/metrics monorepo npm run lint -- --format json --output-file reports/eslint.json'
}
post {
always {
sh """
sed -i 's_"filePath":"/overleaf_"filePath":"/workspace_g' libraries/metrics/reports/eslint.json
"""
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'metrics-eslint', name: 'metrics eslint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [esLint(pattern: 'libraries/metrics/reports/eslint.json')]
}
}
}
stage('Format') {
steps {
sh 'bin/run -w /overleaf/libraries/metrics monorepo npm run format'
}
}
stage('Typecheck') {
steps {
sh 'bin/run -w /overleaf/libraries/metrics monorepo npm run types:check'
}
}
stage('Test') {
steps {
retry(count: 3) {
sh 'bin/run -w /overleaf/libraries/metrics monorepo npm run test:ci'
}
}
}
}
}
}
post {
always {
junit checksName: 'metrics test results', testResults: 'libraries/metrics/reports/junit-*.xml'
}
cleanup {
sh 'rm -rf libraries/metrics/reports'
sh 'make clean_jenkins -j10'
}
}
}
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/metrics monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/metrics
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/metrics
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/metrics
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
+1 -2
View File
@@ -4,6 +4,5 @@ metrics
--env-pass-through=
--esmock-loader=False
--is-library=True
--node-version=24.14.1
--pipeline-owner=32
--node-version=22.18.0
--public-repo=False
+1 -1
View File
@@ -75,4 +75,4 @@ class RequestLogger {
}
}
module.exports = { monitor, RequestLogger, getRoutePath }
module.exports = { monitor, RequestLogger }
-6
View File
@@ -97,12 +97,6 @@ function histogram(key, value, buckets, labels = {}) {
}
class Timer {
/**
* @param {string} key
* @param {number} sampleRate
* @param {Record<string, any>} labels
* @param {Array<number>} buckets
*/
constructor(key, sampleRate = 1, labels = {}, buckets = undefined) {
if (typeof sampleRate === 'object') {
// called with (key, labels, buckets)
+1 -1
View File
@@ -104,7 +104,7 @@ module.exports = MemoryMonitor = {
},
'global.gc() forced'
)
Metrics.timing('memory.gc-time', +gcTime)
// Metrics.timing("memory.gc-time", gcTime)
Metrics.gauge('memory.gc-rss-freed', -deltaMem.rss)
Metrics.gauge('memory.gc-heaptotal-freed', -deltaMem.heapTotal)
return Metrics.gauge('memory.gc-heapused-freed', -deltaMem.heapUsed)
+40 -87
View File
@@ -1,106 +1,67 @@
const { Gauge, Summary, Counter } = require('prom-client')
const { Gauge, Summary } = require('prom-client')
/** @type {poolSize: Gauge<string>, availableConnections: Gauge<string>, waitQueueSize: Gauge<string>, maxPoolSize: Gauge<string>, mongoCommandStarted: Counter<string>, mongoCommandTimer: Summary<string>} */
let metrics
const collectPoolMetrics = []
/**
* @param clientLabel
*/
function initMetricsOnce(clientLabel) {
if (metrics) return
const poolLabelNames = ['mongo_server']
if (clientLabel) poolLabelNames.push('client')
function monitor(mongoClient) {
const labelNames = ['mongo_server']
const poolSize = new Gauge({
name: 'mongo_connection_pool_size',
help: 'number of connections in the connection pool',
labelNames: poolLabelNames,
labelNames,
// Use this one metric's collect() to set all metrics' values.
collect() {
// Reset all gauges in case they contain values for servers that
// disappeared
metrics.poolSize.reset()
metrics.availableConnections.reset()
metrics.waitQueueSize.reset()
metrics.maxPoolSize.reset()
collectPoolMetrics.forEach(fn => fn())
},
collect,
})
const availableConnections = new Gauge({
name: 'mongo_connection_pool_available',
help: 'number of connections that are not busy',
labelNames: poolLabelNames,
labelNames,
})
const waitQueueSize = new Gauge({
name: 'mongo_connection_pool_waiting',
help: 'number of operations waiting for an available connection',
labelNames: poolLabelNames,
labelNames,
})
const maxPoolSize = new Gauge({
name: 'mongo_connection_pool_max',
help: 'max size for the connection pool',
labelNames: poolLabelNames,
labelNames,
})
const mongoCommandStarted = new Counter({
name: 'mongo_command_started',
help: 'mongo command started',
labelNames: ['method', 'collection'],
})
const mongoCommandTimer = new Summary({
name: 'mongo_command_time',
help: 'time taken to complete a mongo command',
percentiles: [],
labelNames: ['status', 'method', 'ns'],
labelNames: ['status', 'method'],
})
metrics = {
poolSize,
availableConnections,
waitQueueSize,
maxPoolSize,
mongoCommandStarted,
mongoCommandTimer,
}
return metrics
}
function monitor(mongoClient, clientLabel) {
initMetricsOnce(clientLabel)
mongoClient.on('commandStarted', event => {
const { commandName, command } = event
const collection = command?.[commandName]
if (typeof collection !== 'string') return // Lifecycle commands
if (commandName === 'create') return // Mongoose init
metrics.mongoCommandStarted.inc({
method: commandName === 'find' ? 'read' : 'write',
collection,
if (mongoClient.on) {
mongoClient.on('commandSucceeded', event => {
mongoCommandTimer.observe(
{
status: 'success',
method: event.commandName === 'find' ? 'read' : 'write',
},
event.duration
)
})
})
mongoClient.on('commandSucceeded', event => {
metrics.mongoCommandTimer.observe(
{
status: 'success',
method: event.commandName === 'find' ? 'read' : 'write',
ns: event.reply?.cursor?.ns, // best effort, set on 'find'
},
event.duration
)
})
mongoClient.on('commandFailed', event => {
metrics.mongoCommandTimer.observe(
{
status: 'failed',
method: event.commandName === 'find' ? 'read' : 'write',
},
event.duration
)
})
mongoClient.on('commandFailed', event => {
mongoCommandTimer.observe(
{
status: 'failed',
method: event.commandName === 'find' ? 'read' : 'write',
},
event.duration
)
})
}
function collect() {
// Reset all gauges in case they contain values for servers that
// disappeared
poolSize.reset()
availableConnections.reset()
waitQueueSize.reset()
maxPoolSize.reset()
const servers = mongoClient.topology?.s?.servers
if (servers != null) {
for (const [address, server] of servers) {
@@ -111,21 +72,13 @@ function monitor(mongoClient, clientLabel) {
}
const labels = { mongo_server: address }
if (clientLabel) labels.client = clientLabel
metrics.poolSize.set(labels, pool.totalConnectionCount)
metrics.availableConnections.set(labels, pool.availableConnectionCount)
metrics.waitQueueSize.set(labels, pool.waitQueueSize)
metrics.maxPoolSize.set(labels, pool.options.maxPoolSize)
poolSize.set(labels, pool.totalConnectionCount)
availableConnections.set(labels, pool.availableConnectionCount)
waitQueueSize.set(labels, pool.waitQueueSize)
maxPoolSize.set(labels, pool.options.maxPoolSize)
}
}
}
collectPoolMetrics.push(collect)
}
module.exports = {
monitor,
reset() {
metrics = undefined
collectPoolMetrics.length = 0
},
}
module.exports = { monitor }
+16 -14
View File
@@ -8,14 +8,14 @@
},
"main": "index.js",
"dependencies": {
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
"@google-cloud/profiler": "^6.0.4",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@google-cloud/profiler": "^6.0.3",
"@opentelemetry/api": "^1.4.1",
"@opentelemetry/auto-instrumentations-node": "^0.39.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.41.2",
"@opentelemetry/resources": "^1.15.2",
"@opentelemetry/sdk-node": "^0.41.2",
"@opentelemetry/semantic-conventions": "^1.15.2",
"compression": "^1.7.4",
"prom-client": "^14.1.1",
"yn": "^3.1.1"
@@ -31,15 +31,17 @@
"typescript": "^5.0.4"
},
"scripts": {
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
"test:unit": "mocha --exit test/**/*.{js,cjs}",
"test:acceptance": "mocha --recursive --exit --grep=${MOCHA_GREP:-} test/acceptance",
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
"test:ci": "yarn run test:unit",
"test:acceptance": "mocha --recursive --exit --grep=$MOCHA_GREP test/acceptance",
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
"test:ci": "npm run test:unit",
"types:check": "tsc --noEmit"
},
"peerDependencies": {
"@overleaf/logger": "workspace:*"
"@overleaf/logger": "*"
}
}
@@ -4,6 +4,5 @@ module.exports = {
mochaFile: `reports/junit-mocha-${process.env.MOCHA_GREP}.xml`,
includePending: true,
jenkinsMode: true,
output: true,
},
}
+3 -21
View File
@@ -14,15 +14,11 @@ describe('mongodb', function () {
}
this.servers = new Map([['server1', { s: { pool: this.pool } }]])
this.mongoClient = {
on() {},
topology: { s: { servers: this.servers } },
}
Metrics.mongodb.reset()
this.mongoClient = { topology: { s: { servers: this.servers } } }
})
it('handles an unconnected client', async function () {
const mongoClient = { on() {} }
const mongoClient = {}
Metrics.mongodb.monitor(mongoClient)
const metrics = await getMetrics()
expect(metrics).to.deep.equal({})
@@ -39,17 +35,6 @@ describe('mongodb', function () {
})
})
it('collects Mongo metrics with client', async function () {
Metrics.mongodb.monitor(this.mongoClient, 'native')
const metrics = await getMetrics()
expect(metrics).to.deep.equal({
'mongo_connection_pool_max:server1:native': 10,
'mongo_connection_pool_size:server1:native': 8,
'mongo_connection_pool_available:server1:native': 2,
'mongo_connection_pool_waiting:server1:native': 4,
})
})
it('handles topology changes', async function () {
Metrics.mongodb.monitor(this.mongoClient)
let metrics = await getMetrics()
@@ -96,10 +81,7 @@ async function getMetrics() {
const result = {}
for (const metric of metrics) {
for (const value of metric.values) {
let key = `${metric.name}:${value.labels.mongo_server}`
if (value.labels.client) {
key = `${key}:${value.labels.client}`
}
const key = `${metric.name}:${value.labels.mongo_server}`
result[key] = value.value
}
}
+5 -1
View File
@@ -1,4 +1,8 @@
{
"extends": "../../tsconfig.backend.json",
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
"include": [
"**/*.js",
"**/*.cjs",
"**/*.ts"
]
}
@@ -0,0 +1,7 @@
# Autogenerated by build scripts. Do not edit.
.eslint*
.prettier*
libraries/mongo-utils/**
package-lock.json
package.json
patches/**
+1 -1
View File
@@ -1 +1 @@
24.14.1
22.18.0
+72
View File
@@ -0,0 +1,72 @@
// Autogenerated by build scripts. Do not edit.
pipeline {
agent {
node {
label 'jenkins-agent-web'
customWorkspace '/workspace'
}
}
options {
timestamps()
timeout(time: 15, unit: 'MINUTES')
}
environment {
BRANCH_NAME = "${env.CHANGE_BRANCH ? env.CHANGE_BRANCH : env.BRANCH_NAME}"
JENKINS_BUILD_NUMBER = "${BUILD_NUMBER}"
BUILD_NUMBER = "${SHORT_SHA}_${BUILD_NUMBER}"
COMMIT_SHA = "${GIT_COMMIT}"
SHORT_SHA = "${GIT_COMMIT.take(7)}"
}
stages {
stage('Stage 1') {
parallel {
stage('Install monorepo') {
steps {
retry(count: 3) {
sh 'make monorepo_setup'
}
}
}
stage('Create reports folder') {
steps {
sh 'mkdir libraries/mongo-utils/reports'
}
}
}
}
stage('Stage 2') {
parallel {
stage('Lint') {
steps {
sh 'bin/run -w /overleaf/libraries/mongo-utils monorepo npm run lint -- --format json --output-file reports/eslint.json'
}
post {
always {
sh """
sed -i 's_"filePath":"/overleaf_"filePath":"/workspace_g' libraries/mongo-utils/reports/eslint.json
"""
recordIssues checksAnnotationScope: 'ALL', enabledForFailure: true, failOnError: true, id: 'mongo-utils-eslint', name: 'mongo-utils eslint', qualityGates: [[integerThreshold: 1, threshold: 1.0, type: 'TOTAL']], sourceCodeRetention: 'LAST_BUILD', tools: [esLint(pattern: 'libraries/mongo-utils/reports/eslint.json')]
}
}
}
stage('Format') {
steps {
sh 'bin/run -w /overleaf/libraries/mongo-utils monorepo npm run format'
}
}
stage('Typecheck') {
steps {
sh 'bin/run -w /overleaf/libraries/mongo-utils monorepo npm run types:check'
}
}
}
}
}
post {
cleanup {
sh 'rm -rf libraries/mongo-utils/reports'
sh 'make clean_jenkins -j10'
}
}
}
-59
View File
@@ -1,59 +0,0 @@
# Run the test commands in the scope of the monorepo.
# Eslint and prettier (plus some configs) are on the root.
RUN_LINTING = ../../bin/run -w /overleaf/libraries/mongo-utils monorepo yarn run --silent
RUN_LINTING_MONOREPO = ../../bin/run monorepo yarn run --silent
WITH_CACHE_FOLDER = \
lint \
lint_ci \
lint_fix \
format \
format_ci \
format_fix \
typecheck \
typecheck_ci \
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/prettier/
../../node_modules/.cache/prettier/:
mkdir -p $@
$(WITH_CACHE_FOLDER): ../../node_modules/.cache/eslint/
../../node_modules/.cache/eslint/:
mkdir -p $@
format:
$(RUN_LINTING_MONOREPO) format libraries/mongo-utils
format_ci:
$(RUN_LINTING_MONOREPO) format libraries/mongo-utils
format_fix:
$(RUN_LINTING_MONOREPO) format:fix libraries/mongo-utils
lint:
$(RUN_LINTING) lint
lint_ci:
-$(RUN_LINTING) lint --format json --output-file reports/eslint.json
sed -i 's_"filePath":"/overleaf_"filePath":"$(MONOREPO)_g' reports/eslint.json
lint_fix:
$(RUN_LINTING) lint:fix
typecheck:
$(RUN_LINTING) types:check
typecheck_ci:
$(RUN_LINTING) types:check
test: test_unit
test_unit:
ifneq (,$(wildcard test/unit))
$(RUN_LINTING) test:unit
endif
test: test_acceptance
test_acceptance:
ifneq (,$(wildcard test/acceptance))
$(RUN_LINTING) test:acceptance
endif
+2 -9
View File
@@ -8,20 +8,13 @@ const READ_PREFERENCE_SECONDARY =
: ReadPreference.secondaryPreferred.mode
const ONE_MONTH_IN_MS = 1000 * 60 * 60 * 24 * 31
/** @type {ObjectId | null} */
let ID_EDGE_PAST
const ID_EDGE_FUTURE = objectIdFromMs(Date.now() + 1000)
/** @type {boolean} */
let BATCH_DESCENDING
/** @type {number} */
let BATCH_SIZE
/** @type {boolean} */
let VERBOSE_LOGGING
/** @type {ObjectId} */
let BATCH_RANGE_START
/** @type {ObjectId} */
let BATCH_RANGE_END
/** @type {number} */
let BATCH_MAX_TIME_SPAN_IN_MS
let BATCHED_UPDATE_RUNNING = false
@@ -62,7 +55,7 @@ function refreshGlobalOptionsForBatchedUpdate(options = {}) {
if (BATCH_DESCENDING) {
BATCH_RANGE_START = ID_EDGE_FUTURE
} else {
BATCH_RANGE_START = /** @type {ObjectId} */ (ID_EDGE_PAST)
BATCH_RANGE_START = ID_EDGE_PAST
}
}
BATCH_MAX_TIME_SPAN_IN_MS = parseInt(
@@ -73,7 +66,7 @@ function refreshGlobalOptionsForBatchedUpdate(options = {}) {
BATCH_RANGE_END = objectIdFromInput(options.BATCH_RANGE_END)
} else {
if (BATCH_DESCENDING) {
BATCH_RANGE_END = /** @type {ObjectId} */ (ID_EDGE_PAST)
BATCH_RANGE_END = ID_EDGE_PAST
} else {
BATCH_RANGE_END = ID_EDGE_FUTURE
}

Some files were not shown because too many files have changed in this diff Show More